rumdl_lib/utils/
mkdocs_footnotes.rs

1use super::mkdocs_common::{BytePositionTracker, ContextStateMachine, MKDOCS_CONTENT_INDENT, get_line_indent};
2use regex::Regex;
3/// MkDocs Footnotes detection utilities
4///
5/// The Footnotes extension provides support for footnotes with references
6/// and definitions using `[^ref]` syntax.
7///
8/// Common patterns:
9/// - `[^1]` - Footnote reference
10/// - `[^note]` - Named footnote reference
11/// - `[^1]: Definition` - Footnote definition
12/// - Multi-line footnote definitions with 4-space indentation
13use std::sync::LazyLock;
14
15/// Pattern to match footnote references in text `[^1]` or `[^name]`
16static FOOTNOTE_REF: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\^[a-zA-Z0-9_-]+\]").unwrap());
17
18/// Pattern to match footnote definitions at start of line
19/// `[^1]: Definition text`
20/// Lenient: accepts empty definitions for real-world markdown
21static FOOTNOTE_DEF: LazyLock<Regex> = LazyLock::new(|| {
22    Regex::new(
23        r"^(\s*)\[\^([a-zA-Z0-9_-]+)\]:\s*", // \s* instead of \s+ to allow empty
24    )
25    .unwrap()
26});
27
28/// Check if a line contains a footnote definition
29pub fn is_footnote_definition(line: &str) -> bool {
30    FOOTNOTE_DEF.is_match(line)
31}
32
33/// Check if a line contains any footnote references
34pub fn contains_footnote_reference(line: &str) -> bool {
35    FOOTNOTE_REF.is_match(line)
36}
37
38/// Get the indentation level of a footnote definition
39pub fn get_footnote_indent(line: &str) -> Option<usize> {
40    if FOOTNOTE_DEF.is_match(line) {
41        // Use consistent indentation calculation (tabs = 4 spaces)
42        return Some(get_line_indent(line));
43    }
44    None
45}
46
47/// Check if a line is part of a multi-line footnote definition
48pub fn is_footnote_continuation(line: &str, base_indent: usize) -> bool {
49    // Empty lines within footnotes are allowed
50    if line.trim().is_empty() {
51        return true;
52    }
53
54    // Content must be indented at least MKDOCS_CONTENT_INDENT spaces from the footnote definition
55    get_line_indent(line) >= base_indent + MKDOCS_CONTENT_INDENT
56}
57
58/// Check if content at a byte position is within a footnote definition
59pub fn is_within_footnote_definition(content: &str, position: usize) -> bool {
60    let tracker = BytePositionTracker::new(content);
61    let mut state = ContextStateMachine::new();
62
63    for (_idx, line, start, end) in tracker.iter_with_positions() {
64        // Check if we're starting a footnote definition
65        if is_footnote_definition(line) {
66            let indent = get_footnote_indent(line).unwrap_or(0);
67            state.enter_context(indent, "footnote".to_string());
68        } else if state.is_in_context() {
69            // Check if we're still in the footnote
70            if !line.trim().is_empty() && !is_footnote_continuation(line, state.context_indent()) {
71                // Non-empty line that's not properly indented ends the footnote
72                state.exit_context();
73
74                // Check if this line starts a new footnote
75                if is_footnote_definition(line) {
76                    let indent = get_footnote_indent(line).unwrap_or(0);
77                    state.enter_context(indent, "footnote".to_string());
78                }
79            }
80        }
81
82        // Check if the position is within this line and we're in a footnote
83        if start <= position && position <= end && state.is_in_context() {
84            return true;
85        }
86    }
87
88    false
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_footnote_definition_detection() {
97        assert!(is_footnote_definition("[^1]: This is a footnote"));
98        assert!(is_footnote_definition("[^note]: Named footnote"));
99        assert!(is_footnote_definition("  [^2]: Indented footnote"));
100        assert!(!is_footnote_definition("[^1] Reference in text"));
101        assert!(!is_footnote_definition("Regular text"));
102    }
103
104    #[test]
105    fn test_footnote_reference_detection() {
106        assert!(contains_footnote_reference("Text with [^1] reference"));
107        assert!(contains_footnote_reference("Multiple [^1] and [^2] refs"));
108        assert!(contains_footnote_reference("[^named-ref]"));
109        assert!(!contains_footnote_reference("No references here"));
110    }
111
112    #[test]
113    fn test_footnote_continuation() {
114        assert!(is_footnote_continuation("    Continued content", 0));
115        assert!(is_footnote_continuation("        More indented", 0));
116        assert!(is_footnote_continuation("", 0)); // Empty lines allowed
117        assert!(!is_footnote_continuation("Not indented enough", 0));
118        assert!(!is_footnote_continuation("  Only 2 spaces", 0));
119    }
120
121    #[test]
122    fn test_within_footnote_definition() {
123        let content = r#"Regular text here.
124
125[^1]: This is a footnote definition
126    with multiple lines
127    of content.
128
129More regular text.
130
131[^2]: Another footnote
132    Also multi-line.
133
134End text."#;
135
136        let def_pos = content.find("footnote definition").unwrap();
137        let multi_pos = content.find("with multiple").unwrap();
138        let regular_pos = content.find("More regular").unwrap();
139        let end_pos = content.find("End text").unwrap();
140
141        assert!(is_within_footnote_definition(content, def_pos));
142        assert!(is_within_footnote_definition(content, multi_pos));
143        assert!(!is_within_footnote_definition(content, regular_pos));
144        assert!(!is_within_footnote_definition(content, end_pos));
145    }
146}