Skip to main content

markdown_contract/
select.rs

1//! Selecting part of a note the way an Obsidian link fragment does:
2//! `[[Note#Heading]]`, `[[Note#Parent#Child]]` and `[[Note#^block-id]]`.
3//!
4//! This reads the body as lines, not through the [`crate::DocTree`]
5//! projection, because the two answer different questions. The tree makes
6//! the first H1 the document title, while a link fragment treats every
7//! heading the same: `#Title` selects the H1 section like any other heading.
8//! The rules are Obsidian's:
9//!
10//! - **A heading** is an ATX heading (`#` to `######`, then whitespace, then
11//!   text), up to three spaces in. A closing `#` run counts only after a
12//!   space or tab, so `## Learn C#` is the heading `Learn C#`. Headings
13//!   inside fenced code are not headings (fences as [`CodeRules::SkillExport`]
14//!   reads them).
15//! - **A section** is its heading line through the line before the next
16//!   heading of the same or a shallower level, so subsections come with it.
17//! - **A heading path** (`Parent#Child`) names the heading by its last
18//!   segment; the segments before it must be the nearest headings above it,
19//!   in order (plain heading ancestry: an H1 is an ancestor like any other
20//!   heading). The first heading that matches wins.
21//! - **A selector** is first tried as one heading's whole text, since a
22//!   heading may contain `#`; only when no heading has that text is it split
23//!   on `#` into a path.
24//! - **A block** `^id` is labelled by a marker at the end of a line (after
25//!   whitespace) or alone on a line. Alone on its line, it labels the
26//!   paragraph or list above it, blank lines skipped. At the end of a list
27//!   item it labels that one item. At the end of any other line it labels
28//!   the paragraph the line ends, walked back to a blank line or a heading.
29//!   The marker is never part of the selection.
30//!
31//! Every selection is trimmed of surrounding whitespace. The `*_range`
32//! functions give the selection's bytes in the body; the others give the text,
33//! which is the same bytes with each `\r\n` read as `\n`.
34//!
35//! The same rules back [`crate::DocTree::section_by_heading_path`],
36//! [`crate::DocTree::section_by_selector`] and
37//! [`crate::DocTree::block_range_by_id`].
38//!
39//! ```
40//! use markdown_contract::{parse_document, select};
41//!
42//! let note = "---\ntitle: x\n---\n# Guide\n\n## Setup\n\nInstall it. ^install\n\n## Use\n";
43//! let tree = parse_document(note);
44//!
45//! let range = tree.section_by_heading_path(&["Guide", "Setup"]).unwrap();
46//! assert_eq!(&tree.body[range], "## Setup\n\nInstall it. ^install");
47//! assert_eq!(select::block(&tree.body, "install").as_deref(), Some("Install it."));
48//! assert_eq!(
49//!     tree.block_by_id("install").unwrap().range(),
50//!     tree.root.sections[0].blocks[0].range()
51//! );
52//! ```
53
54use std::ops::Range;
55
56use markdown_link::code::{CodeRules, FenceLine, Fences};
57
58/// One body line: its text (terminator and a trailing `\r` stripped) and
59/// where it starts.
60struct Line<'a> {
61    text: &'a str,
62    start: usize,
63}
64
65fn lines_of(body: &str) -> Vec<Line<'_>> {
66    let mut out = Vec::new();
67    let mut start = 0;
68    for raw in body.split('\n') {
69        out.push(Line {
70            text: raw.strip_suffix('\r').unwrap_or(raw),
71            start,
72        });
73        start += raw.len() + 1;
74    }
75    out
76}
77
78/// The bytes from the start of line `from` to the end of line `to`'s text
79/// (inclusive), trimmed of surrounding whitespace.
80fn span(body: &str, lines: &[Line<'_>], from: usize, to: usize) -> Range<usize> {
81    trimmed(
82        body,
83        lines[from].start..lines[to].start + lines[to].text.len(),
84    )
85}
86
87/// `range` with leading and trailing whitespace dropped.
88fn trimmed(body: &str, range: Range<usize>) -> Range<usize> {
89    let slice = &body[range.clone()];
90    let start = range.start + (slice.len() - slice.trim_start().len());
91    let end = range.start + slice.trim_end().len();
92    start..end.max(start)
93}
94
95/// The text of `range` in `body`, `\r\n` read as `\n`.
96fn text_of(body: &str, range: Range<usize>) -> String {
97    body[range].replace("\r\n", "\n")
98}
99
100/// An ATX heading's level and text: `^ {0,3}(#{1,6})\s+(.+?)\s*$`, with a
101/// closing `#` run stripped when a space or tab precedes it. A bare `##` or
102/// `## ` is not a heading; `##  ` (whitespace for text) is one with empty
103/// text, which ends sections but can never be selected.
104pub fn atx_heading(line: &str) -> Option<(usize, &str)> {
105    let mut indent = 0;
106    let bytes = line.as_bytes();
107    while indent < 3 && bytes.get(indent) == Some(&b' ') {
108        indent += 1;
109    }
110    let rest = &line[indent..];
111    let hashes = rest.bytes().take_while(|b| *b == b'#').count();
112    if hashes == 0 || hashes > 6 {
113        return None;
114    }
115    let after = &rest[hashes..];
116    let mut chars = after.chars();
117    if !chars.next()?.is_whitespace() {
118        return None;
119    }
120    chars.next()?;
121    Some((hashes, without_closing_hashes(after.trim())))
122}
123
124/// Drops a closing `#` run, which CommonMark counts only when a space or tab
125/// precedes it (or it is the whole text): `Learn C#` keeps its `#`.
126fn without_closing_hashes(text: &str) -> &str {
127    let before_hashes = text.trim_end_matches('#');
128    if before_hashes.is_empty() || before_hashes.ends_with([' ', '\t']) {
129        before_hashes.trim_end()
130    } else {
131        text
132    }
133}
134
135/// [`atx_heading`] for each line, `None` inside fenced code.
136fn live_headings<'a>(lines: &[Line<'a>]) -> Vec<Option<(usize, &'a str)>> {
137    let mut fences = Fences::new(CodeRules::SkillExport);
138    lines
139        .iter()
140        .map(|line| match fences.line(line.text.as_bytes()) {
141            FenceLine::Outside => atx_heading(line.text),
142            FenceLine::Marker | FenceLine::Inside => None,
143        })
144        .collect()
145}
146
147/// The section of the first heading whose text is `path`'s last segment and
148/// whose nearest ancestors are the segments before it. Segments are compared
149/// exactly, as given; an empty `path` selects nothing.
150pub fn heading_path_range(body: &str, path: &[&str]) -> Option<Range<usize>> {
151    let lines = lines_of(body);
152    let headings = live_headings(&lines);
153    select_heading_path(body, &lines, &headings, path)
154}
155
156/// [`heading_path_range`] as text.
157pub fn heading_path(body: &str, path: &[&str]) -> Option<String> {
158    heading_path_range(body, path).map(|r| text_of(body, r))
159}
160
161/// The section a link fragment's heading selector names: the whole trimmed
162/// selector as one heading's text first, then the selector split on `#`
163/// (empty segments dropped, each trimmed) as a heading path.
164pub fn heading_range(body: &str, selector: &str) -> Option<Range<usize>> {
165    let lines = lines_of(body);
166    let headings = live_headings(&lines);
167    let whole = [selector.trim()];
168    let path: Vec<&str> = selector
169        .split('#')
170        .map(str::trim)
171        .filter(|part| !part.is_empty())
172        .collect();
173    select_heading_path(body, &lines, &headings, &whole)
174        .or_else(|| select_heading_path(body, &lines, &headings, &path))
175}
176
177/// [`heading_range`] as text.
178pub fn heading(body: &str, selector: &str) -> Option<String> {
179    heading_range(body, selector).map(|r| text_of(body, r))
180}
181
182fn select_heading_path(
183    body: &str,
184    lines: &[Line<'_>],
185    headings: &[Option<(usize, &str)>],
186    wanted: &[&str],
187) -> Option<Range<usize>> {
188    // The open heading at each level, so the ancestry is known when a
189    // candidate appears.
190    let mut ancestry: [Option<&str>; 6] = [None; 6];
191
192    for (index, heading) in headings.iter().enumerate() {
193        let Some((level, text)) = *heading else {
194            continue;
195        };
196        ancestry[level - 1] = Some(text);
197        for deeper in ancestry.iter_mut().skip(level) {
198            *deeper = None;
199        }
200        if wanted.last() != Some(&text) {
201            continue;
202        }
203        if wanted.len() > 1 {
204            let ancestors: Vec<&str> = ancestry[..level]
205                .iter()
206                .flatten()
207                .filter(|t| !t.is_empty())
208                .copied()
209                .collect();
210            let tail_start = ancestors.len().saturating_sub(wanted.len());
211            if ancestors[tail_start..].join("#") != wanted.join("#") {
212                continue;
213            }
214        }
215
216        let mut end = index + 1;
217        while end < lines.len() {
218            if let Some((next_level, _)) = headings[end]
219                && next_level <= level
220            {
221                break;
222            }
223            end += 1;
224        }
225        return Some(span(body, lines, index, end - 1));
226    }
227
228    None
229}
230
231/// The block `^id` labels; see the module doc for the three shapes.
232pub fn block_range(body: &str, id: &str) -> Option<Range<usize>> {
233    let lines = lines_of(body);
234
235    for (index, line) in lines.iter().enumerate() {
236        let Some(caret_at) = block_marker_position(line.text, id) else {
237            continue;
238        };
239
240        if line.text.trim() == format!("^{id}") {
241            let mut end = index as isize - 1;
242            while end >= 0 && lines[end as usize].text.trim().is_empty() {
243                end -= 1;
244            }
245            if end < 0 {
246                // A marker with nothing above it labels nothing.
247                break;
248            }
249            let end = end as usize;
250            let mut start = end;
251            while start > 0
252                && !lines[start - 1].text.trim().is_empty()
253                && atx_heading(lines[start - 1].text).is_none()
254            {
255                start -= 1;
256            }
257            return Some(span(body, &lines, start, end));
258        }
259
260        let cleaned_end = line.start + line.text[..caret_at].trim_end().len();
261        if is_list_item(line.text) {
262            return Some(trimmed(body, line.start..cleaned_end));
263        }
264        let mut start = index;
265        while start > 0
266            && !lines[start - 1].text.trim().is_empty()
267            && atx_heading(lines[start - 1].text).is_none()
268        {
269            start -= 1;
270        }
271        return Some(trimmed(body, lines[start].start..cleaned_end));
272    }
273
274    None
275}
276
277/// [`block_range`] as text.
278pub fn block(body: &str, id: &str) -> Option<String> {
279    block_range(body, id).map(|r| text_of(body, r))
280}
281
282/// What a link fragment selects: no fragment the whole body, `^id` a block,
283/// anything else a heading selector ([`heading_range`]). All trimmed.
284pub fn fragment_range(body: &str, fragment: Option<&str>) -> Option<Range<usize>> {
285    match fragment {
286        None => Some(trimmed(body, 0..body.len())),
287        Some(fragment) => match fragment.strip_prefix('^') {
288            Some(id) => block_range(body, id),
289            None => heading_range(body, fragment),
290        },
291    }
292}
293
294/// [`fragment_range`] as text.
295pub fn fragment(body: &str, fragment: Option<&str>) -> Option<String> {
296    fragment_range(body, fragment).map(|r| text_of(body, r))
297}
298
299/// `(?:^|\s)\^id\s*$`: the byte offset of the `^` when `line` ends with the
300/// marker.
301fn block_marker_position(line: &str, id: &str) -> Option<usize> {
302    let trimmed = line.trim_end();
303    let before_id = trimmed.strip_suffix(id)?;
304    let before_caret = before_id.strip_suffix('^')?;
305    if before_caret.is_empty() || before_caret.ends_with(|c: char| c.is_whitespace()) {
306        Some(before_caret.len())
307    } else {
308        None
309    }
310}
311
312/// `^\s*(?:[-+*]|\d+[.)])\s+`: is this line a list item?
313fn is_list_item(line: &str) -> bool {
314    let rest = line.trim_start();
315    if let Some(after_bullet) = rest.strip_prefix(['-', '+', '*']) {
316        return after_bullet.starts_with(|c: char| c.is_whitespace());
317    }
318    let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
319    if digits == 0 {
320        return false;
321    }
322    match rest[digits..].strip_prefix(['.', ')']) {
323        Some(after_marker) => after_marker.starts_with(|c: char| c.is_whitespace()),
324        None => false,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    // Ported from skillwright's compile.rs, whose extract_heading and
333    // extract_block these functions replace.
334
335    #[test]
336    fn heading_and_block_extraction_follow_obsidian() {
337        let body = "# Parent\n\n## Selected\n\nText.\n\n### Child\n\nChild text.\n\n## Later\n\n- Item one\n- Item two\n\n^list-block\n";
338        let section = heading(body, "Parent#Selected").unwrap();
339        assert!(section.contains("### Child"));
340        assert!(!section.contains("## Later"));
341        assert_eq!(block(body, "list-block").unwrap(), "- Item one\n- Item two");
342    }
343
344    #[test]
345    fn a_heading_inside_a_fence_neither_ends_nor_starts_a_section() {
346        let body = "## PR body\n\nTemplate:\n\n```md\n## Summary\n<why>\n\n~~~\n# Not closed by this\n~~~\n```\n\nAfter the fence.\n\n## Next\n\nOther.\n";
347        let section = heading(body, "PR body").unwrap();
348        assert!(section.contains("## Summary"), "{section}");
349        assert!(section.ends_with("After the fence."), "{section}");
350        assert_eq!(section.matches("```").count(), 2, "{section}");
351        assert!(heading(body, "Summary").is_none());
352    }
353
354    #[test]
355    fn a_closing_hash_run_is_stripped_only_after_whitespace() {
356        let text_of = |line: &str| atx_heading(line).map(|(_, text)| text.to_owned());
357        for (line, expected) in [
358            ("## foo ##", "foo"),
359            ("###   bar    ###", "bar"),
360            ("# foo ##################################", "foo"),
361            ("##### foo ##", "foo"),
362            ("### foo ###     ", "foo"),
363            ("### foo ### b", "foo ### b"),
364            ("# foo#", "foo#"),
365            ("### foo \\###", "foo \\###"),
366            ("## foo #\\##", "foo #\\##"),
367            ("# foo \\#", "foo \\#"),
368            ("# #", ""),
369            ("## Learn C#", "Learn C#"),
370        ] {
371            assert_eq!(text_of(line).as_deref(), Some(expected), "{line}");
372        }
373        assert_eq!(atx_heading("##"), None);
374        assert_eq!(atx_heading("## "), None);
375        assert_eq!(atx_heading("####### seven"), None);
376        assert_eq!(atx_heading("    # four spaces"), None);
377    }
378
379    #[test]
380    fn a_heading_ending_in_a_hash_is_not_selected_by_its_hashless_prefix() {
381        let body = "## Learn C#\n\nbody\n\n## Next\n";
382        assert!(heading(body, "Learn C").is_none());
383    }
384
385    #[test]
386    fn a_selector_naming_a_heading_that_ends_in_a_hash_resolves() {
387        let body = "## Learn C#\n\nbody\n\n## Next\n";
388        assert_eq!(heading(body, "Learn C#").unwrap(), "## Learn C#\n\nbody");
389    }
390
391    #[test]
392    fn a_whole_selector_match_wins_over_the_path_reading() {
393        let body = "# A\n\npath\n\n## B\n\npath child\n\n# A#B\n\nwhole\n";
394        assert_eq!(heading(body, "A#B").unwrap(), "# A#B\n\nwhole");
395        // The path API never tries the whole reading.
396        assert_eq!(
397            heading_path(body, &["A", "B"]).unwrap(),
398            "## B\n\npath child"
399        );
400    }
401
402    #[test]
403    fn a_block_marker_on_a_list_item_selects_that_item_alone() {
404        let body = "- one ^here\n- two\n";
405        assert_eq!(block(body, "here").unwrap(), "- one");
406    }
407
408    #[test]
409    fn a_heading_selector_disambiguates_by_ancestry() {
410        let body =
411            "# First\n\n## Details\n\nfirst details\n\n# Second\n\n## Details\n\nsecond details\n";
412        let section = heading(body, "Second#Details").unwrap();
413        assert!(section.contains("second details"), "{section}");
414        assert!(!section.contains("first details"), "{section}");
415    }
416
417    #[test]
418    fn an_h1_is_a_plain_heading_not_a_title() {
419        let body = "# Title\n\nintro\n\n## A\n\na\n";
420        assert_eq!(heading(body, "Title").unwrap(), body.trim());
421        assert_eq!(heading_path(body, &["Title", "A"]).unwrap(), "## A\n\na");
422    }
423
424    #[test]
425    fn a_marker_at_the_end_of_a_paragraph_labels_the_whole_paragraph() {
426        let body = "## H\nfirst line\nsecond line ^para\n\nnext\n";
427        assert_eq!(block(body, "para").unwrap(), "first line\nsecond line");
428        assert!(block(body, "missing").is_none());
429        assert!(block("^alone\n", "alone").is_none(), "nothing above it");
430        assert!(block("x^glued\n", "glued").is_none(), "needs whitespace");
431    }
432
433    #[test]
434    fn ranges_point_into_the_body_and_text_reads_crlf_as_lf() {
435        let body = "## A\r\n\r\nalpha ^id\r\n\r\n## B\r\n";
436        let r = heading_range(body, "A").unwrap();
437        assert_eq!(&body[r.clone()], "## A\r\n\r\nalpha ^id");
438        assert_eq!(heading(body, "A").unwrap(), "## A\n\nalpha ^id");
439        let r = block_range(body, "id").unwrap();
440        assert_eq!(&body[r], "alpha");
441    }
442
443    #[test]
444    fn a_fragment_is_a_block_a_heading_or_the_whole_body() {
445        let body = "\n## A\n\nalpha ^id\n\n";
446        assert_eq!(fragment(body, None).unwrap(), "## A\n\nalpha ^id");
447        assert_eq!(fragment(body, Some("^id")).unwrap(), "alpha");
448        assert_eq!(fragment(body, Some("A")).unwrap(), "## A\n\nalpha ^id");
449        assert!(fragment(body, Some("Z")).is_none());
450    }
451}