A file-based task manager

Kinda working, see ext.

So it mostly works if you only use one formatting no a line, however as
soon as you use multiple, the escape characters change the positions of
the corresponding text which messes up the final rendering. I need to do
something significantly smarter for tracking character positions.

+243 -74
+6
.tsk/archive/tsk-14.tsk
··· 1 1 parse internal links from body 2 2 3 + This is some !test bold text!. 4 + here's some =highlighted text= 5 + 6 + and finally some *italics!* 7 + 8 + here's [a link](https://ngp.computer).
+7 -3
src/main.rs
··· 2 2 mod errors; 3 3 mod fzf; 4 4 mod stack; 5 + mod task; 5 6 mod util; 6 7 mod workspace; 7 - mod task; 8 8 use clap_complete::{generate, Shell}; 9 9 use errors::Result; 10 - use std::io; 10 + use std::io::{self, Write}; 11 11 use std::path::PathBuf; 12 12 use std::process::exit; 13 13 use std::{env::current_dir, io::Read}; ··· 359 359 } 360 360 println!("---"); 361 361 } 362 - println!("{task}"); 362 + if let Some(styled_task) = task::parse(&task.to_string()) { 363 + writeln!(io::stdout(), "{}", styled_task.content)?; 364 + } else { 365 + println!("{task}"); 366 + } 363 367 Ok(()) 364 368 }
+230 -71
src/task.rs
··· 1 1 #![allow(dead_code)] 2 + 3 + use std::str::FromStr; 2 4 use url::Url; 3 5 4 - use crate::{errors::Error, workspace::Id}; 6 + use crate::workspace::Id; 5 7 use colored::Colorize; 6 8 7 9 #[derive(Debug, Eq, PartialEq, Clone, Copy)] 8 - enum ParserOpcode { 10 + enum ParserState { 9 11 // Started by ` =`, terminated by `= 10 12 Highlight(usize), 11 13 // Started by ` [`, terminated by `](` 12 14 Linktext(usize), 13 15 // Started by `](`, terminated by `) `, must immedately follow a Linktext 14 16 Link(usize), 15 - // Used to signal to the parser that the Linktext parsed properly and we should parse the 16 - // subsequent ( character as a 17 - LinkJoin, 18 17 // Started by ` [[`, terminated by `]] ` 19 18 InternalLink(usize), 20 19 // Started by ` *`, terminated by `* ` ··· 25 24 Underline(usize), 26 25 // Started by ` -`, terminated by `- ` 27 26 Strikethrough(usize), 27 + 28 + // TODO: implement these. 28 29 // Started by `_ `, terminated by `_` 29 30 UnorderedList(usize, u8), 30 31 // Started by `^\w+1.`, terminated by `\n` ··· 40 41 Blockquote(usize), 41 42 } 42 43 43 - pub(crate) struct ParsedTask { 44 - content: String, 45 - outgoing_internal_links: Vec<Id>, 46 - links: Vec<Url>, 44 + #[derive(Debug, Eq, PartialEq, Clone)] 45 + pub(crate) enum ParsedLink { 46 + Internal(Id), 47 + External(Url), 47 48 } 48 49 49 - #[derive(Default)] 50 - struct ParseState { 51 - highlight: Option<usize>, 52 - link: Option<usize>, 53 - internal: Option<usize>, 54 - italics: Option<usize>, 55 - bold: Option<usize>, 56 - underline: Option<usize>, 57 - strikethrough: Option<usize>, 58 - block: bool, 59 - inline: Option<usize>, 60 - quote: Option<(usize, u8)>, 50 + pub(crate) struct ParsedTask { 51 + pub(crate) content: String, 52 + pub(crate) links: Vec<ParsedLink>, 61 53 } 62 54 63 55 pub(crate) fn parse(s: &str) -> Option<ParsedTask> { 64 - let mut state = ParseState::default(); 65 - let mut out = s.to_string(); 56 + let mut state: Vec<ParserState> = Vec::new(); 57 + let mut out = String::with_capacity(s.len()); 66 58 let mut stream = s.char_indices().peekable(); 67 - let outgoing_internal_links = Vec::new(); 68 - let links = Vec::new(); 59 + let mut links = Vec::new(); 69 60 let mut last = '\0'; 61 + use ParserState::*; 70 62 loop { 63 + let state_last = state.last().cloned(); 71 64 match stream.next() { 72 65 // there will always be an op code in the stack 73 66 Some((pos, c)) => { 74 - match (last, c, &state) { 75 - ( 76 - ' ', 77 - '=', 78 - ParseState { 79 - highlight: Some(hl), 80 - .. 81 - }, 82 - ) 83 - | ( 84 - '=', 85 - ' ', 86 - ParseState { 87 - highlight: Some(hl), 88 - .. 89 - }, 90 - ) 91 - | ( 92 - '=', 93 - '\n', 94 - ParseState { 95 - highlight: Some(hl), 96 - .. 97 - }, 98 - ) => { 67 + if c == '\n' || c == '\r' { 68 + state.clear(); 69 + } 70 + match (last, c, state_last) { 71 + ('=', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Highlight(hl))) => { 72 + state.pop(); 73 + out.replace_range( 74 + hl..pos, 75 + &out.get(hl + 1..pos - 1)?.reversed().to_string(), 76 + ); 77 + } 78 + (' ' | '\r' | '\n', '=', _) => { 79 + state.push(Highlight(pos)); 80 + } 81 + ('[', '[', _) => { 82 + state.push(InternalLink(pos)); 83 + } 84 + (']', ']', Some(InternalLink(il))) => { 85 + state.pop(); 86 + let contents = out.get(il + 1..pos - 1)?; 87 + if let Ok(id) = Id::from_str(&contents) { 88 + let linktext = format!( 89 + "{}{}", 90 + contents.purple(), 91 + super_num(links.len() + 1).purple() 92 + ); 93 + out.replace_range(il..pos, &linktext); 94 + links.push(ParsedLink::Internal(id)); 95 + } else { 96 + panic!("Internal link is not a valid id: {contents}"); 97 + } 98 + } 99 + (' ' | '\r' | '\n', '[', _) => { 100 + state.push(Linktext(pos)); 101 + } 102 + (']', '(', Some(Linktext(_))) => { 103 + state.push(Link(pos)); 104 + } 105 + (')', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Link(_))) => { 106 + let linkpos = if let Link(lp) = state.pop().unwrap() { 107 + lp 108 + } else { 109 + // remove the linktext state, it is always present. 110 + state.pop(); 111 + continue; 112 + }; 113 + let linktextpos = if let Linktext(lt) = state.pop().unwrap() { 114 + lt 115 + } else { 116 + continue; 117 + }; 118 + let linktext = format!( 119 + "{}{}", 120 + out.get(linktextpos + 1..linkpos - 1)?.blue(), 121 + super_num(links.len() + 1).purple() 122 + ); 123 + let link = out.get(linkpos + 1..pos - 1)?; 124 + if let Ok(url) = Url::parse(link) { 125 + links.push(ParsedLink::External(url)); 126 + out.replace_range(linktextpos..pos - 1, &linktext); 127 + } 128 + } 129 + (' ' | '\r' | '\n', '*', _) => { 130 + state.push(Italics(pos)); 131 + } 132 + ('*', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Italics(il))) => { 133 + state.pop(); 134 + out.replace_range(il..pos, &out.get(il + 1..pos - 1)?.italic().to_string()); 135 + } 136 + (' ' | '\r' | '\n', '!', _) => { 137 + state.push(Bold(pos)); 138 + } 139 + ('!', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Bold(il))) => { 140 + state.pop(); 141 + out.replace_range(il..pos, &out.get(il + 1..pos - 1)?.bold().to_string()); 142 + } 143 + (' ' | '\r' | '\n', '_', _) => { 144 + state.push(Underline(pos)); 145 + } 146 + ('_', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Underline(il))) => { 147 + state.pop(); 99 148 out.replace_range( 100 - *hl..pos, 101 - &out.get(*hl + 1..pos - 1)?.reversed().to_string(), 149 + il..pos, 150 + &out.get(il + 1..pos - 1)?.underline().to_string(), 102 151 ); 103 152 } 104 - ( 105 - ' ', 106 - '=', 107 - ParseState { 108 - highlight: None, .. 109 - }, 110 - ) => { 111 - state.highlight = Some(pos); 153 + (' ' | '\r' | '\n', '~', _) => { 154 + state.push(Strikethrough(pos)); 112 155 } 113 - 156 + ('~', ' ' | '\n' | '\r' | '.' | '!' | '?', Some(Strikethrough(il))) => { 157 + state.pop(); 158 + out.replace_range( 159 + il..pos, 160 + &out.get(il + 1..pos - 1)?.strikethrough().to_string(), 161 + ); 162 + } 114 163 _ => (), 115 164 } 116 165 last = c; ··· 120 169 } 121 170 Some(ParsedTask { 122 171 content: out, 123 - outgoing_internal_links, 124 172 links, 125 173 }) 126 174 } 127 175 176 + /// Converts a unsigned integer into a superscripted string 177 + fn super_num(num: usize) -> String { 178 + let num_str = num.to_string(); 179 + let mut out = String::with_capacity(num_str.len()); 180 + for char in num_str.chars() { 181 + out.push(match char { 182 + '0' => '⁰', 183 + '1' => '¹', 184 + '2' => '²', 185 + '3' => '³', 186 + '4' => '⁴', 187 + '5' => '⁵', 188 + '6' => '⁶', 189 + '7' => '⁷', 190 + '8' => '⁸', 191 + '9' => '⁹', 192 + _ => unreachable!(), 193 + }); 194 + } 195 + out 196 + } 197 + 128 198 #[cfg(test)] 129 199 mod test { 130 200 use super::*; ··· 137 207 138 208 #[test] 139 209 fn test_highlight_bad() { 140 - let input = "hello =world"; 210 + let input = "hello =world\n"; 141 211 let output = parse(input).expect("parse to work"); 142 - assert_eq!("hello =world", output.content); 212 + assert_eq!(input, output.content); 143 213 } 144 214 145 215 #[test] 146 216 fn test_link() { 147 - let input = "hello [world](https://ngp.computer)"; 217 + let input = "hello [world](https://ngp.computer)\n"; 148 218 let output = parse(input).expect("parse to work"); 149 219 assert_eq!( 150 - &[Url::parse("https://ngp.computer").unwrap()], 220 + &[ParsedLink::External( 221 + Url::parse("https://ngp.computer").unwrap() 222 + )], 151 223 output.links.as_slice() 152 224 ); 153 - assert_eq!("hello \u{1b}[4;94mworld\u{1b}[0m", output.content); 225 + assert_eq!( 226 + "hello \u{1b}[34mworld\u{1b}[0m\u{1b}[35m¹\u{1b}[0m)\n", 227 + output.content 228 + ); 154 229 } 155 230 156 - #[ignore = "Known styling bug"] 157 231 #[test] 158 232 fn test_link_no_terminal_link() { 159 - let input = "hello [world](https://ngp.computer"; 233 + let input = "hello [world](https://ngp.computer\n"; 160 234 let output = parse(input).expect("parse to work"); 161 235 assert!(output.links.len() == 0); 162 236 assert_eq!(input, output.content); 163 237 } 164 238 #[test] 165 239 fn test_link_bad_no_start_link() { 166 - let input = "hello [world]https://ngp.computer)"; 240 + let input = "hello [world]https://ngp.computer)\n"; 167 241 let output = parse(input).expect("parse to work"); 168 242 assert!(output.links.len() == 0); 169 243 assert_eq!(input, output.content); 170 244 } 171 245 #[test] 172 246 fn test_link_bad_no_link() { 173 - let input = "hello [world]"; 247 + let input = "hello [world]\n"; 174 248 let output = parse(input).expect("parse to work"); 175 249 assert!(output.links.len() == 0); 176 250 assert_eq!(input, output.content); 251 + } 252 + 253 + #[test] 254 + fn test_internal_link_good() { 255 + let input = "hello [[tsk-123]]\n"; 256 + let output = parse(input).expect("parse to work"); 257 + assert_eq!(&[ParsedLink::Internal(Id(123))], output.links.as_slice()); 258 + assert_eq!( 259 + "hello [\u{1b}[35mtsk-123\u{1b}[0m\u{1b}[35m¹\u{1b}[0m]\n", 260 + output.content 261 + ); 262 + } 263 + 264 + #[test] 265 + fn test_internal_link_bad() { 266 + let input = "hello [[tsk-123"; 267 + let output = parse(input).expect("parse to work"); 268 + assert!(output.links.len() == 0); 269 + assert_eq!(input, output.content); 270 + } 271 + 272 + #[test] 273 + fn test_italics() { 274 + let input = "hello *world*\n"; 275 + let output = parse(input).expect("parse to work"); 276 + assert_eq!("hello \u{1b}[3mworld\u{1b}[0m\n", output.content); 277 + } 278 + 279 + #[test] 280 + fn test_italics_bad() { 281 + let input = "hello *world"; 282 + let output = parse(input).expect("parse to work"); 283 + assert_eq!(input, output.content); 284 + } 285 + 286 + #[test] 287 + fn test_bold() { 288 + let input = "hello !world!\n"; 289 + let output = parse(input).expect("parse to work"); 290 + assert_eq!("hello \u{1b}[1mworld\u{1b}[0m\n", output.content); 291 + } 292 + 293 + #[test] 294 + fn test_bold_bad() { 295 + let input = "hello !world\n"; 296 + let output = parse(input).expect("parse to work"); 297 + assert_eq!(input, output.content); 298 + } 299 + 300 + #[test] 301 + fn test_underline() { 302 + let input = "hello _world_\n"; 303 + let output = parse(input).expect("parse to work"); 304 + assert_eq!("hello \u{1b}[4mworld\u{1b}[0m\n", output.content); 305 + } 306 + 307 + #[test] 308 + fn test_underline_bad() { 309 + let input = "hello _world\n"; 310 + let output = parse(input).expect("parse to work"); 311 + assert_eq!(input, output.content); 312 + } 313 + 314 + #[test] 315 + fn test_strikethrough() { 316 + let input = "hello ~world~\n"; 317 + let output = parse(input).expect("parse to work"); 318 + assert_eq!("hello \u{1b}[9mworld\u{1b}[0m\n", output.content); 319 + } 320 + 321 + #[test] 322 + fn test_strikethrough_bad() { 323 + let input = "hello ~world\n"; 324 + let output = parse(input).expect("parse to work"); 325 + assert_eq!(input, output.content); 326 + } 327 + 328 + #[test] 329 + fn test_multiple_styles() { 330 + let input = "hello *world* ~world~ !world!\n"; 331 + let output = parse(input).expect("parse to work"); 332 + assert_eq!( 333 + "hello \u{1b}[3mworld\u{1b}[0m \u{1b}[9mworld\u{1b}[0m \u{1b}[1mworld\u{1b}[0m\n", 334 + output.content 335 + ); 177 336 } 178 337 }