Skip to main content

rumdl_lib/utils/
mod.rs

1//!
2//! Shared utilities for rumdl, including document structure analysis, code block handling, regex helpers, and string extensions.
3//! Provides reusable traits and functions for rule implementations and core linter logic.
4
5pub mod anchor_styles;
6pub mod atomic_write;
7pub mod blockquote;
8pub mod code_block_utils;
9pub mod emphasis_utils;
10pub mod fix_utils;
11pub mod header_id_utils;
12pub mod html_block;
13pub mod jinja_utils;
14pub mod kramdown_utils;
15pub mod line_ending;
16pub mod mkdocs_admonitions;
17pub mod mkdocs_attr_list;
18pub mod mkdocs_common;
19pub mod mkdocs_config;
20pub mod mkdocs_critic;
21pub mod mkdocs_definition_lists;
22pub mod mkdocs_extensions;
23pub mod mkdocs_footnotes;
24pub mod mkdocs_html_markdown;
25pub mod mkdocs_icons;
26pub mod mkdocs_patterns;
27pub mod mkdocs_snippets;
28pub mod mkdocs_tabs;
29pub mod mkdocstrings_refs;
30pub mod obsidian_config;
31pub mod pandoc;
32pub mod parser_options;
33pub mod project_root;
34pub mod pymdown_blocks;
35pub mod quarto_chunks;
36pub mod range_utils;
37pub mod regex_cache;
38pub mod sentence_utils;
39pub mod skip_context;
40pub mod table_utils;
41pub mod text_reflow;
42pub mod thematic_break;
43pub mod upward_walk;
44pub mod utf8_offsets;
45
46pub use code_block_utils::CodeBlockUtils;
47pub use line_ending::{
48    LineEnding, detect_line_ending, detect_line_ending_enum, ensure_consistent_line_endings, get_line_ending_str,
49    normalize_line_ending,
50};
51pub use parser_options::rumdl_parser_options;
52pub use range_utils::LineIndex;
53
54/// Calculate the visual indentation width of a string, expanding tabs to spaces.
55///
56/// Per CommonMark, tabs expand to the next tab stop (columns 4, 8, 12, ...).
57pub fn calculate_indentation_width(indent_str: &str, tab_width: usize) -> usize {
58    let mut width = 0;
59    for ch in indent_str.chars() {
60        if ch == '\t' {
61            width = ((width / tab_width) + 1) * tab_width;
62        } else if ch == ' ' {
63            width += 1;
64        } else {
65            break;
66        }
67    }
68    width
69}
70
71/// Calculate the visual indentation width using default tab width of 4
72pub fn calculate_indentation_width_default(indent_str: &str) -> usize {
73    calculate_indentation_width(indent_str, 4)
74}
75
76/// Check if a line is a definition list item (Extended Markdown)
77///
78/// Definition lists use the pattern:
79/// ```text
80/// Term
81/// : Definition
82/// ```
83///
84/// Supported by: PHP Markdown Extra, Kramdown, Pandoc, Hugo, and others
85pub fn is_definition_list_item(line: &str) -> bool {
86    let trimmed = line.trim_start();
87    trimmed.starts_with(": ")
88        || (trimmed.starts_with(':') && trimmed.len() > 1 && trimmed.chars().nth(1).is_some_and(char::is_whitespace))
89}
90
91/// Check if a line consists only of a template directive with no surrounding text.
92///
93/// Detects template syntax used in static site generators:
94/// - Handlebars/mdBook/Mustache: `{{...}}`
95/// - Jinja2/Liquid/Jekyll: `{%...%}`
96/// - Hugo shortcodes: `{{<...>}}` or `{{%...%}}`
97///
98/// Template directives are preprocessor instructions that should not be merged
99/// into surrounding paragraphs during reflow.
100pub fn is_template_directive_only(line: &str) -> bool {
101    let trimmed = line.trim();
102    if trimmed.is_empty() {
103        return false;
104    }
105    (trimmed.starts_with("{{") && trimmed.ends_with("}}")) || (trimmed.starts_with("{%") && trimmed.ends_with("%}"))
106}
107
108/// Trait for string-related extensions
109pub trait StrExt {
110    /// Replace trailing spaces with a specified replacement string
111    fn replace_trailing_spaces(&self, replacement: &str) -> String;
112
113    /// Check if the string has trailing whitespace
114    fn has_trailing_spaces(&self) -> bool;
115
116    /// Count the number of trailing spaces in the string
117    fn trailing_spaces(&self) -> usize;
118}
119
120impl StrExt for str {
121    fn replace_trailing_spaces(&self, replacement: &str) -> String {
122        // Custom implementation to handle both newlines and tabs specially
123
124        // Check if string ends with newline
125        let (content, ends_with_newline) = if let Some(stripped) = self.strip_suffix('\n') {
126            (stripped, true)
127        } else {
128            (self, false)
129        };
130
131        // Find where the trailing spaces begin
132        let mut non_space_len = content.len();
133        for c in content.chars().rev() {
134            if c == ' ' {
135                non_space_len -= 1;
136            } else {
137                break;
138            }
139        }
140
141        // Build the final string
142        let mut result = String::with_capacity(non_space_len + replacement.len() + usize::from(ends_with_newline));
143        result.push_str(&content[..non_space_len]);
144        result.push_str(replacement);
145        if ends_with_newline {
146            result.push('\n');
147        }
148
149        result
150    }
151
152    fn has_trailing_spaces(&self) -> bool {
153        self.trailing_spaces() > 0
154    }
155
156    fn trailing_spaces(&self) -> usize {
157        // Custom implementation to handle both newlines and tabs specially
158
159        // Prepare the string without newline if it ends with one
160        let content = self.strip_suffix('\n').unwrap_or(self);
161
162        // Count only trailing spaces at the end, not tabs
163        let mut space_count = 0;
164        for c in content.chars().rev() {
165            if c == ' ' {
166                space_count += 1;
167            } else {
168                break;
169            }
170        }
171
172        space_count
173    }
174}
175
176use std::collections::hash_map::DefaultHasher;
177use std::hash::{Hash, Hasher};
178
179/// Fast hash function for string content
180///
181/// This utility function provides a quick way to generate a hash from string content
182/// for use in caching mechanisms. It uses Rust's built-in DefaultHasher.
183///
184/// # Arguments
185///
186/// * `content` - The string content to hash
187///
188/// # Returns
189///
190/// A 64-bit hash value derived from the content
191pub fn fast_hash(content: &str) -> u64 {
192    let mut hasher = DefaultHasher::new();
193    content.hash(&mut hasher);
194    hasher.finish()
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_detect_line_ending_pure_lf() {
203        // Test content with only LF line endings
204        let content = "First line\nSecond line\nThird line\n";
205        assert_eq!(detect_line_ending(content), "\n");
206    }
207
208    #[test]
209    fn test_detect_line_ending_pure_crlf() {
210        // Test content with only CRLF line endings
211        let content = "First line\r\nSecond line\r\nThird line\r\n";
212        assert_eq!(detect_line_ending(content), "\r\n");
213    }
214
215    #[test]
216    fn test_detect_line_ending_mixed_more_lf() {
217        // Test content with mixed line endings where LF is more common
218        let content = "First line\nSecond line\r\nThird line\nFourth line\n";
219        assert_eq!(detect_line_ending(content), "\n");
220    }
221
222    #[test]
223    fn test_detect_line_ending_mixed_more_crlf() {
224        // Test content with mixed line endings where CRLF is more common
225        let content = "First line\r\nSecond line\r\nThird line\nFourth line\r\n";
226        assert_eq!(detect_line_ending(content), "\r\n");
227    }
228
229    #[test]
230    fn test_detect_line_ending_empty_string() {
231        // Test empty string - should default to LF
232        let content = "";
233        assert_eq!(detect_line_ending(content), "\n");
234    }
235
236    #[test]
237    fn test_detect_line_ending_single_line_no_ending() {
238        // Test single line without any line endings - should default to LF
239        let content = "This is a single line with no line ending";
240        assert_eq!(detect_line_ending(content), "\n");
241    }
242
243    #[test]
244    fn test_detect_line_ending_equal_lf_and_crlf() {
245        // Test edge case with equal number of CRLF and LF
246        // Since LF count is calculated as total '\n' minus CRLF count,
247        // and the algorithm uses > (not >=), it should default to LF
248        let content = "Line 1\r\nLine 2\nLine 3\r\nLine 4\n";
249        assert_eq!(detect_line_ending(content), "\n");
250    }
251
252    #[test]
253    fn test_detect_line_ending_single_lf() {
254        // Test with just a single LF
255        let content = "Line 1\n";
256        assert_eq!(detect_line_ending(content), "\n");
257    }
258
259    #[test]
260    fn test_detect_line_ending_single_crlf() {
261        // Test with just a single CRLF
262        let content = "Line 1\r\n";
263        assert_eq!(detect_line_ending(content), "\r\n");
264    }
265
266    #[test]
267    fn test_detect_line_ending_embedded_cr() {
268        // Test with CR characters that are not part of CRLF
269        // These should not affect the count
270        let content = "Line 1\rLine 2\nLine 3\r\nLine 4\n";
271        // This has 1 CRLF and 2 LF (after subtracting the CRLF)
272        assert_eq!(detect_line_ending(content), "\n");
273    }
274}