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 /// `` — 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/// `[](/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  ──────────────────────────────────
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 // `[](/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)` / `` 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 `` 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/// (`|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 (``), 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}