Skip to main content

moss_core/resolve/
md_extract.rs

1//! Pure markdown reference extractor — zero I/O, no resolve, no indexes.
2//!
3//! Scans raw markdown source for every reference token (wikilink / embed /
4//! markdown link / markdown image) and returns the raw text plus byte offsets
5//! covering the whole token. The offsets let callers rewrite the source without
6//! re-scanning.
7//!
8//! **No resolution** happens here. The caller (src-tauri) resolves each
9//! `RawRef` against the project's indexes.
10//!
11//! Recognition runs over [`crate::inert_regions`]'s mask rather than a
12//! private fence tracker, so this scanner and
13//! [`crate::ast::shortcode_extract::shortcode_asset_spans`] give one
14//! identical answer to "which bytes are live syntax". Two behaviours changed
15//! when the private tracker was deleted (2026-08-03): references inside an
16//! authored `<!-- … -->` comment and inside an indented code block are no
17//! longer extracted. The old doc justified scanning comments with
18//! build-internal `<!-- moss-embed:… -->` sentinels, which never appear in
19//! the author files this module's only consumer (src-tauri's
20//! `editor::ref_scan`) reads from disk.
21
22/// Which surface syntax produced this reference.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RefSyntax {
25    /// `[[stem]]` — bare wikilink, stem only (no `/`)
26    WikilinkStem,
27    /// `[[a/b]]` — wikilink with a path component
28    WikilinkPath,
29    /// `![[x]]` — embed, bare stem
30    WikilinkStemEmbed,
31    /// `![[a/b]]` — embed, path
32    WikilinkPathEmbed,
33    /// `[[stem|Display]]` — wikilink with alias
34    WikilinkAliased { display: String },
35    /// `![[stem|Display]]` / `![[stem|500]]` — embed with pothole
36    WikilinkAliasedEmbed { display: String },
37    /// `[label](path)` — standard markdown link
38    MarkdownLink { label: String },
39    /// `![alt](path)` — standard markdown image
40    MarkdownImage { alt: String },
41}
42
43/// A raw reference extracted from a markdown source string.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct RawRef {
46    /// The resolved/target text (the inner `stem`, `a/b`, or `path` part — no
47    /// brackets, no alias, no pothole). This is the string to pass to the
48    /// classifier.
49    pub text: String,
50    /// Which syntax form produced this reference.
51    pub syntax: RefSyntax,
52    /// Byte offset in the source string where the token starts (inclusive).
53    pub byte_from: usize,
54    /// Byte offset in the source string where the token ends (exclusive).
55    pub byte_to: usize,
56    /// Byte span of [`text`](Self::text) itself inside the source — the
57    /// wikilink target, or the markdown destination with any title stripped.
58    /// `source[ref_from..ref_to] == text`.
59    ///
60    /// A RENAME replaces exactly this span, which is narrower than
61    /// `byte_from..byte_to` and never covers a nested reference. That is what
62    /// makes `[![[hero.png]]](/album/)` rewritable: the inner embed's span and
63    /// the outer link's destination span are disjoint, so both can be edited
64    /// in one pass. Rebuilding the whole token from `syntax` instead would
65    /// re-emit the label verbatim and silently drop the inner rewrite.
66    pub ref_from: usize,
67    /// Exclusive end of [`ref_from`](Self::ref_from).
68    pub ref_to: usize,
69}
70
71/// Extract all markdown references from `source`.
72///
73/// Recognition runs over [`crate::inert_regions::mask_inert`], the one
74/// shared answer to "which bytes are not live syntax" — so references
75/// inside fenced code blocks, indented code blocks, inline code spans and
76/// HTML comments are skipped. Every *string* (`text`, `label`, `alt`, the
77/// wikilink alias) is sliced from the ORIGINAL source, because the mask
78/// blanks inline code spans and would otherwise corrupt a label like
79/// ``[a `b` c](x.md)``.
80///
81/// External URLs (`http://…`, `https://…`, `//`, `mailto:`, `tel:`, `data:`)
82/// are included as `MarkdownLink` / `MarkdownImage` — the caller decides
83/// whether to filter them out.
84///
85/// **Nested references are reported too.** The label of a markdown link is
86/// re-scanned, so `[![[hero.png]]](/album/)` — ADR-041's link-wrapped embed —
87/// yields the outer `MarkdownLink` AND the inner `WikilinkStemEmbed`, and
88/// `[![a](hero.png)](/album/)` yields the outer link and the inner image.
89/// Before that, the inner reference existed only as a substring of the outer
90/// ref's `label`, so a rename left it dangling with no report. Results stay in
91/// source order, with an enclosing reference immediately preceding the ones
92/// nested inside it.
93///
94/// An image's `alt` is deliberately NOT re-scanned: `![alt [![[x]]](/u)](y.png)`
95/// is one image whose alt text happens to contain brackets, and rewriting
96/// inside it would edit prose.
97pub fn extract_md_references(source: &str) -> Vec<RawRef> {
98    let mask = crate::inert_regions::mask_inert(source);
99    let mut refs = Vec::new();
100    scan_range(source, mask.as_bytes(), 0, source.len(), &mut refs);
101    refs
102}
103
104/// Scan `source[from..to]` for reference tokens, appending to `refs`.
105///
106/// `bytes` is the whole-source inert mask (byte-length preserving, so an
107/// offset in it is an offset in `source`); `to` bounds every lookahead, which
108/// is what keeps a nested scan of a link label from claiming a `]]` or a `)`
109/// that lives past the label's end.
110fn scan_range(source: &str, bytes: &[u8], from: usize, to: usize, refs: &mut Vec<RawRef>) {
111    let len = to;
112    let mut i = from;
113
114    while i < len {
115        // ── Backslash escape: `\[[note]]` / `\[t](p)` are NOT references ──
116        // An escape is not an inert region (the mask leaves it alone), so it
117        // stays a case here. Skip the backslash and the next char so the
118        // escaped bracket can't start a reference token. Advance by a full
119        // char (not a byte) so `i` stays on a UTF-8 boundary for later slices.
120        if bytes[i] == b'\\' {
121            i += 1; // past the backslash (ASCII, boundary-safe)
122            if i < len {
123                // SAFETY: `i` is a char boundary here; read one full char.
124                #[allow(clippy::string_slice)]
125                if let Some(ch) = source[i..].chars().next() {
126                    i += ch.len_utf8();
127                }
128            }
129            continue;
130        }
131
132        // ── Wikilink / embed: ![[…]] or [[…]] ───────────────────────────
133        let is_embed_wikilink = i + 4 < len
134            && bytes[i] == b'!'
135            && bytes[i+1] == b'['
136            && bytes[i+2] == b'[';
137        let is_wikilink = !is_embed_wikilink
138            && i + 3 < len
139            && bytes[i] == b'['
140            && bytes[i+1] == b'[';
141
142        if is_embed_wikilink || is_wikilink {
143            let token_start = i;
144            let inner_start = if is_embed_wikilink { i + 3 } else { i + 2 };
145            // Find closing ]] — in the MASK, so a `]]` hidden in inline code
146            // does not close a live wikilink.
147            if let Some(close) = find_double_bracket(bytes, inner_start, len) {
148                // SAFETY: inner_start and close are valid UTF-8 char boundaries
149                // because we only advance past ASCII bytes ([, !, ]) to reach them.
150                #[allow(clippy::string_slice)]
151                let inner = &source[inner_start..close];
152                let token_end = close + 2;
153                // Split on | for alias/pothole
154                let (path_part, pipe_part) = match inner.split_once('|') {
155                    Some((before, after)) => (before, Some(after)),
156                    None => (inner, None),
157                };
158                // Only record non-empty targets
159                if !path_part.trim().is_empty() {
160                    let text = path_part.trim().to_string();
161                    let has_slash = text.contains('/');
162                    let syntax = match (is_embed_wikilink, pipe_part) {
163                        (false, None) => {
164                            if has_slash { RefSyntax::WikilinkPath } else { RefSyntax::WikilinkStem }
165                        }
166                        (false, Some(alias)) => RefSyntax::WikilinkAliased { display: alias.to_string() },
167                        (true, None) => {
168                            if has_slash { RefSyntax::WikilinkPathEmbed } else { RefSyntax::WikilinkStemEmbed }
169                        }
170                        (true, Some(pot)) => RefSyntax::WikilinkAliasedEmbed { display: pot.to_string() },
171                    };
172                    // `text` is `path_part` trimmed; its span starts past the
173                    // leading whitespace `trim` removed.
174                    let ref_from = inner_start + (path_part.len() - path_part.trim_start().len());
175                    let ref_to = ref_from + text.len();
176                    refs.push(RawRef {
177                        text,
178                        syntax,
179                        byte_from: token_start,
180                        byte_to: token_end,
181                        ref_from,
182                        ref_to,
183                    });
184                }
185                i = token_end;
186                continue;
187            }
188        }
189
190        // ── Markdown image ![alt](path) ──────────────────────────────────
191        if i + 3 < len && bytes[i] == b'!' && bytes[i+1] == b'[' {
192            if let Some(link) = parse_md_link(source, bytes, i + 1, len) {
193                let token_start = i;
194                refs.push(RawRef {
195                    text: link.path,
196                    syntax: RefSyntax::MarkdownImage { alt: link.label },
197                    byte_from: token_start,
198                    byte_to: link.token_end,
199                    ref_from: link.path_from,
200                    ref_to: link.path_to,
201                });
202                i = link.token_end;
203                continue;
204            }
205        }
206
207        // ── Markdown link [label](path) ──────────────────────────────────
208        if bytes[i] == b'[' {
209            // Guard: not a wikilink (already handled above)
210            if i + 1 < len && bytes[i+1] != b'[' {
211                if let Some(link) = parse_md_link(source, bytes, i, len) {
212                    let (label_from, label_to) = (link.label_from, link.label_to);
213                    let end = link.token_end;
214                    refs.push(RawRef {
215                        text: link.path,
216                        syntax: RefSyntax::MarkdownLink { label: link.label },
217                        byte_from: i,
218                        byte_to: end,
219                        ref_from: link.path_from,
220                        ref_to: link.path_to,
221                    });
222                    // The label can itself hold references — ADR-041's
223                    // `[![[hero.png]]](/album/)`, or the CommonMark spelling
224                    // `[![a](hero.png)](/album/)`. Scan it, bounded by the
225                    // label's own end.
226                    scan_range(source, bytes, label_from, label_to, refs);
227                    i = end;
228                    continue;
229                }
230            }
231        }
232
233        i += 1;
234    }
235}
236
237/// Find the byte index of the first `]]` in `bytes[start..limit]`.
238/// Returns the index of the first `]` in the `]]` pair, or `None`.
239fn find_double_bracket(bytes: &[u8], start: usize, limit: usize) -> Option<usize> {
240    let bytes = &bytes[..limit];
241    let mut j = start;
242    while j + 1 < bytes.len() {
243        if bytes[j] == b']' && bytes[j+1] == b']' {
244            return Some(j);
245        }
246        // Bail on newline — wikilinks are single-line
247        if bytes[j] == b'\n' {
248            return None;
249        }
250        j += 1;
251    }
252    None
253}
254
255/// One parsed `[label](path)` / `![alt](path)` token, with the byte spans a
256/// rewriter needs: `label_from..label_to` (re-scanned for nested references)
257/// and `path_from..path_to` (the only bytes a rename touches).
258struct ParsedLink {
259    label: String,
260    label_from: usize,
261    label_to: usize,
262    path: String,
263    path_from: usize,
264    path_to: usize,
265    token_end: usize,
266}
267
268/// Parse a `[label](path)` or `![alt](path)` link starting at `bracket_pos`
269/// (the position of the opening `[`), scanning no further than `limit`.
270fn parse_md_link(
271    source: &str,
272    bytes: &[u8],
273    bracket_pos: usize,
274    limit: usize,
275) -> Option<ParsedLink> {
276    let len = limit;
277    // Find closing ] — but respect nested brackets and bail on newline
278    let mut depth = 0usize;
279    let mut j = bracket_pos;
280    while j < len {
281        match bytes[j] {
282            b'[' => { depth += 1; j += 1; }
283            b']' => {
284                depth -= 1;
285                if depth == 0 { break; }
286                j += 1;
287            }
288            b'\n' => return None,
289            _ => { j += 1; }
290        }
291    }
292    if j >= len || bytes[j] != b']' { return None; }
293    let label_start = bracket_pos + 1;
294    let label_end = j;
295    #[allow(clippy::string_slice)]
296    let label = source[label_start..label_end].to_string();
297
298    // Expect `(` immediately after `]`
299    let paren_open = j + 1;
300    if paren_open >= len || bytes[paren_open] != b'(' { return None; }
301
302    // Find closing `)` — respect nesting, bail on newline
303    let mut depth = 0usize;
304    let mut k = paren_open;
305    while k < len {
306        match bytes[k] {
307            b'(' => { depth += 1; k += 1; }
308            b')' => {
309                depth -= 1;
310                if depth == 0 { break; }
311                k += 1;
312            }
313            b'\n' => return None,
314            _ => { k += 1; }
315        }
316    }
317    if k >= len || bytes[k] != b')' { return None; }
318    let path_start = paren_open + 1;
319    let path_end = k;
320    #[allow(clippy::string_slice)]
321    let raw = &source[path_start..path_end];
322    // Strip optional title: `path "title"` → path. Both `trim` and
323    // `strip_link_title` only ever cut from the ends, so the surviving text is
324    // a prefix of the trimmed slice and its span is arithmetic, not a search
325    // (a `find` would land on the wrong copy of a repeated path).
326    let trimmed = raw.trim();
327    let path_from = path_start + (raw.len() - raw.trim_start().len());
328    let path = strip_link_title(trimmed);
329    let path_to = path_from + path.len();
330
331    Some(ParsedLink {
332        label,
333        label_from: label_start,
334        label_to: label_end,
335        path,
336        path_from,
337        path_to,
338        token_end: k + 1,
339    })
340}
341
342/// Strip an optional CommonMark link title from a raw link destination string.
343/// `path "My Title"` → `path`, `path 'title'` → `path`, `path (title)` → `path`.
344/// If no title is present, returns the input unchanged.
345fn strip_link_title(raw: &str) -> String {
346    let raw = raw.trim();
347    // Find the last whitespace-separated token that looks like a title
348    if let Some(ws) = raw.rfind(|c: char| c.is_ascii_whitespace()) {
349        let (path_part, maybe_title) = raw.split_at(ws);
350        let maybe_title = maybe_title.trim();
351        let is_title = (maybe_title.starts_with('"') && maybe_title.ends_with('"'))
352            || (maybe_title.starts_with('\'') && maybe_title.ends_with('\''))
353            || (maybe_title.starts_with('(') && maybe_title.ends_with(')'));
354        if is_title {
355            return path_part.trim().to_string();
356        }
357    }
358    raw.to_string()
359}
360
361// ── Tests ─────────────────────────────────────────────────────────────────────
362
363#[cfg(test)]
364#[path = "md_extract_tests.rs"]
365mod tests;
366
367// ── Structural asset paths ────────────────────────────────────────────────
368//
369// `extract_md_references` above sees only BRACKETED markdown tokens. A
370// `:::gallery` body line, a `:::hero {image=…}` attribute and a frontmatter
371// `cover:` value are asset references with no reference syntax around them,
372// so they were invisible to rename tracking and silently broke on rename.
373// The types below are the second half of the answer; `ref_scan` (src-tauri)
374// unions the two.
375
376/// Which container a structurally-extracted path was found in.
377/// Decides quoting when the value is rebuilt.
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub enum PathContainer {
380    /// A body line of a `:::gallery` block.
381    GalleryBody,
382    /// A body media line of a `:::hero` block.
383    HeroBodyMedia,
384    /// The positional path on a `:::hero <path>` directive line.
385    HeroDirective,
386    /// A `key=value` attribute of a `:::` block (today only `image=`).
387    ShortcodeAttr { key: String },
388    /// A frontmatter field whose value names a project file.
389    FrontmatterField { key: String },
390}
391
392/// An asset path that occupies a span with NO markdown reference syntax
393/// around it.
394///
395/// # Contract
396///
397/// A rename replaces `value` with `render_bare_value(container, quote, path,
398/// attrs)`; a delete removes `outer`. `attrs` is the `|attrs` suffix that
399/// lives INSIDE `value` — it is empty when the author's attrs sit outside it
400/// (`![alt](x.png)|cover`, where the attrs follow the closing paren), so
401/// re-rendering never duplicates or drops them.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub struct AssetPathSpan {
404    /// Decoded and trimmed: no quotes, no `|attrs`, no `[[ ]]`.
405    pub path: String,
406    /// The `|attrs` suffix inside `value`, or `""` — rebuilt verbatim.
407    pub attrs: String,
408    /// Quote character the value was wrapped in, if any.
409    pub quote: Option<char>,
410    /// Span a RENAME rewrites — the whole value, quotes included.
411    pub value: std::ops::Range<usize>,
412    /// Span a DELETE removes — the whole gallery/frontmatter line
413    /// (including its terminator) or the whole `key=value` attr item.
414    pub outer: std::ops::Range<usize>,
415    /// Where the path was found.
416    pub container: PathContainer,
417}
418
419/// One recognized media reference on a single line, with LINE-RELATIVE
420/// offsets. Produced by the gallery and hero line recognizers; lifted to
421/// absolute offsets by the block-level scanner.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct MediaLineSpan {
424    /// The path text as the parser would read it.
425    pub path: String,
426    /// Alt text (`![alt](…)`), or `""`.
427    pub alt: String,
428    /// The full `|attrs` suffix the parser reads (may sit outside `value`).
429    pub attrs: String,
430    /// Line-relative span a rename replaces.
431    pub value: std::ops::Range<usize>,
432    /// The `|attrs` suffix contained WITHIN `value`.
433    pub value_attrs: String,
434    /// The line carried markdown reference syntax (`![[…]]` / `![…](…)`),
435    /// so [`extract_md_references`] already sees it as a token.
436    pub is_token: bool,
437}
438
439/// `(base, content_len, terminator_len)` per physical line of `source`,
440/// index-aligned with [`str::lines`].
441pub(crate) fn line_table(source: &str) -> Vec<(usize, usize, usize)> {
442    let bytes = source.as_bytes();
443    let mut table = Vec::new();
444    let mut base = 0usize;
445    while base <= bytes.len() {
446        let nl = bytes[base..].iter().position(|&b| b == b'\n');
447        match nl {
448            Some(off) => {
449                let mut content = off;
450                if content > 0 && bytes[base + content - 1] == b'\r' {
451                    content -= 1;
452                }
453                table.push((base, content, off - content + 1));
454                base += off + 1;
455            }
456            None => {
457                if base < bytes.len() {
458                    table.push((base, bytes.len() - base, 0));
459                }
460                break;
461            }
462        }
463    }
464    table
465}
466
467/// Every structural asset path in `source`, ascending by `value.start`.
468///
469/// Complements [`extract_md_references`], which sees only bracketed markdown
470/// tokens. A caller that REWRITES must union the two and resolve overlaps —
471/// see `apply_edits` in src-tauri's `editor::ref_scan`.
472pub fn extract_structural_asset_refs(source: &str) -> Vec<AssetPathSpan> {
473    let mut v = crate::ast::shortcode_extract::shortcode_asset_spans(source);
474    v.extend(crate::frontmatter::frontmatter_asset_spans(source));
475    v.sort_by_key(|s| s.value.start);
476    v
477}