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