Skip to main content

rumdl_lib/utils/
mkdocstrings_refs.rs

1use regex::Regex;
2/// MkDocstrings cross-references detection utilities
3///
4/// MkDocstrings provides automatic cross-references to documented code objects
5/// using special syntax patterns for Python, JavaScript, and other languages.
6///
7/// Common patterns:
8/// - `::: module.Class` - Auto-doc insertion
9/// - `[module.Class][]` - Cross-reference link
10/// - `[text][module.Class]` - Cross-reference with custom text
11/// - `::: module.Class` with options block (YAML indented)
12use std::sync::LazyLock;
13
14use super::mkdocs_common::get_line_indent;
15use crate::config::MarkdownFlavor;
16use crate::utils::skip_context::ByteRange;
17
18/// Pre-filter regex for auto-doc insertion markers.
19/// Matches any `:::` followed by non-whitespace. Which identifiers count as
20/// auto-doc depends on the flavor and the following lines; see
21/// `opens_autodoc_block()`.
22static AUTODOC_MARKER: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(
24        r"^(\s*):::\s+\S+.*$", // Pre-filter: any non-whitespace after :::
25    )
26    .unwrap()
27});
28
29/// mkdocstrings configuration keys that can open the YAML block after a marker.
30/// mkdocstrings itself recognises these two when the block follows a blank line.
31const OPTIONS_BLOCK_KEYS: [&str; 2] = ["handler:", "options:"];
32
33/// The identifier after an auto-doc marker, read the way mkdocstrings reads it:
34/// everything after `::: `. Pandoc attribute syntax (`::: {.note}`) is never one.
35fn marker_identifier(line: &str) -> Option<&str> {
36    if !line.trim_start().starts_with(":::") || !AUTODOC_MARKER.is_match(line) {
37        return None;
38    }
39    let identifier = line.trim_start().strip_prefix(":::")?.trim();
40    (!identifier.starts_with('{')).then_some(identifier)
41}
42
43/// Check if a line is an auto-doc marker on its own, without context
44///
45/// Matches mkdocstrings syntax `::: module.Class` but NOT Pandoc fenced divs
46/// like `::: warning` or `::: {.note}`. The key distinction is that autodoc
47/// paths contain at least one `.` or `:` separator (e.g., `package.module`,
48/// `handler:path`), while Pandoc divs use plain words or `{}`-wrapped classes.
49/// A marker this rejects can still open an auto-doc block, depending on the
50/// flavor and the lines after it; see `detect_autodoc_block_ranges()`.
51pub fn is_autodoc_marker(line: &str) -> bool {
52    let Some(identifier) = marker_identifier(line) else {
53        return false;
54    };
55    is_autodoc_identifier(identifier)
56}
57
58fn is_autodoc_identifier(identifier: &str) -> bool {
59    // The module path is the first token; the regex guarantees there is one
60    let module_path = identifier.split_whitespace().next().unwrap_or_default();
61
62    // Require at least one `.` or `:` separator to distinguish module paths
63    // (package.module.Class, handler:module) from Pandoc fenced div names
64    // (warning, note, danger)
65    if !module_path.contains(['.', ':']) {
66        return false;
67    }
68
69    // Reject malformed paths: a separator at either end, or two in a row
70    // (module..Class, handler::path)
71    if module_path.starts_with(['.', ':']) || module_path.ends_with(['.', ':']) {
72        return false;
73    }
74    !["..", "::", ".:", ":."].iter().any(|pair| module_path.contains(pair))
75}
76
77/// Whether `lines[idx]` opens an mkdocstrings auto-doc block under `flavor`
78///
79/// mkdocstrings accepts any identifier after `::: `, including a top-level
80/// package with no separator (`::: mypackage`). Outside MkDocs a single word
81/// also reads as a Pandoc fenced div (`::: warning`), so a marker is accepted
82/// when its path is self-evidently a module path, when the flavor is MkDocs
83/// (which has no fenced divs), or when the options block mkdocstrings reads
84/// follows it. The Pandoc flavors accept only the path form, since there a
85/// single word always names a div.
86fn opens_autodoc_block(lines: &[&str], idx: usize, flavor: MarkdownFlavor) -> bool {
87    let line = lines[idx];
88    let Some(identifier) = marker_identifier(line) else {
89        return false;
90    };
91    if is_autodoc_identifier(identifier) {
92        return true;
93    }
94    if flavor.is_pandoc_compatible() {
95        return false;
96    }
97    flavor == MarkdownFlavor::MkDocs || starts_options_block(&lines[idx + 1..], get_line_indent(line))
98}
99
100/// Whether `following` begins with the YAML block mkdocstrings reads as a
101/// marker's configuration: a `handler:` or `options:` key indented at least
102/// four columns past the marker, on the next line or after one blank line.
103fn starts_options_block(following: &[&str], marker_indent: usize) -> bool {
104    let mut rest = following.iter();
105    let first = match rest.next() {
106        Some(line) if line.trim().is_empty() => rest.next(),
107        other => other,
108    };
109    first.is_some_and(|line| {
110        let key = line.trim_start();
111        get_line_indent(line) >= marker_indent + 4 && OPTIONS_BLOCK_KEYS.iter().any(|k| key.starts_with(k))
112    })
113}
114
115/// Check if a line is part of autodoc options (YAML format)
116pub fn is_autodoc_options(line: &str, base_indent: usize) -> bool {
117    // Options must be indented at least 4 spaces more than the ::: marker
118    let line_indent = get_line_indent(line);
119
120    // Check if properly indented (at least 4 spaces from base)
121    if line_indent >= base_indent + 4 {
122        // Empty lines that are properly indented are considered part of options
123        if line.trim().is_empty() {
124            return true;
125        }
126
127        // YAML key-value pairs
128        if line.contains(':') {
129            return true;
130        }
131        // YAML list items
132        let trimmed = line.trim_start();
133        if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
134            return true;
135        }
136    }
137
138    false
139}
140
141/// Pre-compute all autodoc block ranges in the content for `flavor`
142/// Returns a sorted vector of byte ranges for efficient lookup
143pub fn detect_autodoc_block_ranges(content: &str, flavor: MarkdownFlavor) -> Vec<ByteRange> {
144    if !content.contains(":::") {
145        return Vec::new();
146    }
147    autodoc_block_ranges_in_lines(&content.lines().collect::<Vec<_>>(), flavor)
148}
149
150/// Read the line slices already held by a context instead of splitting again.
151pub(crate) fn autodoc_block_ranges_in_lines(lines: &[&str], flavor: MarkdownFlavor) -> Vec<ByteRange> {
152    let mut ranges = Vec::new();
153    let mut byte_pos = 0;
154    // Start of the open block, and the indentation of its marker
155    let mut open_block: Option<(usize, usize)> = None;
156
157    for (idx, line) in lines.iter().enumerate() {
158        let line_start = byte_pos;
159        // Account for the newline character
160        byte_pos += line.len() + 1;
161
162        if opens_autodoc_block(lines, idx, flavor) {
163            // A marker directly after another block closes that block first
164            if let Some((start, _)) = open_block {
165                ranges.push(ByteRange {
166                    start,
167                    end: line_start.saturating_sub(1),
168                });
169            }
170            open_block = Some((line_start, get_line_indent(line)));
171        } else if let Some((start, marker_indent)) = open_block {
172            // Option lines and blank lines, at any indentation, continue the
173            // block; any other line ends it before that line's newline
174            if !is_autodoc_options(line, marker_indent) && !line.trim().is_empty() {
175                ranges.push(ByteRange {
176                    start,
177                    end: line_start.saturating_sub(1),
178                });
179                open_block = None;
180            }
181        }
182    }
183
184    // If we ended while still in an autodoc block, save it
185    if let Some((start, _)) = open_block {
186        ranges.push(ByteRange {
187            start,
188            end: byte_pos.saturating_sub(1),
189        });
190    }
191
192    ranges
193}
194
195/// Check if a position is within any of the pre-computed autodoc block ranges
196pub fn is_within_autodoc_block_ranges(ranges: &[ByteRange], position: usize) -> bool {
197    crate::utils::skip_context::is_in_html_comment_ranges(ranges, position)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_autodoc_marker_detection() {
206        // Valid mkdocstrings autodoc markers (dotted or colon-separated paths)
207        assert!(is_autodoc_marker("::: mymodule.MyClass"));
208        assert!(is_autodoc_marker("::: package.module.Class"));
209        assert!(is_autodoc_marker("  ::: indented.Class"));
210        assert!(is_autodoc_marker("::: module:function"));
211        assert!(is_autodoc_marker("::: handler:package.module"));
212        assert!(is_autodoc_marker("::: a.b"));
213
214        // Not autodoc: wrong syntax
215        assert!(!is_autodoc_marker(":: Wrong number"));
216        assert!(!is_autodoc_marker("Regular text"));
217        assert!(!is_autodoc_marker(":::"));
218        assert!(!is_autodoc_marker(":::    "));
219
220        // Not autodoc: Pandoc fenced divs (plain words, no separator)
221        assert!(!is_autodoc_marker("::: warning"));
222        assert!(!is_autodoc_marker("::: note"));
223        assert!(!is_autodoc_marker("::: danger"));
224        assert!(!is_autodoc_marker("::: sidebar"));
225        assert!(!is_autodoc_marker("  ::: callout"));
226
227        // Not autodoc: Pandoc attribute syntax
228        assert!(!is_autodoc_marker("::: {.note}"));
229        assert!(!is_autodoc_marker("::: {#myid .warning}"));
230        assert!(!is_autodoc_marker("::: {.note .important}"));
231
232        // Not autodoc: malformed paths
233        assert!(!is_autodoc_marker("::: .starts.with.dot"));
234        assert!(!is_autodoc_marker("::: ends.with.dot."));
235        assert!(!is_autodoc_marker("::: has..consecutive.dots"));
236        assert!(!is_autodoc_marker("::: :starts.with.colon"));
237    }
238
239    /// 1-indexed lines of `content` that fall inside a detected auto-doc block
240    fn autodoc_lines(content: &str, flavor: MarkdownFlavor) -> Vec<usize> {
241        let ranges = detect_autodoc_block_ranges(content, flavor);
242        let mut offset = 0;
243        let mut lines = Vec::new();
244        for (idx, line) in content.lines().enumerate() {
245            if is_within_autodoc_block_ranges(&ranges, offset) {
246                lines.push(idx + 1);
247            }
248            offset += line.len() + 1;
249        }
250        lines
251    }
252
253    #[test]
254    fn test_single_word_marker_with_options_block() {
255        let content = "::: mypackage\n    options:\n      show_source: false\nText\n";
256        for flavor in [
257            MarkdownFlavor::Standard,
258            MarkdownFlavor::MkDocs,
259            MarkdownFlavor::Obsidian,
260        ] {
261            assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
262        }
263        // A single word after `:::` names a fenced div in the Pandoc flavors
264        for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
265            assert!(autodoc_lines(content, flavor).is_empty(), "{flavor:?}");
266        }
267    }
268
269    #[test]
270    fn test_options_block_after_one_blank_line() {
271        let content = "::: mypackage\n\n    handler: python\nText\n";
272        assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
273        // Two blank lines separate the options from the marker in mkdocstrings too
274        let content = "::: mypackage\n\n\n    handler: python\n";
275        assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
276    }
277
278    #[test]
279    fn test_whitespace_only_blank_line_keeps_block_open() {
280        // A blank line holding only spaces separates the options like an empty one
281        let content = "::: mypackage\n  \n    handler: python\nText\n";
282        assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3]);
283        let content = "::: pkg.mod\n  \n    options:\n      x: 1\nText\n";
284        assert_eq!(autodoc_lines(content, MarkdownFlavor::Standard), vec![1, 2, 3, 4]);
285    }
286
287    #[test]
288    fn test_pandoc_flavors_recognize_only_path_markers() {
289        // A dotted path is never a div class, so the Pandoc flavors keep it
290        let content = "::: module.Class\n    options:\n      x: 1\nText\n";
291        for flavor in [MarkdownFlavor::Pandoc, MarkdownFlavor::Quarto] {
292            assert_eq!(autodoc_lines(content, flavor), vec![1, 2, 3], "{flavor:?}");
293        }
294    }
295
296    #[test]
297    fn test_single_word_marker_needs_options_key_outside_mkdocs() {
298        // No options block: a plain Pandoc-style div name
299        assert!(autodoc_lines("::: warning\nText\n", MarkdownFlavor::Standard).is_empty());
300        // An indented key mkdocstrings does not read as configuration
301        let content = "::: sidebar\n    Important: note\n";
302        assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
303        // The key must be indented four columns past the marker
304        let content = "  ::: mypackage\n    options:\n";
305        assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
306        // Attribute syntax is never an identifier, even with an options key after it
307        let content = "::: {.note}\n    options:\n";
308        assert!(autodoc_lines(content, MarkdownFlavor::Standard).is_empty());
309        assert!(autodoc_lines(content, MarkdownFlavor::MkDocs).is_empty());
310    }
311
312    #[test]
313    fn test_mkdocs_flavor_accepts_any_identifier() {
314        assert_eq!(autodoc_lines("::: mypackage\nText\n", MarkdownFlavor::MkDocs), vec![1]);
315        let content = "::: handler: python\n    options:\n      show_source: false\n";
316        assert_eq!(autodoc_lines(content, MarkdownFlavor::MkDocs), vec![1, 2, 3]);
317    }
318
319    #[test]
320    fn test_consecutive_blocks_each_keep_their_range() {
321        let content = "::: pkg.a\n    options:\n      x: 1\n\n::: pkg.b\n    options:\n      x: 1\n::: pkg.c\nText\n";
322        let ranges = detect_autodoc_block_ranges(content, MarkdownFlavor::Standard);
323        assert_eq!(ranges.len(), 3, "one range per block: {ranges:?}");
324        // The blank line 4 holds only the newline that ends the first range
325        assert_eq!(
326            autodoc_lines(content, MarkdownFlavor::Standard),
327            vec![1, 2, 3, 5, 6, 7, 8]
328        );
329    }
330
331    #[test]
332    fn test_autodoc_options() {
333        assert!(is_autodoc_options("    handler: python", 0));
334        assert!(is_autodoc_options("    options:", 0));
335        assert!(is_autodoc_options("      show_source: true", 0));
336        assert!(!is_autodoc_options("", 0)); // Empty lines are neutral
337        assert!(!is_autodoc_options("Not indented", 0));
338        assert!(!is_autodoc_options("  Only 2 spaces", 0));
339        // Test YAML list items
340        assert!(is_autodoc_options("            - window", 0));
341        assert!(is_autodoc_options("            - app", 0));
342    }
343}