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 External(Url),
54}
55
56pub(crate) struct ParsedTask {
57 pub(crate) content: String,
58 pub(crate) links: Vec<ParsedLink>,
59}
60
61impl ParsedTask {
62 pub(crate) fn intenal_links(&self) -> HashSet<Id> {
63 let mut out = HashSet::with_capacity(self.links.len());
64 for link in &self.links {
65 if let ParsedLink::Internal(id) = link {
66 out.insert(*id);
67 }
68 }
69 out
70 }
71}
72
73pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
74 let mut state: Vec<ParserState> = Vec::new();
75 let mut out = String::with_capacity(s.len());
76 let mut stream = s.char_indices().peekable();
77 let mut links = Vec::new();
78 let mut last = '\0';
79 use ParserState::*;
80 loop {
81 let state_last = state.last().cloned();
82 match stream.next() {
83 // there will always be an op code in the stack
84 Some((char_pos, c)) => {
85 out.push(c);
86 let end = out.len() - 1;
87 match (last, c, state_last) {
88 ('[', '[', _) => {
89 state.push(InternalLink(end, char_pos));
90 }
91 (']', ']', Some(InternalLink(il, s_pos))) => {
92 state.pop();
93 let contents = s.get(s_pos + 1..char_pos - 1)?;
94 if let Ok(id) = Id::from_str(contents) {
95 let linktext = format!(
96 "{}{}",
97 contents.purple(),
98 super_num(links.len() + 1).purple()
99 );
100 out.replace_range(il - 1..out.len(), &linktext);
101 links.push(ParsedLink::Internal(id));
102 } else {
103 panic!("Internal link is not a valid id: {contents}");
104 }
105 }
106 (last, '[', _) if is_boundary(last) => {
107 state.push(Linktext(end, char_pos));
108 }
109 (']', '(', Some(Linktext(_, _))) => {
110 state.push(Link(end, char_pos));
111 }
112 (')', c, Some(Link(_, _))) if is_boundary(c) => {
113 // TODO: this needs to be updated to use `s` instead of `out` for position
114 // parsing
115 let linkpos = if let Link(lp, _) = state.pop().unwrap() {
116 lp
117 } else {
118 // remove the linktext state, it is always present.
119 state.pop();
120 continue;
121 };
122 let linktextpos = if let Linktext(lt, _) = state.pop().unwrap() {
123 lt
124 } else {
125 continue;
126 };
127 let linktext = format!(
128 "{}{}",
129 out.get(linktextpos + 1..linkpos - 1)?.blue(),
130 super_num(links.len() + 1).purple()
131 );
132 let link = out.get(linkpos + 1..end - 1)?;
133 if let Ok(url) = Url::parse(link) {
134 links.push(ParsedLink::External(url));
135 out.replace_range(linktextpos..end, &linktext);
136 }
137 }
138 ('>', c, Some(RawLink(hl, s_pos)))
139 if is_boundary(c) && s_pos != char_pos - 1 =>
140 {
141 state.pop();
142 let link = s.get(s_pos + 1..char_pos - 1)?;
143 if let Ok(url) = Url::parse(link) {
144 let linktext =
145 format!("{}{}", link.blue(), super_num(links.len() + 1).purple());
146 links.push(ParsedLink::External(url));
147 out.replace_range(hl..end, &linktext);
148 }
149 }
150 (last, '<', _) if is_boundary(last) => {
151 state.push(RawLink(end, char_pos));
152 }
153 ('=', c, Some(Highlight(hl, s_pos)))
154 if is_boundary(c) && s_pos != char_pos - 1 =>
155 {
156 state.pop();
157 out.replace_range(
158 hl..end,
159 &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(),
160 );
161 }
162 (last, '=', _) if is_boundary(last) => {
163 state.push(Highlight(end, char_pos));
164 }
165 (last, '*', _) if is_boundary(last) => {
166 state.push(Italics(end, char_pos));
167 }
168 ('*', c, Some(Italics(il, s_pos)))
169 if is_boundary(c) && s_pos != char_pos - 1 =>
170 {
171 state.pop();
172 out.replace_range(
173 il..end,
174 &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(),
175 );
176 }
177 (last, '!', _) if is_boundary(last) => {
178 state.push(Bold(end, char_pos));
179 }
180 ('!', c, Some(Bold(il, s_pos))) if is_boundary(c) && s_pos != char_pos - 1 => {
181 state.pop();
182 out.replace_range(
183 il..end,
184 &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(),
185 );
186 }
187 (last, '_', _) if is_boundary(last) => {
188 state.push(Underline(end, char_pos));
189 }
190 ('_', c, Some(Underline(il, s_pos)))
191 if is_boundary(c) && s_pos != char_pos - 1 =>
192 {
193 state.pop();
194 out.replace_range(
195 il..end,
196 &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(),
197 );
198 }
199 (last, '~', _) if is_boundary(last) => {
200 state.push(Strikethrough(end, char_pos));
201 }
202 ('~', c, Some(Strikethrough(il, s_pos)))
203 if is_boundary(c) && s_pos != char_pos - 1 =>
204 {
205 state.pop();
206 out.replace_range(
207 il..end,
208 &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(),
209 );
210 }
211 ('`', c, Some(InlineBlock(hl, s_pos)))
212 if is_boundary(c) && s_pos != char_pos - 1 =>
213 {
214 out.replace_range(
215 hl..end,
216 &s.get(s_pos + 1..char_pos - 1)?.green().to_string(),
217 );
218 }
219 (last, '`', _) if is_boundary(last) => {
220 state.push(InlineBlock(end, char_pos));
221 }
222 _ => (),
223 }
224 if c == '\n' || c == '\r' {
225 state.clear();
226 }
227 last = c;
228 }
229 None => break,
230 }
231 }
232 Some(ParsedTask {
233 content: out,
234 links,
235 })
236}
237
238/// Converts a unsigned integer into a superscripted string
239fn super_num(num: usize) -> String {
240 let num_str = num.to_string();
241 let mut out = String::with_capacity(num_str.len());
242 for char in num_str.chars() {
243 out.push(match char {
244 '0' => '⁰',
245 '1' => '¹',
246 '2' => '²',
247 '3' => '³',
248 '4' => '⁴',
249 '5' => '⁵',
250 '6' => '⁶',
251 '7' => '⁷',
252 '8' => '⁸',
253 '9' => '⁹',
254 _ => unreachable!(),
255 });
256 }
257 out
258}
259
260#[cfg(test)]
261mod test {
262 use super::*;
263 #[test]
264 fn test_highlight() {
265 let input = "hello =world=\n";
266 let output = parse(input).expect("parse to work");
267 assert_eq!("hello \u{1b}[7mworld\u{1b}[0m\n", output.content);
268 }
269
270 #[test]
271 fn test_highlight_bad() {
272 let input = "hello =world\n";
273 let output = parse(input).expect("parse to work");
274 assert_eq!(input, output.content);
275 }
276
277 #[test]
278 fn test_link() {
279 let input = "hello [world](https://ngp.computer)\n";
280 let output = parse(input).expect("parse to work");
281 assert_eq!(
282 &[ParsedLink::External(
283 Url::parse("https://ngp.computer").unwrap()
284 )],
285 output.links.as_slice()
286 );
287 assert_eq!(
288 "hello \u{1b}[34mworld\u{1b}[0m\u{1b}[35m¹\u{1b}[0m\n",
289 output.content
290 );
291 }
292
293 #[test]
294 fn test_link_no_terminal_link() {
295 let input = "hello [world](https://ngp.computer\n";
296 let output = parse(input).expect("parse to work");
297 assert!(output.links.is_empty());
298 assert_eq!(input, output.content);
299 }
300 #[test]
301 fn test_link_bad_no_start_link() {
302 let input = "hello [world]https://ngp.computer)\n";
303 let output = parse(input).expect("parse to work");
304 assert!(output.links.is_empty());
305 assert_eq!(input, output.content);
306 }
307 #[test]
308 fn test_link_bad_no_link() {
309 let input = "hello [world]\n";
310 let output = parse(input).expect("parse to work");
311 assert!(output.links.is_empty());
312 assert_eq!(input, output.content);
313 }
314
315 #[test]
316 fn test_internal_link_good() {
317 let input = "hello [[tsk-123]]\n";
318 let output = parse(input).expect("parse to work");
319 assert_eq!(&[ParsedLink::Internal(Id(123))], output.links.as_slice());
320 assert_eq!(
321 "hello \u{1b}[35mtsk-123\u{1b}[0m\u{1b}[35m¹\u{1b}[0m\n",
322 output.content
323 );
324 }
325
326 #[test]
327 fn test_internal_link_bad() {
328 let input = "hello [[tsk-123";
329 let output = parse(input).expect("parse to work");
330 assert!(output.links.is_empty());
331 assert_eq!(input, output.content);
332 }
333
334 #[test]
335 fn test_italics() {
336 let input = "hello *world*\n";
337 let output = parse(input).expect("parse to work");
338 assert_eq!("hello \u{1b}[3mworld\u{1b}[0m\n", output.content);
339 }
340
341 #[test]
342 fn test_italics_bad() {
343 let input = "hello *world";
344 let output = parse(input).expect("parse to work");
345 assert_eq!(input, output.content);
346 }
347
348 #[test]
349 fn test_bold() {
350 let input = "hello !world!\n";
351 let output = parse(input).expect("parse to work");
352 assert_eq!("hello \u{1b}[1mworld\u{1b}[0m\n", output.content);
353 }
354
355 #[test]
356 fn test_bold_bad() {
357 let input = "hello !world\n";
358 let output = parse(input).expect("parse to work");
359 assert_eq!(input, output.content);
360 }
361
362 #[test]
363 fn test_underline() {
364 let input = "hello _world_\n";
365 let output = parse(input).expect("parse to work");
366 assert_eq!("hello \u{1b}[4mworld\u{1b}[0m\n", output.content);
367 }
368
369 #[test]
370 fn test_underline_bad() {
371 let input = "hello _world\n";
372 let output = parse(input).expect("parse to work");
373 assert_eq!(input, output.content);
374 }
375
376 #[test]
377 fn test_strikethrough() {
378 let input = "hello ~world~\n";
379 let output = parse(input).expect("parse to work");
380 assert_eq!("hello \u{1b}[9mworld\u{1b}[0m\n", output.content);
381 }
382
383 #[test]
384 fn test_strikethrough_bad() {
385 let input = "hello ~world\n";
386 let output = parse(input).expect("parse to work");
387 assert_eq!(input, output.content);
388 }
389
390 #[test]
391 fn test_inlineblock() {
392 let input = "hello `world`\n";
393 let output = parse(input).expect("parse to work");
394 assert_eq!("hello \u{1b}[32mworld\u{1b}[0m\n", output.content);
395 }
396
397 #[test]
398 fn test_inlineblock_bad() {
399 let input = "hello `world\n";
400 let output = parse(input).expect("parse to work");
401 assert_eq!(input, output.content);
402 }
403
404 #[test]
405 fn test_multiple_styles() {
406 let input = "hello *italic* ~strikethrough~ !bold!\n";
407 let output = parse(input).expect("parse to work");
408 assert_eq!(
409 "hello \u{1b}[3mitalic\u{1b}[0m \u{1b}[9mstrikethrough\u{1b}[0m \u{1b}[1mbold\u{1b}[0m\n",
410 output.content
411 );
412 }
413}