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