Files
Marcos Paulo 5e8020aa38
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: Obsidian basics, editor previews, graph rework, query blocks, stacked tabs, pop-out windows
Editor previews (colour swatches, arrows, embeds), the 18 obsidian-basics tasks (find and replace
with regex, back/forward, auto-pair, footnotes, folding, search operators, split view, PDF/audio/
video embeds, spelling, drag and drop, pinned tabs, settings) and round 2: ```query blocks, stacked
tabs, pop-out windows, tasks ticked inside embeds, text around an embed, embeds that follow edits,
block menu shortcut. New crates: notescraft-regex (linear-time engine) and notescraft-syntax.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:21:19 -03:00

272 lines
11 KiB
Rust

//! Footnote items: `[^label]` references, `[^label]:` definition markers and
//! `^[inline]` footnotes — ranges, escapes, contexts, hostile input and a
//! randomized overlap property.
use std::ops::Range;
use notescraft_markdown::{ItemKind, parse};
/// The footnote items of `src` as `(kind, text)` pairs, in source order.
fn foots(src: &str) -> Vec<(ItemKind, String)> {
parse(src)
.items
.iter()
.filter(|item| matches!(item.kind, ItemKind::Footnote { .. } | ItemKind::FootnoteDef { .. } | ItemKind::InlineFootnote))
.map(|item| (item.kind.clone(), src.get(item.range.clone()).unwrap_or_default().to_string()))
.collect()
}
/// The labels of the footnote items of `src`, in source order.
fn labels(src: &str) -> Vec<(ItemKind, String)> {
parse(src)
.items
.iter()
.filter_map(|item| match &item.kind {
ItemKind::Footnote { label } | ItemKind::FootnoteDef { label } => Some((item.kind.clone(), label.clone())),
_ => None,
})
.collect()
}
/// Whether two byte ranges intersect.
fn overlaps(a: &Range<usize>, b: &Range<usize>) -> bool {
a.start < b.end && b.start < a.end
}
/// Whether the item is one of the three footnote kinds.
fn is_foot(kind: &ItemKind) -> bool {
matches!(kind, ItemKind::Footnote { .. } | ItemKind::FootnoteDef { .. } | ItemKind::InlineFootnote)
}
#[test]
fn references_are_reported_with_or_without_definition() {
let src = "A [^1] and [^2].\n\n[^1]: first\n";
assert_eq!(
labels(src),
vec![
(ItemKind::Footnote { label: String::from("1") }, String::from("1")),
(ItemKind::Footnote { label: String::from("2") }, String::from("2")),
(ItemKind::FootnoteDef { label: String::from("1") }, String::from("1"))
]
);
let doc = parse(src);
let reference = &doc.items[0];
assert_eq!(reference.range.start, src.find("[^1]").unwrap_or_default());
assert_eq!(src.get(reference.range.clone()), Some("[^1]"));
assert_eq!(foots("[^nope]\n").len(), 1, "an undefined label is still reported");
}
#[test]
fn repeated_references_are_all_reported() {
let src = "[^1] [^1] [^2]\n";
assert_eq!(labels(src).len(), 3);
assert_eq!(foots(src).iter().map(|(_, text)| text.clone()).collect::<Vec<_>>(), vec!["[^1]", "[^1]", "[^2]"]);
}
#[test]
fn definitions_cover_the_marker_only() {
let src = "[^note]: the body stays in the source\n";
assert_eq!(foots(src), vec![(ItemKind::FootnoteDef { label: String::from("note") }, String::from("[^note]:"))]);
let doc = parse(src);
assert_eq!(doc.items[0].range.start, src.find("[^note]:").unwrap_or_default());
assert_eq!(doc.items[0].range.end, src.find(" the body").unwrap_or_default());
}
#[test]
fn definitions_follow_containers() {
let src = "- [^a]: in a list\n> [^b]: in a quote\n [^c]: indented\n";
let got: Vec<String> = labels(src)
.into_iter()
.map(|(kind, label)| match kind {
ItemKind::FootnoteDef { .. } => label,
_ => String::new(),
})
.collect();
assert_eq!(got, vec!["a", "b", "c"]);
}
#[test]
fn definitions_need_a_line_start_and_a_colon() {
assert_eq!(foots("text [^1]: not a def\n"), vec![(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]"))]);
assert_eq!(foots("[^1] : spaced\n"), vec![(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]"))]);
assert_eq!(foots("[^1]:x\n"), vec![(ItemKind::FootnoteDef { label: String::from("1") }, String::from("[^1]:"))]);
}
#[test]
fn inline_footnotes_cover_the_whole_construct() {
let src = "See ^[the note] here.\n";
assert_eq!(foots(src), vec![(ItemKind::InlineFootnote, String::from("^[the note]"))]);
let doc = parse(src);
assert_eq!(doc.items[0].range.start, src.find("^[the note]").unwrap_or_default());
assert_eq!(doc.items[0].range.end, src.find(" here").unwrap_or_default());
}
#[test]
fn inline_footnotes_balance_nested_brackets() {
assert_eq!(foots("^[a [b [c] d] e]\n"), vec![(ItemKind::InlineFootnote, String::from("^[a [b [c] d] e]"))]);
assert_eq!(foots("^[outer ^[inner] done]\n"), vec![(ItemKind::InlineFootnote, String::from("^[outer ^[inner] done]"))]);
assert_eq!(foots("^[unclosed [bracket\n").len(), 0, "an unbalanced body is not a footnote");
}
#[test]
fn inline_footnotes_cross_one_line_but_not_a_blank_one() {
assert_eq!(foots("^[a\nb]\n"), vec![(ItemKind::InlineFootnote, String::from("^[a\nb]"))]);
assert_eq!(foots("^[a\n\nb]\n"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("^[a\n \t\nb]\n"), Vec::<(ItemKind, String)>::new(), "a whitespace-only line is blank");
}
#[test]
fn inline_footnote_bodies_are_capped_at_2000_bytes() {
let ok = format!("^[{}]", "a".repeat(2_000));
assert_eq!(foots(&ok), vec![(ItemKind::InlineFootnote, ok.clone())]);
let big = format!("^[{}]", "a".repeat(2_001));
assert_eq!(foots(&big), Vec::<(ItemKind, String)>::new());
}
#[test]
fn backslash_escapes_the_caret() {
assert_eq!(foots("\\^[x] no\n"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("\\\\^[x] yes\n"), vec![(ItemKind::InlineFootnote, String::from("^[x]"))]);
// A backslash before `[` is not an escape here, matching the body
// scanner (a wikilink after `\[` still parses).
assert_eq!(foots("\\[^1] yes\n"), vec![(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]"))]);
}
#[test]
fn no_footnotes_in_code_math_comments_or_frontmatter() {
assert_eq!(foots("`[^1] ^[x]`"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("```\n[^1] ^[x]\n```\n"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("$[^1]$"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("$$a ^[x] b$$"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("%%[^1] ^[x]%%"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("---\na: \"[^1]\"\n---\nbody\n"), Vec::<(ItemKind, String)>::new());
}
#[test]
fn no_footnotes_inside_wikilinks_or_link_labels_and_destinations() {
assert_eq!(foots("[[Note|[^1]]]"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("![[a [^1]]]"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("[see [^1]](dest)"), Vec::<(ItemKind, String)>::new());
assert_eq!(foots("[a](dest [^1] here)"), Vec::<(ItemKind, String)>::new());
}
#[test]
fn label_grammar_and_caps() {
let src = "[^A-z_0-9] [^a.b#c] [^with-dash_under]\n";
assert_eq!(labels(src).len(), 3);
assert_eq!(foots("[^a b] [^] [^ ] [^ 1]\n"), Vec::<(ItemKind, String)>::new());
let hundred = format!("[^{}]", "a".repeat(100));
assert_eq!(foots(&hundred).len(), 1);
let too_long = format!("[^{}]", "a".repeat(101));
assert_eq!(foots(&too_long), Vec::<(ItemKind, String)>::new());
}
#[test]
fn crlf_notes_keep_exact_offsets() {
let src = "A [^1]\r\nB ^[x]\r\n[^2]: def\r\n";
assert_eq!(
foots(src),
vec![
(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]")),
(ItemKind::InlineFootnote, String::from("^[x]")),
(ItemKind::FootnoteDef { label: String::from("2") }, String::from("[^2]:"))
]
);
let doc = parse(src);
assert_eq!(doc.items[1].range.start, src.find("^[x]").unwrap_or_default());
assert_eq!(doc.items[2].range.start, src.find("[^2]:").unwrap_or_default());
assert_eq!(doc.write(), src);
}
#[test]
fn unicode_labels_and_offsets_stay_on_char_boundaries() {
let src = "Référence [^é] et ^[únïcødé 🎉] fin.\n";
assert_eq!(labels(src), vec![(ItemKind::Footnote { label: String::from("é") }, String::from("é"))]);
let doc = parse(src);
let (reference, inline) = (&doc.items[0], &doc.items[1]);
assert_eq!(reference.range.start, src.find("[^é]").unwrap_or_default());
assert_eq!(inline.range.start, src.find("^[únïcødé").unwrap_or_default());
assert!(src.is_char_boundary(reference.range.end) && src.is_char_boundary(inline.range.end));
assert_eq!(src.get(inline.range.clone()), Some("^[únïcødé 🎉]"));
}
#[test]
fn footnotes_inside_headings_and_highlights() {
let doc = parse("## See [^1]\n");
assert_eq!(doc.headings().count(), 1);
assert_eq!(foots("## See [^1]\n"), vec![(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]"))]);
let doc = parse("==see [^1]==\n");
assert_eq!(doc.items.iter().filter(|i| matches!(i.kind, ItemKind::Highlight)).count(), 1);
assert_eq!(foots("==see [^1]==\n"), vec![(ItemKind::Footnote { label: String::from("1") }, String::from("[^1]"))]);
}
#[test]
fn inline_footnotes_contain_code_but_never_other_items() {
let src = "^[a `b` c]\n";
assert_eq!(foots(src), vec![(ItemKind::InlineFootnote, String::from("^[a `b` c]"))]);
assert_eq!(foots("^[see [a](b) x]\n"), Vec::<(ItemKind, String)>::new(), "a body crossing a link is rejected");
assert_eq!(foots("^[a\n# h]\n"), Vec::<(ItemKind, String)>::new(), "a body crossing a heading is rejected");
assert_eq!(foots("^[a %%c%% b]\n"), Vec::<(ItemKind, String)>::new(), "a body crossing a comment is rejected");
}
#[test]
fn hostile_openers_finish_in_bounded_time() {
let inline = "^[".repeat(100_000);
assert_eq!(foots(&inline), Vec::<(ItemKind, String)>::new());
assert_eq!(parse(&inline).write(), inline);
let refs = "[^".repeat(512 * 1024);
assert_eq!(foots(&refs), Vec::<(ItemKind, String)>::new());
assert_eq!(parse(&refs).write(), refs);
}
#[test]
fn parsing_stays_lossless() {
for src in ["[^1]\n", "^[x]\n", "[^1]: def\n", "a [^1] ^[b] [^c]:\n", "\\^[x] [^ 1] [^]", "é ^[🎉] [^é]\n"] {
assert_eq!(parse(src).write(), src);
}
}
/// xorshift64: a deterministic generator, no dependency needed.
fn xorshift(state: &mut u64) -> u64 {
let mut x = *state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}
#[test]
fn random_notes_keep_footnote_ranges_clean() {
let pieces = ["[^", "^[", "]", ":", "a", "é", "🎉", "\n", "\n\n", " ", "`", "$", "[[", "]]", "==", "#", "\\", "(", ")", "[", "]", "> ", "- ", "!"];
let mut state = 0x00C0_FFEE_1234_5678_u64;
for _ in 0..300 {
let count = 8 + usize::try_from(xorshift(&mut state) % 32).unwrap_or(0);
let mut src = String::new();
for _ in 0..count {
let index = usize::try_from(xorshift(&mut state) % pieces.len() as u64).unwrap_or(0);
let piece = pieces.get(index).copied().unwrap_or("a");
src.push_str(piece);
}
let doc = parse(&src);
assert_eq!(doc.write(), src);
for item in &doc.items {
assert!(item.range.start < item.range.end && item.range.end <= src.len(), "{src:?}");
assert!(src.is_char_boundary(item.range.start) && src.is_char_boundary(item.range.end), "{src:?}");
}
let feet: Vec<&notescraft_markdown::Item> = doc.items.iter().filter(|item| is_foot(&item.kind)).collect();
for (i, foot) in feet.iter().enumerate() {
for (j, other) in feet.iter().enumerate() {
if i != j {
assert!(!overlaps(&foot.range, &other.range), "two footnotes overlap in {src:?}");
}
}
for item in &doc.items {
if !std::ptr::eq(*foot, item) && overlaps(&foot.range, &item.range) {
assert!(matches!(item.kind, ItemKind::Heading { .. } | ItemKind::Highlight), "{foot:?} overlaps {item:?} in {src:?}");
}
}
}
}
}