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