rumdl_lib/utils/
mkdocs_footnotes.rs

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