A file-based task manager
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}