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 `` 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(¬e.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>) \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 }