git.lucas.co / cce-vault
notes vault library and CLI (Obsidian-compatible)
git clone https://git.lucas.co/cce-vault.git

src/parse.rs (28.5K)

  1 //! One note's text, parsed into what the index needs: frontmatter
  2 //! properties, links, tags, headings, block ids and tasks.
  3 //!
  4 //! The split of labour is deliberate. pulldown-cmark knows CommonMark, so it
  5 //! decides what is code (fenced, indented, inline), what is math, where the
  6 //! headings are and where the inline `[text](dest)` links are. Everything
  7 //! Obsidian adds on top — `[[wikilinks]]`, `![[embeds]]`, `#tags`, `^block`
  8 //! ids, `%%comments%%`, task statuses other than `x` — is scanned from the
  9 //! raw bytes by hand, skipping the ranges pulldown called code. Scanning raw
 10 //! text rather than pulldown's `Text` events is what gives every link an
 11 //! exact byte span, which the rename path needs to rewrite a link in place
 12 //! without touching the bytes around it; `Text` events split at arbitrary
 13 //! punctuation and never carry the brackets.
 14 //!
 15 //! pulldown's own `ENABLE_WIKILINKS` stays off: it would turn `[[x]]` into
 16 //! link events we would then have to reconcile with the scanner, and it has
 17 //! no notion of `![[embed]]` or `\|` inside tables.
 18 
 19 use std::ops::Range;
 20 
 21 use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
 22 use serde::{Deserialize, Serialize};
 23 use serde_json::{Map, Value};
 24 
 25 /// Frontmatter properties, in file order. An `IndexMap` rather than
 26 /// `serde_json`'s `preserve_order` feature, which would unify across the
 27 /// workspace and reorder every other crate's JSON (see `canvas`). Maps
 28 /// nested inside a value are ordinary sorted `serde_json` maps.
 29 pub type Properties = indexmap::IndexMap<String, Value>;
 30 
 31 #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
 32 pub struct Note {
 33     pub properties: Properties,
 34     /// Byte range of the whole frontmatter block, delimiters included.
 35     pub frontmatter: Option<Range<usize>>,
 36     pub links: Vec<Link>,
 37     pub tags: Vec<NoteTag>,
 38     pub headings: Vec<Heading>,
 39     pub blocks: Vec<BlockId>,
 40     pub tasks: Vec<Task>,
 41 }
 42 
 43 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 44 #[serde(rename_all = "snake_case")]
 45 pub enum LinkKind {
 46     /// `[[target#sub|display]]`, or `![[...]]` when `embed`.
 47     Wiki,
 48     /// `[display](target)`, or `![alt](target)` when `embed`.
 49     Markdown,
 50     /// A canvas `file` node. Spans are empty: the rewrite edits the node's
 51     /// `file` field instead (see `canvas`).
 52     CanvasFile,
 53 }
 54 
 55 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 56 pub struct Link {
 57     pub kind: LinkKind,
 58     pub embed: bool,
 59     /// The link path with any `#heading` / `#^block` subpath removed.
 60     /// Markdown targets are percent-decoded.
 61     pub target: String,
 62     /// Heading or block reference, without the leading `#`
 63     /// (`Heading`, `^block-id`).
 64     pub subpath: Option<String>,
 65     pub display: Option<String>,
 66     /// The whole link, from `!` or the first bracket to the last.
 67     pub span: Range<usize>,
 68     /// The bytes of the link path alone, as written (still encoded for a
 69     /// markdown link). A rename replaces exactly these bytes.
 70     pub target_span: Range<usize>,
 71     /// 0-based line of `span.start`.
 72     pub line: usize,
 73     /// The canvas node the link came from; `None` in a Markdown note.
 74     #[serde(default, skip_serializing_if = "Option::is_none")]
 75     pub node: Option<String>,
 76 }
 77 
 78 /// A tag, without its `#`. (`Tag` is pulldown's name.)
 79 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 80 pub struct NoteTag {
 81     pub name: String,
 82     /// `None` for a tag that came from the `tags` property.
 83     pub line: Option<usize>,
 84 }
 85 
 86 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 87 pub struct Heading {
 88     pub level: u8,
 89     pub text: String,
 90     pub line: usize,
 91 }
 92 
 93 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 94 pub struct BlockId {
 95     pub id: String,
 96     pub line: usize,
 97 }
 98 
 99 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100 pub struct Task {
101     /// The character between the brackets: ' ' open, 'x' done, and the
102     /// custom statuses themes and the Tasks plugin use ('/', '-', '>', …).
103     pub status: char,
104     pub text: String,
105     pub line: usize,
106     /// Byte offset of the status character, for toggling in place.
107     pub status_at: usize,
108 }
109 
110 impl Task {
111     /// Open means still to do: a blank box, or `/` (in progress) as the
112     /// Tasks plugin uses it. Everything else — `x`, `-` cancelled,
113     /// `>` forwarded — is closed.
114     pub fn is_open(&self) -> bool {
115         matches!(self.status, ' ' | '/')
116     }
117 }
118 
119 /// Parse a note's full text.
120 pub fn parse(src: &str) -> Note {
121     let lines = LineIndex::new(src);
122     let mut note = Note::default();
123 
124     let body_start = match frontmatter_range(src) {
125         Some((whole, inner)) => {
126             note.properties = parse_properties(&src[inner]);
127             note.frontmatter = Some(whole.clone());
128             whole.end
129         }
130         None => 0,
131     };
132     for name in property_tags(&note.properties) {
133         note.tags.push(NoteTag { name, line: None });
134     }
135 
136     // Everything code-like is invisible to the Obsidian scanners.
137     let mut excluded: Vec<Range<usize>> = Vec::new();
138     let mut md_links: Vec<Link> = Vec::new();
139     let mut heading: Option<(u8, usize, String)> = None;
140 
141     let opts = Options::ENABLE_TABLES
142         | Options::ENABLE_FOOTNOTES
143         | Options::ENABLE_STRIKETHROUGH
144         | Options::ENABLE_TASKLISTS
145         | Options::ENABLE_MATH
146         | Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
147     for (event, range) in Parser::new_ext(src, opts).into_offset_iter() {
148         match event {
149             Event::Start(Tag::CodeBlock(_)) => excluded.push(range),
150             Event::Code(text) => {
151                 excluded.push(range);
152                 if let Some((_, _, buf)) = heading.as_mut() {
153                     buf.push_str(&text);
154                 }
155             }
156             Event::InlineMath(_) | Event::DisplayMath(_) => excluded.push(range),
157             Event::Start(Tag::Heading { level, .. }) => {
158                 heading = Some((level as u8, range.start, String::new()))
159             }
160             Event::Text(text) => {
161                 if let Some((_, _, buf)) = heading.as_mut() {
162                     buf.push_str(&text);
163                 }
164             }
165             Event::End(TagEnd::Heading(_)) => {
166                 if let Some((level, start, text)) = heading.take() {
167                     note.headings.push(Heading {
168                         level,
169                         text: text.trim().to_string(),
170                         line: lines.line_of(start),
171                     });
172                 }
173             }
174             Event::Start(Tag::Link { link_type: pulldown_cmark::LinkType::Inline, .. }) => {
175                 if let Some(link) = markdown_link(src, range, false, &lines) {
176                     md_links.push(link);
177                 }
178             }
179             Event::Start(Tag::Image { link_type: pulldown_cmark::LinkType::Inline, .. }) => {
180                 if let Some(link) = markdown_link(src, range, true, &lines) {
181                     md_links.push(link);
182                 }
183             }
184             _ => {}
185         }
186     }
187     excluded.sort_by_key(|r| r.start);
188     let comments = comment_ranges(src, body_start, &excluded);
189     excluded.extend(comments.iter().cloned());
190     excluded.sort_by_key(|r| r.start);
191     let skip = Ranges(excluded);
192 
193     // Headings pulldown found inside a %%comment%% are not headings, and
194     // neither is anything it read in a frontmatter block it did not
195     // recognise as one (`---` can also be a rule or a setext underline).
196     let comment_skip = Ranges(comments);
197     note.headings.retain(|h| {
198         let at = lines.start_of(h.line);
199         at >= body_start && !comment_skip.contains(at)
200     });
201 
202     // Wikilinks are scanned over the frontmatter too: Obsidian indexes a
203     // `related: "[[Other]]"` property as a link.
204     let wikis = wikilinks(src, &skip, &lines);
205     let mut links: Vec<Link> = wikis;
206     links.extend(md_links.into_iter().filter(|l| !skip.contains(l.span.start)));
207     links.sort_by_key(|l| l.span.start);
208     note.links = links;
209 
210     let link_spans = Ranges(note.links.iter().map(|l| l.span.clone()).collect());
211     scan_tags(src, body_start, &skip, &link_spans, &lines, &mut note.tags);
212     scan_lines(src, body_start, &skip, &lines, &mut note);
213     note
214 }
215 
216 /// `---\n...\n---` at the very top of the file: (whole block, inner YAML).
217 pub fn frontmatter_range(src: &str) -> Option<(Range<usize>, Range<usize>)> {
218     let first_end = src.find('\n')?;
219     if src[..first_end].trim_end_matches('\r') != "---" {
220         return None;
221     }
222     let inner_start = first_end + 1;
223     let mut pos = inner_start;
224     while pos <= src.len() {
225         let end = src[pos..].find('\n').map(|i| pos + i).unwrap_or(src.len());
226         if src[pos..end].trim_end_matches('\r') == "---" {
227             let whole_end = if end < src.len() { end + 1 } else { end };
228             return Some((0..whole_end, inner_start..pos));
229         }
230         if end == src.len() {
231             break;
232         }
233         pos = end + 1;
234     }
235     None
236 }
237 
238 /// YAML → JSON values. A block that does not parse, or is not a mapping,
239 /// yields no properties rather than an error: Obsidian shows such a note
240 /// with its frontmatter as plain text, and the index should still hold it.
241 pub fn parse_properties(yaml: &str) -> Properties {
242     let docs = match yaml_rust2::YamlLoader::load_from_str(yaml) {
243         Ok(docs) => docs,
244         Err(_) => return Properties::new(),
245     };
246     match docs.into_iter().next() {
247         Some(yaml_rust2::Yaml::Hash(h)) => h
248             .into_iter()
249             .filter_map(|(k, v)| Some((yaml_key(k)?, yaml_to_json(v))))
250             .collect(),
251         _ => Properties::new(),
252     }
253 }
254 
255 fn yaml_key(k: yaml_rust2::Yaml) -> Option<String> {
256     use yaml_rust2::Yaml;
257     match k {
258         Yaml::String(s) | Yaml::Real(s) => Some(s),
259         Yaml::Integer(i) => Some(i.to_string()),
260         Yaml::Boolean(b) => Some(b.to_string()),
261         _ => None,
262     }
263 }
264 
265 fn yaml_to_json(y: yaml_rust2::Yaml) -> Value {
266     use yaml_rust2::Yaml;
267     match y {
268         Yaml::Real(s) => s
269             .parse::<f64>()
270             .ok()
271             .and_then(serde_json::Number::from_f64)
272             .map(Value::Number)
273             .unwrap_or(Value::String(s)),
274         Yaml::Integer(i) => Value::from(i),
275         Yaml::String(s) => Value::String(s),
276         Yaml::Boolean(b) => Value::Bool(b),
277         Yaml::Array(a) => Value::Array(a.into_iter().map(yaml_to_json).collect()),
278         Yaml::Hash(h) => Value::Object(
279             h.into_iter().filter_map(|(k, v)| Some((yaml_key(k)?, yaml_to_json(v)))).collect::<Map<_, _>>(),
280         ),
281         Yaml::Null | Yaml::Alias(_) | Yaml::BadValue => Value::Null,
282     }
283 }
284 
285 /// `tags` (or the older `tag`) as a list, or as one string split on commas
286 /// and whitespace. A leading `#` is tolerated and dropped.
287 fn property_tags(props: &Properties) -> Vec<String> {
288     let mut out = Vec::new();
289     for key in ["tags", "tag"] {
290         let push = |s: &str, out: &mut Vec<String>| {
291             for part in s.split(|c: char| c == ',' || c.is_whitespace()) {
292                 let t = part.trim().trim_start_matches('#');
293                 if !t.is_empty() {
294                     out.push(t.to_string());
295                 }
296             }
297         };
298         match props.get(key) {
299             Some(Value::String(s)) => push(s, &mut out),
300             Some(Value::Array(items)) => {
301                 for item in items {
302                     match item {
303                         Value::String(s) => push(s, &mut out),
304                         Value::Number(n) => out.push(n.to_string()),
305                         _ => {}
306                     }
307                 }
308             }
309             _ => {}
310         }
311     }
312     out
313 }
314 
315 /// Aliases from the `aliases` (or `alias`) property.
316 pub fn aliases(props: &Properties) -> Vec<String> {
317     let mut out = Vec::new();
318     for key in ["aliases", "alias"] {
319         match props.get(key) {
320             Some(Value::String(s)) => {
321                 out.extend(s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from))
322             }
323             Some(Value::Array(items)) => {
324                 out.extend(items.iter().filter_map(Value::as_str).map(String::from))
325             }
326             _ => {}
327         }
328     }
329     out
330 }
331 
332 /// Byte ranges sorted by start, with a point query.
333 struct Ranges(Vec<Range<usize>>);
334 
335 impl Ranges {
336     fn contains(&self, pos: usize) -> bool {
337         // Ranges can nest (a code span inside a comment), so any range that
338         // starts at or before `pos` may hold it. A note has a few dozen.
339         let idx = self.0.partition_point(|r| r.start <= pos);
340         self.0[..idx].iter().any(|r| r.contains(&pos))
341     }
342 }
343 
344 /// Byte offset → 0-based line.
345 pub struct LineIndex {
346     starts: Vec<usize>,
347 }
348 
349 impl LineIndex {
350     pub fn new(src: &str) -> Self {
351         let mut starts = vec![0];
352         starts.extend(src.match_indices('\n').map(|(i, _)| i + 1));
353         LineIndex { starts }
354     }
355     pub fn line_of(&self, pos: usize) -> usize {
356         self.starts.partition_point(|&s| s <= pos) - 1
357     }
358     pub fn start_of(&self, line: usize) -> usize {
359         self.starts.get(line).copied().unwrap_or(0)
360     }
361 }
362 
363 /// `%%...%%` comments outside code; an unclosed one runs to the end of the
364 /// file, as Obsidian renders it.
365 fn comment_ranges(src: &str, from: usize, code: &[Range<usize>]) -> Vec<Range<usize>> {
366     let code = Ranges(code.to_vec());
367     let mut out = Vec::new();
368     let mut pos = from;
369     while let Some(i) = src[pos..].find("%%") {
370         let start = pos + i;
371         if code.contains(start) {
372             pos = start + 2;
373             continue;
374         }
375         let end = src[start + 2..].find("%%").map(|j| start + 2 + j + 2).unwrap_or(src.len());
376         out.push(start..end);
377         pos = end;
378     }
379     out
380 }
381 
382 fn wikilinks(src: &str, skip: &Ranges, lines: &LineIndex) -> Vec<Link> {
383     let bytes = src.as_bytes();
384     let mut out = Vec::new();
385     let mut pos = 0;
386     while let Some(i) = src[pos..].find("[[") {
387         let open = pos + i;
388         pos = open + 2;
389         if skip.contains(open) || (open > 0 && bytes[open - 1] == b'\\') {
390             continue;
391         }
392         // The link ends at the first `]]` on the same line.
393         let rest = &src[open + 2..];
394         let line_end = rest.find('\n').unwrap_or(rest.len());
395         let Some(close_rel) = rest[..line_end].find("]]") else { continue };
396         let inner_start = open + 2;
397         let inner = &src[inner_start..inner_start + close_rel];
398         let close = inner_start + close_rel + 2;
399         let embed = open > 0 && bytes[open - 1] == b'!';
400         let span_start = if embed { open - 1 } else { open };
401 
402         // Inside a table the pipe is escaped as `\|`; the path then ends
403         // before the backslash.
404         let (path_part, display) = match inner.find('|') {
405             Some(p) => {
406                 let path_end = if p > 0 && inner.as_bytes()[p - 1] == b'\\' { p - 1 } else { p };
407                 (&inner[..path_end], Some(inner[p + 1..].to_string()))
408             }
409             None => (inner, None),
410         };
411         let (target_raw, subpath) = match path_part.find('#') {
412             Some(h) => (&path_part[..h], Some(path_part[h + 1..].trim().to_string())),
413             None => (path_part, None),
414         };
415         let lead = target_raw.len() - target_raw.trim_start().len();
416         let target = target_raw.trim();
417         if target.is_empty() {
418             // `[[#Heading]]` points into the note itself; nothing to index.
419             pos = close;
420             continue;
421         }
422         let t_start = inner_start + lead;
423         out.push(Link {
424             kind: LinkKind::Wiki,
425             embed,
426             target: target.to_string(),
427             subpath: subpath.filter(|s| !s.is_empty()),
428             display,
429             span: span_start..close,
430             target_span: t_start..t_start + target.len(),
431             line: lines.line_of(span_start),
432             node: None,
433         });
434         pos = close;
435     }
436     out
437 }
438 
439 /// A `[text](dest)` link from pulldown's range, with the destination's own
440 /// byte span found by re-reading the source: pulldown hands back the
441 /// destination unescaped and gives no offset for it.
442 fn markdown_link(src: &str, range: Range<usize>, embed: bool, lines: &LineIndex) -> Option<Link> {
443     let text = &src[range.clone()];
444     let bytes = text.as_bytes();
445     let mut i = if embed { 1 } else { 0 };
446     if bytes.get(i) != Some(&b'[') {
447         return None;
448     }
449     // Find the `]` closing the link text, honouring nesting and escapes.
450     let mut depth = 0i32;
451     let close_text = loop {
452         match bytes.get(i)? {
453             b'\\' => i += 1,
454             b'[' => depth += 1,
455             b']' => {
456                 depth -= 1;
457                 if depth == 0 {
458                     break i;
459                 }
460             }
461             _ => {}
462         }
463         i += 1;
464     };
465     if bytes.get(close_text + 1) != Some(&b'(') {
466         return None;
467     }
468     let mut d = close_text + 2;
469     while bytes.get(d).is_some_and(|b| *b == b' ' || *b == b'\t' || *b == b'\n') {
470         d += 1;
471     }
472     let (dest_start, dest_end) = if bytes.get(d) == Some(&b'<') {
473         let end = text[d + 1..].find('>')? + d + 1;
474         (d + 1, end)
475     } else {
476         let mut e = d;
477         let mut parens = 0;
478         while let Some(&b) = bytes.get(e) {
479             match b {
480                 b'\\' => e += 1,
481                 b'(' => parens += 1,
482                 b')' if parens == 0 => break,
483                 b')' => parens -= 1,
484                 b' ' | b'\t' | b'\n' => break,
485                 _ => {}
486             }
487             e += 1;
488         }
489         (d, e.min(text.len()))
490     };
491     let raw_dest = &text[dest_start..dest_end];
492     if raw_dest.is_empty() || is_external(raw_dest) {
493         return None;
494     }
495     let (path_raw, subpath) = match raw_dest.find('#') {
496         Some(h) => (&raw_dest[..h], Some(decode(&raw_dest[h + 1..]))),
497         None => (raw_dest, None),
498     };
499     if path_raw.is_empty() {
500         return None;
501     }
502     let display = text[if embed { 2 } else { 1 }..close_text].to_string();
503     Some(Link {
504         kind: LinkKind::Markdown,
505         embed,
506         target: decode(path_raw),
507         subpath: subpath.filter(|s| !s.is_empty()),
508         display: Some(display),
509         span: range.clone(),
510         target_span: range.start + dest_start..range.start + dest_start + path_raw.len(),
511         line: lines.line_of(range.start),
512         node: None,
513     })
514 }
515 
516 fn decode(s: &str) -> String {
517     percent_encoding::percent_decode_str(s).decode_utf8_lossy().into_owned()
518 }
519 
520 /// A destination that leaves the vault: any `scheme:` (http, mailto,
521 /// obsidian://, file:) — but not a Windows drive letter, which nobody writes
522 /// in a vault link anyway.
523 fn is_external(dest: &str) -> bool {
524     match dest.find(':') {
525         Some(c) if c > 1 => dest[..c]
526             .chars()
527             .all(|ch| ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.'),
528         _ => false,
529     }
530 }
531 
532 fn is_tag_char(c: char) -> bool {
533     c.is_alphanumeric() || c == '_' || c == '-' || c == '/'
534 }
535 
536 /// `#tag` in the body: `#` at a line start or after whitespace, then tag
537 /// characters, at least one of them not a digit (`#123` is not a tag).
538 /// `# Heading` never matches because the space is not a tag character.
539 fn scan_tags(
540     src: &str,
541     from: usize,
542     skip: &Ranges,
543     links: &Ranges,
544     lines: &LineIndex,
545     out: &mut Vec<NoteTag>,
546 ) {
547     let mut prev: Option<char> = src[..from].chars().next_back();
548     let mut iter = src[from..].char_indices().map(|(i, c)| (i + from, c)).peekable();
549     while let Some((i, c)) = iter.next() {
550         let at_boundary = prev.is_none_or(|p| p.is_whitespace());
551         prev = Some(c);
552         if c != '#' || !at_boundary || skip.contains(i) || links.contains(i) {
553             continue;
554         }
555         let rest = &src[i + 1..];
556         let len: usize = rest.chars().take_while(|&c| is_tag_char(c)).map(char::len_utf8).sum();
557         let name = rest[..len].trim_end_matches('/');
558         if name.is_empty() || name.chars().all(|c| c.is_ascii_digit() || c == '/') {
559             continue;
560         }
561         out.push(NoteTag { name: name.to_string(), line: Some(lines.line_of(i)) });
562         // Skip past the tag so `#a#b` yields one tag, as Obsidian reads it.
563         while iter.peek().is_some_and(|&(j, _)| j <= i + len) {
564             prev = iter.next().map(|(_, c)| c);
565         }
566     }
567 }
568 
569 /// Line-shaped things: tasks and `^block` ids.
570 fn scan_lines(src: &str, from: usize, skip: &Ranges, lines: &LineIndex, note: &mut Note) {
571     let mut start = from;
572     for line in src[from..].split_inclusive('\n') {
573         let line_start = start;
574         start += line.len();
575         let text = line.trim_end_matches(['\n', '\r']);
576         if skip.contains(line_start) {
577             continue;
578         }
579         let line_no = lines.line_of(line_start);
580         if let Some((status_at, status, rest)) = task_parts(text) {
581             if !skip.contains(line_start + status_at) {
582                 note.tasks.push(Task {
583                     status,
584                     text: rest.trim().to_string(),
585                     line: line_no,
586                     status_at: line_start + status_at,
587                 });
588             }
589         }
590         if let Some(id) = block_id(text) {
591             if !skip.contains(line_start + text.len() - 1) {
592                 note.blocks.push(BlockId { id: id.to_string(), line: line_no });
593             }
594         }
595     }
596 }
597 
598 /// `- [ ] text`, `* [x] text`, `1. [/] text`, also inside `> ` quotes and
599 /// callouts. Returns (byte offset of the status char in the line, status,
600 /// the text after the box).
601 pub fn task_parts(line: &str) -> Option<(usize, char, &str)> {
602     let b = line.as_bytes();
603     let mut i = 0;
604     loop {
605         while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
606             i += 1;
607         }
608         if i < b.len() && b[i] == b'>' {
609             i += 1;
610             continue;
611         }
612         break;
613     }
614     match b.get(i)? {
615         b'-' | b'*' | b'+' => i += 1,
616         b'0'..=b'9' => {
617             while b.get(i).is_some_and(u8::is_ascii_digit) {
618                 i += 1;
619             }
620             if !matches!(b.get(i), Some(b'.') | Some(b')')) {
621                 return None;
622             }
623             i += 1;
624         }
625         _ => return None,
626     }
627     if !matches!(b.get(i), Some(b' ') | Some(b'\t')) {
628         return None;
629     }
630     while matches!(b.get(i), Some(b' ') | Some(b'\t')) {
631         i += 1;
632     }
633     if b.get(i) != Some(&b'[') {
634         return None;
635     }
636     let status_at = i + 1;
637     let status = line[status_at..].chars().next()?;
638     let after = status_at + status.len_utf8();
639     if b.get(after) != Some(&b']') {
640         return None;
641     }
642     let rest = &line[after + 1..];
643     if !(rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t')) {
644         return None;
645     }
646     Some((status_at, status, rest))
647 }
648 
649 /// A trailing ` ^block-id` (or a line that is only `^block-id`).
650 fn block_id(line: &str) -> Option<&str> {
651     let t = line.trim_end();
652     let caret = t.rfind('^')?;
653     let id = &t[caret + 1..];
654     if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
655         return None;
656     }
657     if caret > 0 && !t[..caret].ends_with([' ', '\t']) {
658         return None;
659     }
660     Some(id)
661 }
662 
663 #[cfg(test)]
664 mod tests {
665     use super::*;
666 
667     fn targets(n: &Note) -> Vec<&str> {
668         n.links.iter().map(|l| l.target.as_str()).collect()
669     }
670 
671     #[test]
672     fn wikilink_forms() {
673         let src = "See [[Alpha]], [[folder/Beta#Intro|the beta]] and ![[pic.png]].\n\
674                    Block: [[Gamma#^abc123]]. Self: [[#Local]].\n";
675         let n = parse(src);
676         assert_eq!(targets(&n), ["Alpha", "folder/Beta", "pic.png", "Gamma"]);
677         let beta = &n.links[1];
678         assert_eq!(beta.subpath.as_deref(), Some("Intro"));
679         assert_eq!(beta.display.as_deref(), Some("the beta"));
680         assert_eq!(&src[beta.target_span.clone()], "folder/Beta");
681         assert_eq!(&src[beta.span.clone()], "[[folder/Beta#Intro|the beta]]");
682         assert!(n.links[2].embed);
683         assert_eq!(&src[n.links[2].span.clone()], "![[pic.png]]");
684         assert_eq!(n.links[3].subpath.as_deref(), Some("^abc123"));
685         assert_eq!(n.links[3].line, 1);
686     }
687 
688     #[test]
689     fn table_escaped_pipe() {
690         let src = "| a | b |\n|---|---|\n| [[Note\\|shown]] | x |\n";
691         let n = parse(src);
692         assert_eq!(targets(&n), ["Note"]);
693         assert_eq!(n.links[0].display.as_deref(), Some("shown"));
694         assert_eq!(&src[n.links[0].target_span.clone()], "Note");
695     }
696 
697     #[test]
698     fn code_and_comments_hide_links_and_tags() {
699         let src = "```\n[[InFence]] #infence\n```\n\
700                    `[[InCode]]` %% [[InComment]] #incomment %%\n\n\
701                    \x20   [[Indented]]\n\n[[Real]] #real\n";
702         let n = parse(src);
703         assert_eq!(targets(&n), ["Real"]);
704         let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
705         assert_eq!(tags, ["real"]);
706     }
707 
708     #[test]
709     fn markdown_links() {
710         let src = "[a](My%20Note.md) [b](<Other Note.md#Part>) ![c](img/x.png)\n\
711                    [web](https://example.com) [mail](mailto:a@b.c) [here](#local)\n";
712         let n = parse(src);
713         assert_eq!(targets(&n), ["My Note.md", "Other Note.md", "img/x.png"]);
714         assert_eq!(&src[n.links[0].target_span.clone()], "My%20Note.md");
715         assert_eq!(&src[n.links[1].target_span.clone()], "Other Note.md");
716         assert_eq!(n.links[1].subpath.as_deref(), Some("Part"));
717         assert!(n.links[2].embed);
718         assert!(n.links.iter().all(|l| l.kind == LinkKind::Markdown));
719     }
720 
721     #[test]
722     fn tag_rules() {
723         let src = "# Heading\n#top and #nested/child, #123 not, a#b not, \
724                    #under_score #日本 (see [[Note#Sec]]) #trail/\n";
725         let n = parse(src);
726         let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
727         assert_eq!(tags, ["top", "nested/child", "under_score", "日本", "trail"]);
728         assert_eq!(n.headings.len(), 1);
729     }
730 
731     #[test]
732     fn frontmatter_properties_and_tags() {
733         let src = "---\ntitle: Hello\ncount: 3\ndone: true\ntags: [one, \"#two\"]\n\
734                    aliases:\n  - Hi\n  - Hey\nrelated: \"[[Other]]\"\n---\n# Body #three\n";
735         let n = parse(src);
736         assert_eq!(n.properties["title"], "Hello");
737         assert_eq!(n.properties["count"], 3);
738         assert_eq!(n.properties["done"], true);
739         assert_eq!(aliases(&n.properties), ["Hi", "Hey"]);
740         let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
741         assert_eq!(tags, ["one", "two", "three"]);
742         assert_eq!(targets(&n), ["Other"]);
743         assert_eq!(n.headings[0].text, "Body #three");
744         assert_eq!(n.headings[0].line, 10);
745         let keys: Vec<_> = n.properties.keys().collect();
746         assert_eq!(keys, ["title", "count", "done", "tags", "aliases", "related"]);
747     }
748 
749     #[test]
750     fn tags_as_a_string_and_bad_yaml() {
751         let n = parse("---\ntags: a, b c\n---\nx\n");
752         let tags: Vec<_> = n.tags.iter().map(|t| t.name.as_str()).collect();
753         assert_eq!(tags, ["a", "b", "c"]);
754         let bad = parse("---\n: : [\n---\nbody [[Link]]\n");
755         assert!(bad.properties.is_empty());
756         assert!(bad.frontmatter.is_some());
757         assert_eq!(targets(&bad), ["Link"]);
758     }
759 
760     #[test]
761     fn no_frontmatter_without_closing_fence() {
762         let n = parse("---\nnot: closed\n\n[[A]]\n");
763         assert!(n.frontmatter.is_none());
764     }
765 
766     #[test]
767     fn tasks_and_statuses() {
768         let src = "- [ ] open one\n* [x] done\n1. [/] half\n> - [-] quoted cancel\n\
769                    - [] not a task\n-[ ] not either\n```\n- [ ] in code\n```\n";
770         let n = parse(src);
771         let got: Vec<_> = n.tasks.iter().map(|t| (t.status, t.text.as_str(), t.line)).collect();
772         assert_eq!(
773             got,
774             [(' ', "open one", 0), ('x', "done", 1), ('/', "half", 2), ('-', "quoted cancel", 3)]
775         );
776         assert_eq!(&src[n.tasks[1].status_at..n.tasks[1].status_at + 1], "x");
777         assert!(n.tasks[0].is_open() && n.tasks[2].is_open());
778         assert!(!n.tasks[1].is_open() && !n.tasks[3].is_open());
779     }
780 
781     #[test]
782     fn block_ids_and_headings() {
783         let src = "Para one ^p1\n\n^standalone\n\nnot^block\n\n## Two `code`\nSetext\n===\n";
784         let n = parse(src);
785         let ids: Vec<_> = n.blocks.iter().map(|b| b.id.as_str()).collect();
786         assert_eq!(ids, ["p1", "standalone"]);
787         let hs: Vec<_> = n.headings.iter().map(|h| (h.level, h.text.as_str(), h.line)).collect();
788         assert_eq!(hs, [(2, "Two code", 6), (1, "Setext", 7)]);
789     }
790 
791     #[test]
792     fn crlf_and_unicode_offsets() {
793         let src = "---\r\na: 1\r\n---\r\nÜber [[Zürich]]\r\n- [ ] tâche\r\n";
794         let n = parse(src);
795         assert_eq!(n.properties["a"], 1);
796         assert_eq!(&src[n.links[0].target_span.clone()], "Zürich");
797         assert_eq!(n.tasks[0].text, "tâche");
798     }
799 }