A file-based task manager
at better-search 413 lines 15 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 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}