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