A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at fa5327e49123fbfc98101ddb019337980b6e5ef7 568 lines 20 kB view raw
1#![allow(dead_code)] 2 3use std::{collections::HashSet, str::FromStr}; 4use url::Url; 5 6use crate::workspace::Id; 7use colored::Colorize; 8 9/// Returns true if the character is a word boundary (whitespace or punctuation) 10fn is_boundary(c: char) -> bool { 11 c.is_whitespace() || c.is_ascii_punctuation() 12} 13 14#[derive(Debug, Eq, PartialEq, Clone, Copy)] 15enum ParserState { 16 // Started by ` =`, terminated by `= 17 Highlight(usize, usize), 18 // Started by ` [`, terminated by `](` 19 Linktext(usize, usize), 20 // Started by `](`, terminated by `) `, must immedately follow a Linktext 21 Link(usize, usize), 22 RawLink(usize, usize), 23 // Started by ` [[`, terminated by `]] ` 24 InternalLink(usize, usize), 25 // Started by ` *`, terminated by `* ` 26 Italics(usize, usize), 27 // Started by ` !`, termianted by `!` 28 Bold(usize, usize), 29 // Started by ` _`, terminated by `_ ` 30 Underline(usize, usize), 31 // Started by ` -`, terminated by `- ` 32 Strikethrough(usize, usize), 33 34 // TODO: implement these. 35 // Started by `_ `, terminated by `_` 36 UnorderedList(usize, u8), 37 // Started by `^\w+1.`, terminated by `\n` 38 OrderedList(usize, u8), 39 // Started by `^`````, terminated by a [`ParserOpcode::BlockEnd`] 40 BlockStart(usize), 41 // Started by `$````, is terminal itself. It must appear on its own line and be preceeded by a 42 // `\n` and followed by a `\n` 43 BlockEnd(usize), 44 // Started by ` ``, terminated by `` ` or `\n` 45 InlineBlock(usize, usize), 46 // Started by `^\w+>`, terminated by `\n` 47 Blockquote(usize), 48} 49 50#[derive(Debug, Eq, PartialEq, Clone)] 51pub(crate) enum ParsedLink { 52 Internal(Id), 53 Foreign { 54 prefix: String, 55 id: u32, 56 }, 57 /// `[[<namespace>/tsk-N]]` — a task in a sibling namespace of the same repo. 58 Namespaced { 59 namespace: String, 60 id: Id, 61 }, 62 External(Url), 63} 64 65pub(crate) struct ParsedTask { 66 pub(crate) content: String, 67 pub(crate) links: Vec<ParsedLink>, 68} 69 70impl ParsedTask { 71 pub(crate) fn intenal_links(&self) -> HashSet<Id> { 72 let mut out = HashSet::with_capacity(self.links.len()); 73 for link in &self.links { 74 if let ParsedLink::Internal(id) = link { 75 out.insert(*id); 76 } 77 } 78 out 79 } 80} 81 82pub(crate) fn parse(s: &str) -> Option<ParsedTask> { 83 let mut state: Vec<ParserState> = Vec::new(); 84 let mut out = String::with_capacity(s.len()); 85 let mut stream = s.char_indices().peekable(); 86 let mut links = Vec::new(); 87 let mut last = '\0'; 88 use ParserState::*; 89 loop { 90 let state_last = state.last().cloned(); 91 match stream.next() { 92 // there will always be an op code in the stack 93 Some((char_pos, c)) => { 94 out.push(c); 95 let end = out.len() - 1; 96 match (last, c, state_last) { 97 ('[', '[', _) => { 98 state.push(InternalLink(end, char_pos)); 99 } 100 (']', ']', Some(InternalLink(il, s_pos))) => { 101 state.pop(); 102 let contents = s.get(s_pos + 1..char_pos - 1)?; 103 let valid_ident = |s: &str| { 104 !s.is_empty() 105 && s.chars() 106 .all(|c| c.is_alphanumeric() || c == '_' || c == '-') 107 }; 108 if let Ok(id) = Id::from_str(contents) { 109 let linktext = format!( 110 "{}{}", 111 contents.purple(), 112 super_num(links.len() + 1).purple() 113 ); 114 out.replace_range(il - 1..out.len(), &linktext); 115 links.push(ParsedLink::Internal(id)); 116 } else if let Some((ns, rest)) = contents.split_once('/') 117 && valid_ident(ns) 118 && let Ok(id) = Id::from_str(rest) 119 { 120 let linktext = 121 format!("{}{}", contents.cyan(), super_num(links.len() + 1).cyan()); 122 out.replace_range(il - 1..out.len(), &linktext); 123 links.push(ParsedLink::Namespaced { 124 namespace: ns.to_string(), 125 id, 126 }); 127 } else if let Some((prefix, id_str)) = contents.split_once('-') 128 && let Ok(id) = id_str.parse::<u32>() 129 && valid_ident(prefix) 130 { 131 let linktext = 132 format!("{}{}", contents.cyan(), super_num(links.len() + 1).cyan()); 133 out.replace_range(il - 1..out.len(), &linktext); 134 links.push(ParsedLink::Foreign { 135 prefix: prefix.to_string(), 136 id, 137 }); 138 } 139 // If the bracketed text isn't a valid id, leave it in 140 // the output as-is (no link registered, no panic). 141 } 142 (last, '[', _) if is_boundary(last) => { 143 state.push(Linktext(end, char_pos)); 144 } 145 (']', '(', Some(Linktext(_, _))) => { 146 state.push(Link(end, char_pos)); 147 } 148 (')', c, Some(Link(_, _))) if is_boundary(c) => { 149 // TODO: this needs to be updated to use `s` instead of `out` for position 150 // parsing 151 let linkpos = if let Link(lp, _) = state.pop().unwrap() { 152 lp 153 } else { 154 // remove the linktext state, it is always present. 155 state.pop(); 156 continue; 157 }; 158 let linktextpos = if let Linktext(lt, _) = state.pop().unwrap() { 159 lt 160 } else { 161 continue; 162 }; 163 let linktext = format!( 164 "{}{}", 165 out.get(linktextpos + 1..linkpos - 1)?.blue(), 166 super_num(links.len() + 1).purple() 167 ); 168 let link = out.get(linkpos + 1..end - 1)?; 169 if let Ok(url) = Url::parse(link) { 170 links.push(ParsedLink::External(url)); 171 out.replace_range(linktextpos..end, &linktext); 172 } 173 } 174 ('>', c, Some(RawLink(hl, s_pos))) 175 if is_boundary(c) && s_pos != char_pos - 1 => 176 { 177 state.pop(); 178 let link = s.get(s_pos + 1..char_pos - 1)?; 179 if let Ok(url) = Url::parse(link) { 180 let linktext = 181 format!("{}{}", link.blue(), super_num(links.len() + 1).purple()); 182 links.push(ParsedLink::External(url)); 183 out.replace_range(hl..end, &linktext); 184 } 185 } 186 (last, '<', _) if is_boundary(last) => { 187 state.push(RawLink(end, char_pos)); 188 } 189 ('=', c, Some(Highlight(hl, s_pos))) 190 if is_boundary(c) && s_pos != char_pos - 1 => 191 { 192 state.pop(); 193 out.replace_range( 194 hl..end, 195 &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(), 196 ); 197 } 198 (last, '=', _) if is_boundary(last) => { 199 state.push(Highlight(end, char_pos)); 200 } 201 (last, '*', _) if is_boundary(last) => { 202 state.push(Italics(end, char_pos)); 203 } 204 ('*', c, Some(Italics(il, s_pos))) 205 if is_boundary(c) && s_pos != char_pos - 1 => 206 { 207 state.pop(); 208 out.replace_range( 209 il..end, 210 &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(), 211 ); 212 } 213 (last, '!', _) if is_boundary(last) => { 214 state.push(Bold(end, char_pos)); 215 } 216 ('!', c, Some(Bold(il, s_pos))) if is_boundary(c) && s_pos != char_pos - 1 => { 217 state.pop(); 218 out.replace_range( 219 il..end, 220 &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(), 221 ); 222 } 223 (last, '_', _) if is_boundary(last) => { 224 state.push(Underline(end, char_pos)); 225 } 226 ('_', c, Some(Underline(il, s_pos))) 227 if is_boundary(c) && s_pos != char_pos - 1 => 228 { 229 state.pop(); 230 out.replace_range( 231 il..end, 232 &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(), 233 ); 234 } 235 (last, '~', _) if is_boundary(last) => { 236 state.push(Strikethrough(end, char_pos)); 237 } 238 ('~', c, Some(Strikethrough(il, s_pos))) 239 if is_boundary(c) && s_pos != char_pos - 1 => 240 { 241 state.pop(); 242 out.replace_range( 243 il..end, 244 &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(), 245 ); 246 } 247 ('`', c, Some(InlineBlock(hl, s_pos))) 248 if is_boundary(c) && s_pos != char_pos - 1 => 249 { 250 out.replace_range( 251 hl..end, 252 &s.get(s_pos + 1..char_pos - 1)?.green().to_string(), 253 ); 254 } 255 (last, '`', _) if is_boundary(last) => { 256 state.push(InlineBlock(end, char_pos)); 257 } 258 _ => (), 259 } 260 if c == '\n' || c == '\r' { 261 state.clear(); 262 } 263 last = c; 264 } 265 None => break, 266 } 267 } 268 Some(ParsedTask { 269 content: out, 270 links, 271 }) 272} 273 274/// Converts a unsigned integer into a superscripted string 275fn super_num(num: usize) -> String { 276 let num_str = num.to_string(); 277 let mut out = String::with_capacity(num_str.len()); 278 for char in num_str.chars() { 279 out.push(match char { 280 '0' => '⁰', 281 '1' => '¹', 282 '2' => '²', 283 '3' => '³', 284 '4' => '⁴', 285 '5' => '⁵', 286 '6' => '⁶', 287 '7' => '⁷', 288 '8' => '⁸', 289 '9' => '⁹', 290 _ => unreachable!(), 291 }); 292 } 293 out 294} 295 296#[cfg(test)] 297mod test { 298 use super::*; 299 300 fn setup() { 301 colored::control::set_override(true); 302 } 303 304 #[test] 305 fn test_highlight() { 306 setup(); 307 let input = "hello =world=\n"; 308 let output = parse(input).expect("parse to work"); 309 assert_eq!("hello \u{1b}[7mworld\u{1b}[0m\n", output.content); 310 } 311 312 #[test] 313 fn test_highlight_bad() { 314 setup(); 315 let input = "hello =world\n"; 316 let output = parse(input).expect("parse to work"); 317 assert_eq!(input, output.content); 318 } 319 320 #[test] 321 fn test_link() { 322 setup(); 323 let input = "hello [world](https://ngp.computer)\n"; 324 let output = parse(input).expect("parse to work"); 325 assert_eq!( 326 &[ParsedLink::External( 327 Url::parse("https://ngp.computer").unwrap() 328 )], 329 output.links.as_slice() 330 ); 331 assert_eq!( 332 "hello \u{1b}[34mworld\u{1b}[0m\u{1b}[35m¹\u{1b}[0m\n", 333 output.content 334 ); 335 } 336 337 #[test] 338 fn test_link_no_terminal_link() { 339 setup(); 340 let input = "hello [world](https://ngp.computer\n"; 341 let output = parse(input).expect("parse to work"); 342 assert!(output.links.is_empty()); 343 assert_eq!(input, output.content); 344 } 345 #[test] 346 fn test_link_bad_no_start_link() { 347 setup(); 348 let input = "hello [world]https://ngp.computer)\n"; 349 let output = parse(input).expect("parse to work"); 350 assert!(output.links.is_empty()); 351 assert_eq!(input, output.content); 352 } 353 #[test] 354 fn test_link_bad_no_link() { 355 setup(); 356 let input = "hello [world]\n"; 357 let output = parse(input).expect("parse to work"); 358 assert!(output.links.is_empty()); 359 assert_eq!(input, output.content); 360 } 361 362 #[test] 363 fn test_internal_link_good() { 364 setup(); 365 let input = "hello [[tsk-123]]\n"; 366 let output = parse(input).expect("parse to work"); 367 assert_eq!(&[ParsedLink::Internal(Id(123))], output.links.as_slice()); 368 assert_eq!( 369 "hello \u{1b}[35mtsk-123\u{1b}[0m\u{1b}[35m¹\u{1b}[0m\n", 370 output.content 371 ); 372 } 373 374 #[test] 375 fn test_internal_link_bad() { 376 setup(); 377 let input = "hello [[tsk-123"; 378 let output = parse(input).expect("parse to work"); 379 assert!(output.links.is_empty()); 380 assert_eq!(input, output.content); 381 } 382 383 #[test] 384 fn test_italics() { 385 setup(); 386 let input = "hello *world*\n"; 387 let output = parse(input).expect("parse to work"); 388 assert_eq!("hello \u{1b}[3mworld\u{1b}[0m\n", output.content); 389 } 390 391 #[test] 392 fn test_italics_bad() { 393 setup(); 394 let input = "hello *world"; 395 let output = parse(input).expect("parse to work"); 396 assert_eq!(input, output.content); 397 } 398 399 #[test] 400 fn test_bold() { 401 setup(); 402 let input = "hello !world!\n"; 403 let output = parse(input).expect("parse to work"); 404 assert_eq!("hello \u{1b}[1mworld\u{1b}[0m\n", output.content); 405 } 406 407 #[test] 408 fn test_bold_bad() { 409 setup(); 410 let input = "hello !world\n"; 411 let output = parse(input).expect("parse to work"); 412 assert_eq!(input, output.content); 413 } 414 415 #[test] 416 fn test_underline() { 417 setup(); 418 let input = "hello _world_\n"; 419 let output = parse(input).expect("parse to work"); 420 assert_eq!("hello \u{1b}[4mworld\u{1b}[0m\n", output.content); 421 } 422 423 #[test] 424 fn test_underline_bad() { 425 setup(); 426 let input = "hello _world\n"; 427 let output = parse(input).expect("parse to work"); 428 assert_eq!(input, output.content); 429 } 430 431 #[test] 432 fn test_strikethrough() { 433 setup(); 434 let input = "hello ~world~\n"; 435 let output = parse(input).expect("parse to work"); 436 assert_eq!("hello \u{1b}[9mworld\u{1b}[0m\n", output.content); 437 } 438 439 #[test] 440 fn test_strikethrough_bad() { 441 setup(); 442 let input = "hello ~world\n"; 443 let output = parse(input).expect("parse to work"); 444 assert_eq!(input, output.content); 445 } 446 447 #[test] 448 fn test_inlineblock() { 449 setup(); 450 let input = "hello `world`\n"; 451 let output = parse(input).expect("parse to work"); 452 assert_eq!("hello \u{1b}[32mworld\u{1b}[0m\n", output.content); 453 } 454 455 #[test] 456 fn test_inlineblock_bad() { 457 setup(); 458 let input = "hello `world\n"; 459 let output = parse(input).expect("parse to work"); 460 assert_eq!(input, output.content); 461 } 462 463 #[test] 464 fn test_multiple_styles() { 465 setup(); 466 let input = "hello *italic* ~strikethrough~ !bold!\n"; 467 let output = parse(input).expect("parse to work"); 468 assert_eq!( 469 "hello \u{1b}[3mitalic\u{1b}[0m \u{1b}[9mstrikethrough\u{1b}[0m \u{1b}[1mbold\u{1b}[0m\n", 470 output.content 471 ); 472 } 473 474 #[test] 475 fn test_foreign_link_jira() { 476 setup(); 477 let input = "see [[jira-123]]\n"; 478 let output = parse(input).expect("parse to work"); 479 assert_eq!( 480 &[ParsedLink::Foreign { 481 prefix: "jira".to_string(), 482 id: 123 483 }], 484 output.links.as_slice() 485 ); 486 } 487 488 #[test] 489 fn test_foreign_link_gitlab() { 490 setup(); 491 let input = "related to [[gl-456]]\n"; 492 let output = parse(input).expect("parse to work"); 493 assert_eq!( 494 &[ParsedLink::Foreign { 495 prefix: "gl".to_string(), 496 id: 456 497 }], 498 output.links.as_slice() 499 ); 500 } 501 502 #[test] 503 fn test_mixed_internal_and_foreign_links() { 504 setup(); 505 let input = "see [[tsk-1]] and [[jira-99]]\n"; 506 let output = parse(input).expect("parse to work"); 507 assert_eq!( 508 &[ 509 ParsedLink::Internal(Id(1)), 510 ParsedLink::Foreign { 511 prefix: "jira".to_string(), 512 id: 99 513 } 514 ], 515 output.links.as_slice() 516 ); 517 } 518 519 /// `[[jira-abc]]` looks like a foreign link but the id portion isn't 520 /// numeric. The parser must not panic; the bracketed text is left as plain 521 /// content and no link is registered. 522 #[test] 523 fn test_foreign_link_bad_no_number() { 524 setup(); 525 let input = "see [[jira-abc]]\n"; 526 let output = parse(input).expect("parse to work"); 527 assert!(output.links.is_empty()); 528 assert_eq!(input, output.content); 529 } 530 531 /// `[[plain text]]` doesn't match any link form. Same as above: no panic, 532 /// content preserved, no link registered. 533 #[test] 534 fn test_internal_link_unparseable_contents() { 535 setup(); 536 let input = "see [[not a link]]\n"; 537 let output = parse(input).expect("parse to work"); 538 assert!(output.links.is_empty()); 539 assert_eq!(input, output.content); 540 } 541 542 /// `[[<namespace>/tsk-N]]` registers as a Namespaced link — used for 543 /// cross-namespace references within a single git repo. 544 #[test] 545 fn test_namespaced_link() { 546 setup(); 547 let input = "see [[ns/tsk-12]]\n"; 548 let output = parse(input).expect("parse to work"); 549 assert_eq!( 550 &[ParsedLink::Namespaced { 551 namespace: "ns".into(), 552 id: Id(12) 553 }], 554 output.links.as_slice() 555 ); 556 } 557 558 /// A bracketed phrase whose namespace segment contains a non-ident 559 /// character isn't a valid link; keep the text and don't register one. 560 #[test] 561 fn test_namespaced_link_invalid_namespace() { 562 setup(); 563 let input = "see [[a b/tsk-12]]\n"; 564 let output = parse(input).expect("parse to work"); 565 assert!(output.links.is_empty(), "{:?}", output.links); 566 assert_eq!(input, output.content); 567 } 568}