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/// Which surface syntax produced this reference.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum RefSyntax {
14    /// `[[stem]]` — bare wikilink, stem only (no `/`)
15    WikilinkStem,
16    /// `[[a/b]]` — wikilink with a path component
17    WikilinkPath,
18    /// `![[x]]` — embed, bare stem
19    WikilinkStemEmbed,
20    /// `![[a/b]]` — embed, path
21    WikilinkPathEmbed,
22    /// `[[stem|Display]]` — wikilink with alias
23    WikilinkAliased { display: String },
24    /// `![[stem|Display]]` / `![[stem|500]]` — embed with pothole
25    WikilinkAliasedEmbed { display: String },
26    /// `[label](path)` — standard markdown link
27    MarkdownLink { label: String },
28    /// `![alt](path)` — standard markdown image
29    MarkdownImage { alt: String },
30}
31
32/// A raw reference extracted from a markdown source string.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct RawRef {
35    /// The resolved/target text (the inner `stem`, `a/b`, or `path` part — no
36    /// brackets, no alias, no pothole). This is the string to pass to the
37    /// classifier.
38    pub text: String,
39    /// Which syntax form produced this reference.
40    pub syntax: RefSyntax,
41    /// Byte offset in the source string where the token starts (inclusive).
42    pub byte_from: usize,
43    /// Byte offset in the source string where the token ends (exclusive).
44    pub byte_to: usize,
45}
46
47/// Extract all markdown references from `source`.
48///
49/// Skips content inside fenced code blocks (` ``` ` / `~~~`) and inline
50/// code spans.  Does **not** skip HTML comments — `<!-- moss-embed:… -->`
51/// is build-internal and not a user-authored reference.
52///
53/// External URLs (`http://…`, `https://…`, `//`, `mailto:`, `tel:`, `data:`)
54/// are included as `MarkdownLink` / `MarkdownImage` — the caller decides
55/// whether to filter them out.
56pub fn extract_md_references(source: &str) -> Vec<RawRef> {
57    let bytes = source.as_bytes();
58    let len = bytes.len();
59    let mut refs = Vec::new();
60    let mut i = 0;
61
62    // Fenced block tracking: Some(char) while inside a fence.
63    let mut fence_char: Option<u8> = None;
64    // Track line starts for fence detection.
65    let mut line_start = 0;
66
67    while i < len {
68        // ── Newline: advance line_start, check for fence ─────────────────
69        if bytes[i] == b'\n' {
70            i += 1;
71            line_start = i;
72            continue;
73        }
74
75        // ── At start of a line: check for fence open/close ───────────────
76        if i == line_start {
77            // Skip leading whitespace (up to 3 spaces per CommonMark for fences)
78            let mut j = i;
79            while j < len && (bytes[j] == b' ' || bytes[j] == b'\t') && j - i < 4 {
80                j += 1;
81            }
82            // Check for ``` or ~~~
83            let fence_cand = if j + 2 < len && bytes[j] == b'`' && bytes[j+1] == b'`' && bytes[j+2] == b'`' {
84                Some(b'`')
85            } else if j + 2 < len && bytes[j] == b'~' && bytes[j+1] == b'~' && bytes[j+2] == b'~' {
86                Some(b'~')
87            } else {
88                None
89            };
90            if let Some(fc) = fence_cand {
91                if let Some(cur_fc) = fence_char {
92                    if cur_fc == fc {
93                        // Closing fence: rest of line must not contain fc
94                        let mut k = j + 3;
95                        while k < len && bytes[k] == fc { k += 1; }
96                        // skip spaces
97                        while k < len && bytes[k] == b' ' { k += 1; }
98                        if k >= len || bytes[k] == b'\n' {
99                            fence_char = None;
100                            // Advance past the closing-fence line itself. Without
101                            // this, `i` still points at the fence line and the
102                            // backtick handler below would consume the rest of
103                            // the file, silently dropping every reference AFTER a
104                            // fenced code block.
105                            while i < len && bytes[i] != b'\n' {
106                                i += 1;
107                            }
108                            continue;
109                        }
110                    }
111                } else {
112                    fence_char = Some(fc);
113                }
114            }
115        }
116
117        // Inside a fenced block: skip until newline
118        if fence_char.is_some() {
119            while i < len && bytes[i] != b'\n' {
120                i += 1;
121            }
122            continue;
123        }
124
125        // ── Inline code span: skip ──────────────────────────────────────
126        if bytes[i] == b'`' {
127            // Count backtick run
128            let mut n = 0;
129            while i + n < len && bytes[i + n] == b'`' { n += 1; }
130            let start = i;
131            i += n;
132            // Find matching run of n backticks
133            while i < len {
134                if bytes[i] == b'`' {
135                    let mut m = 0;
136                    while i + m < len && bytes[i + m] == b'`' { m += 1; }
137                    if m == n {
138                        i += m;
139                        break;
140                    }
141                    i += m;
142                } else {
143                    i += 1;
144                }
145            }
146            let _ = start;
147            continue;
148        }
149
150        // ── Backslash escape: `\[[note]]` / `\[t](p)` are NOT references ──
151        // Skip the backslash and the next char so the escaped bracket can't
152        // start a reference token. Advance by a full char (not a byte) so `i`
153        // stays on a UTF-8 boundary for later string slices.
154        if bytes[i] == b'\\' {
155            i += 1; // past the backslash (ASCII, boundary-safe)
156            if i < len {
157                // SAFETY: `i` is a char boundary here; read one full char.
158                #[allow(clippy::string_slice)]
159                if let Some(ch) = source[i..].chars().next() {
160                    i += ch.len_utf8();
161                }
162            }
163            continue;
164        }
165
166        // ── Wikilink / embed: ![[…]] or [[…]] ───────────────────────────
167        let is_embed_wikilink = i + 4 < len
168            && bytes[i] == b'!'
169            && bytes[i+1] == b'['
170            && bytes[i+2] == b'[';
171        let is_wikilink = !is_embed_wikilink
172            && i + 3 < len
173            && bytes[i] == b'['
174            && bytes[i+1] == b'[';
175
176        if is_embed_wikilink || is_wikilink {
177            let token_start = i;
178            let inner_start = if is_embed_wikilink { i + 3 } else { i + 2 };
179            // Find closing ]]
180            if let Some(close) = find_double_bracket(bytes, inner_start) {
181                // SAFETY: inner_start and close are valid UTF-8 char boundaries
182                // because we only advance past ASCII bytes ([, !, ]) to reach them.
183                #[allow(clippy::string_slice)]
184                let inner = &source[inner_start..close];
185                let token_end = close + 2;
186                // Split on | for alias/pothole
187                let (path_part, pipe_part) = match inner.find('|') {
188                    Some(p) => (&inner[..p], Some(&inner[p+1..])),
189                    None => (inner, None),
190                };
191                // Only record non-empty targets
192                if !path_part.trim().is_empty() {
193                    let text = path_part.trim().to_string();
194                    let has_slash = text.contains('/');
195                    let syntax = match (is_embed_wikilink, pipe_part) {
196                        (false, None) => {
197                            if has_slash { RefSyntax::WikilinkPath } else { RefSyntax::WikilinkStem }
198                        }
199                        (false, Some(alias)) => RefSyntax::WikilinkAliased { display: alias.to_string() },
200                        (true, None) => {
201                            if has_slash { RefSyntax::WikilinkPathEmbed } else { RefSyntax::WikilinkStemEmbed }
202                        }
203                        (true, Some(pot)) => RefSyntax::WikilinkAliasedEmbed { display: pot.to_string() },
204                    };
205                    refs.push(RawRef { text, syntax, byte_from: token_start, byte_to: token_end });
206                }
207                i = token_end;
208                continue;
209            }
210        }
211
212        // ── Markdown image ![alt](path) ──────────────────────────────────
213        if i + 3 < len && bytes[i] == b'!' && bytes[i+1] == b'[' {
214            if let Some((alt, path, end)) = parse_md_link(source, bytes, i + 1) {
215                let token_start = i;
216                refs.push(RawRef {
217                    text: path,
218                    syntax: RefSyntax::MarkdownImage { alt },
219                    byte_from: token_start,
220                    byte_to: end,
221                });
222                i = end;
223                continue;
224            }
225        }
226
227        // ── Markdown link [label](path) ──────────────────────────────────
228        if bytes[i] == b'[' {
229            // Guard: not a wikilink (already handled above)
230            if i + 1 < len && bytes[i+1] != b'[' {
231                if let Some((label, path, end)) = parse_md_link(source, bytes, i) {
232                    refs.push(RawRef {
233                        text: path,
234                        syntax: RefSyntax::MarkdownLink { label },
235                        byte_from: i,
236                        byte_to: end,
237                    });
238                    i = end;
239                    continue;
240                }
241            }
242        }
243
244        i += 1;
245    }
246
247    refs
248}
249
250/// Find the byte index of the first `]]` in `bytes` at or after `start`.
251/// Returns the index of the first `]` in the `]]` pair, or `None`.
252fn find_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
253    let mut j = start;
254    while j + 1 < bytes.len() {
255        if bytes[j] == b']' && bytes[j+1] == b']' {
256            return Some(j);
257        }
258        // Bail on newline — wikilinks are single-line
259        if bytes[j] == b'\n' {
260            return None;
261        }
262        j += 1;
263    }
264    None
265}
266
267/// Parse a `[label](path)` or `![alt](path)` link starting at `bracket_pos`
268/// (the position of the opening `[`).
269/// Returns `(label_or_alt, path, byte_end)` or `None`.
270fn parse_md_link(source: &str, bytes: &[u8], bracket_pos: usize) -> Option<(String, String, usize)> {
271    let len = bytes.len();
272    // Find closing ] — but respect nested brackets and bail on newline
273    let mut depth = 0usize;
274    let mut j = bracket_pos;
275    while j < len {
276        match bytes[j] {
277            b'[' => { depth += 1; j += 1; }
278            b']' => {
279                depth -= 1;
280                if depth == 0 { break; }
281                j += 1;
282            }
283            b'\n' => return None,
284            _ => { j += 1; }
285        }
286    }
287    if j >= len || bytes[j] != b']' { return None; }
288    let label_start = bracket_pos + 1;
289    let label_end = j;
290    #[allow(clippy::string_slice)]
291    let label = source[label_start..label_end].to_string();
292
293    // Expect `(` immediately after `]`
294    let paren_open = j + 1;
295    if paren_open >= len || bytes[paren_open] != b'(' { return None; }
296
297    // Find closing `)` — respect nesting, bail on newline
298    let mut depth = 0usize;
299    let mut k = paren_open;
300    while k < len {
301        match bytes[k] {
302            b'(' => { depth += 1; k += 1; }
303            b')' => {
304                depth -= 1;
305                if depth == 0 { break; }
306                k += 1;
307            }
308            b'\n' => return None,
309            _ => { k += 1; }
310        }
311    }
312    if k >= len || bytes[k] != b')' { return None; }
313    let path_start = paren_open + 1;
314    let path_end = k;
315    #[allow(clippy::string_slice)]
316    let path_raw = source[path_start..path_end].trim().to_string();
317    // Strip optional title: `path "title"` → path
318    let path = strip_link_title(&path_raw);
319    let token_end = k + 1;
320
321    Some((label, path, token_end))
322}
323
324/// Strip an optional CommonMark link title from a raw link destination string.
325/// `path "My Title"` → `path`, `path 'title'` → `path`, `path (title)` → `path`.
326/// If no title is present, returns the input unchanged.
327fn strip_link_title(raw: &str) -> String {
328    let raw = raw.trim();
329    // Find the last whitespace-separated token that looks like a title
330    if let Some(ws) = raw.rfind(|c: char| c.is_ascii_whitespace()) {
331        let (path_part, maybe_title) = raw.split_at(ws);
332        let maybe_title = maybe_title.trim();
333        let is_title = (maybe_title.starts_with('"') && maybe_title.ends_with('"'))
334            || (maybe_title.starts_with('\'') && maybe_title.ends_with('\''))
335            || (maybe_title.starts_with('(') && maybe_title.ends_with(')'));
336        if is_title {
337            return path_part.trim().to_string();
338        }
339    }
340    raw.to_string()
341}
342
343// ── Tests ─────────────────────────────────────────────────────────────────────
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn wikilink_stem() {
351        let src = "See [[note]] for details.";
352        let refs = extract_md_references(src);
353        assert_eq!(refs.len(), 1);
354        assert_eq!(refs[0].text, "note");
355        assert_eq!(refs[0].syntax, RefSyntax::WikilinkStem);
356        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[note]]");
357    }
358
359    #[test]
360    fn wikilink_path() {
361        let src = "See [[a/b]] here.";
362        let refs = extract_md_references(src);
363        assert_eq!(refs.len(), 1);
364        assert_eq!(refs[0].text, "a/b");
365        assert_eq!(refs[0].syntax, RefSyntax::WikilinkPath);
366        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a/b]]");
367    }
368
369    #[test]
370    fn wikilink_stem_embed() {
371        let src = "![[x]] is an embed.";
372        let refs = extract_md_references(src);
373        assert_eq!(refs.len(), 1);
374        assert_eq!(refs[0].text, "x");
375        assert_eq!(refs[0].syntax, RefSyntax::WikilinkStemEmbed);
376        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[x]]");
377    }
378
379    #[test]
380    fn wikilink_path_embed() {
381        let src = "![[a/b]] embedded.";
382        let refs = extract_md_references(src);
383        assert_eq!(refs.len(), 1);
384        assert_eq!(refs[0].text, "a/b");
385        assert_eq!(refs[0].syntax, RefSyntax::WikilinkPathEmbed);
386        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[a/b]]");
387    }
388
389    #[test]
390    fn wikilink_aliased() {
391        let src = "See [[stem|Display]] here.";
392        let refs = extract_md_references(src);
393        assert_eq!(refs.len(), 1);
394        assert_eq!(refs[0].text, "stem");
395        assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliased { display } if display == "Display"));
396        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[stem|Display]]");
397    }
398
399    #[test]
400    fn wikilink_aliased_embed() {
401        let src = "![[stem|500]] wide embed.";
402        let refs = extract_md_references(src);
403        assert_eq!(refs.len(), 1);
404        assert_eq!(refs[0].text, "stem");
405        assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "500"));
406        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![[stem|500]]");
407    }
408
409    #[test]
410    fn markdown_link() {
411        let src = "Click [here](page.md) now.";
412        let refs = extract_md_references(src);
413        assert_eq!(refs.len(), 1);
414        assert_eq!(refs[0].text, "page.md");
415        assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { label } if label == "here"));
416        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[here](page.md)");
417    }
418
419    #[test]
420    fn markdown_image() {
421        let src = "![alt text](img.png) here.";
422        let refs = extract_md_references(src);
423        assert_eq!(refs.len(), 1);
424        assert_eq!(refs[0].text, "img.png");
425        assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownImage { alt } if alt == "alt text"));
426        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "![alt text](img.png)");
427    }
428
429    #[test]
430    fn external_link_included() {
431        let src = "[foo](https://example.com)";
432        let refs = extract_md_references(src);
433        assert_eq!(refs.len(), 1);
434        assert_eq!(refs[0].text, "https://example.com");
435        assert!(matches!(&refs[0].syntax, RefSyntax::MarkdownLink { .. }));
436    }
437
438    #[test]
439    fn skip_fenced_code_block() {
440        let src = "```\n[[note]]\n```\nAfter.";
441        let refs = extract_md_references(src);
442        assert_eq!(refs.len(), 0, "wikilink inside fenced block should be skipped");
443    }
444
445    #[test]
446    fn ref_after_fence_is_found() {
447        // Regression: the closing-fence line must be advanced past, otherwise
448        // the backtick handler swallows the rest of the file and drops every
449        // reference after a fenced block.
450        let src = "```\n[[skip]]\n```\n[[find]]";
451        let refs = extract_md_references(src);
452        assert_eq!(refs.len(), 1, "exactly the ref after the fence is found, got: {:?}", refs);
453        assert_eq!(refs[0].text, "find");
454        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[find]]");
455    }
456
457    #[test]
458    fn backslash_escaped_refs_are_skipped() {
459        // `\[[note]]` and `\[t](p)` are escaped and must NOT be extracted.
460        let src = "Escaped \\[[note]] and \\[t](p.md) but [[real]] counts.";
461        let refs = extract_md_references(src);
462        assert_eq!(refs.len(), 1, "only the unescaped ref should be found, got: {:?}", refs);
463        assert_eq!(refs[0].text, "real");
464    }
465
466    #[test]
467    fn skip_inline_code_span() {
468        let src = "In `` [[note]] `` code.";
469        let refs = extract_md_references(src);
470        assert_eq!(refs.len(), 0, "wikilink inside inline code should be skipped");
471    }
472
473    #[test]
474    fn multiple_refs_byte_offsets() {
475        let src = "[[a]] and [[b]]";
476        let refs = extract_md_references(src);
477        assert_eq!(refs.len(), 2);
478        assert_eq!(&src[refs[0].byte_from..refs[0].byte_to], "[[a]]");
479        assert_eq!(&src[refs[1].byte_from..refs[1].byte_to], "[[b]]");
480    }
481
482    #[test]
483    fn aliased_embed_preserves_alias() {
484        // ![[image.png|600]] — pothole is "600"
485        let src = "![[image.png|600]]";
486        let refs = extract_md_references(src);
487        assert_eq!(refs.len(), 1);
488        assert_eq!(refs[0].text, "image.png");
489        assert!(matches!(&refs[0].syntax, RefSyntax::WikilinkAliasedEmbed { display } if display == "600"));
490    }
491}