Skip to main content

rumdl_lib/utils/
regex_cache.rs

1//!
2//! Cached Regex Patterns and Fast Content Checks for Markdown Linting
3//!
4//! This module provides a centralized collection of pre-compiled, cached regex patterns
5//! for all major Markdown constructs (headings, lists, code blocks, links, images, etc.).
6//! It also includes fast-path utility functions for quickly checking if content
7//! potentially contains certain Markdown elements, allowing rules to skip expensive
8//! processing when unnecessary.
9//!
10//! # Performance
11//!
12//! Fixed patterns use `LazyLock` and compile on first use. Dynamic patterns are
13//! cached by their pattern text so subsequent lookups reuse the compiled regex.
14//!
15//! # Usage
16//!
17//! - Use the provided statics for common Markdown patterns.
18//! - Use the `regex_lazy!` macro for ad-hoc regexes that are not predefined.
19//! - Use the utility functions for fast content checks before running regexes.
20
21use fancy_regex::Regex as FancyRegex;
22use regex::Regex;
23use std::collections::HashMap;
24use std::sync::LazyLock;
25use std::sync::{Arc, Mutex};
26
27/// Global regex cache for dynamic patterns
28#[derive(Debug)]
29pub struct RegexCache {
30    cache: HashMap<String, (Arc<Regex>, u64)>,
31}
32
33impl Default for RegexCache {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl RegexCache {
40    pub fn new() -> Self {
41        Self { cache: HashMap::new() }
42    }
43
44    /// Get or compile a regex pattern
45    pub fn get_regex(&mut self, pattern: &str) -> Result<Arc<Regex>, regex::Error> {
46        if let Some((regex, uses)) = self.cache.get_mut(pattern) {
47            *uses += 1;
48            return Ok(regex.clone());
49        }
50
51        let regex = Arc::new(Regex::new(pattern)?);
52        self.cache.insert(pattern.to_string(), (regex.clone(), 1));
53        Ok(regex)
54    }
55
56    /// Get cache statistics
57    pub fn get_stats(&self) -> HashMap<String, u64> {
58        self.cache
59            .iter()
60            .map(|(pattern, (_, uses))| (pattern.clone(), *uses))
61            .collect()
62    }
63
64    /// Clear cache (useful for testing)
65    pub fn clear(&mut self) {
66        self.cache.clear();
67    }
68}
69
70/// Global regex cache instance
71static GLOBAL_REGEX_CACHE: LazyLock<Mutex<RegexCache>> = LazyLock::new(|| Mutex::new(RegexCache::new()));
72
73/// Get a regex from the global cache
74///
75/// If the mutex is poisoned (another thread panicked while holding the lock),
76/// this function recovers by clearing the cache and continuing. This ensures
77/// the library never panics due to mutex poisoning.
78pub fn get_cached_regex(pattern: &str) -> Result<Arc<Regex>, regex::Error> {
79    let mut cache = GLOBAL_REGEX_CACHE.lock().unwrap_or_else(|poisoned| {
80        // Recover from poisoned mutex by clearing the cache
81        let mut guard = poisoned.into_inner();
82        guard.clear();
83        guard
84    });
85    cache.get_regex(pattern)
86}
87
88/// Get cache usage statistics
89///
90/// If the mutex is poisoned, returns an empty HashMap rather than panicking.
91pub fn get_cache_stats() -> HashMap<String, u64> {
92    match GLOBAL_REGEX_CACHE.lock() {
93        Ok(cache) => cache.get_stats(),
94        Err(_) => HashMap::new(),
95    }
96}
97
98/// Macro for defining a lazily-initialized, cached regex pattern.
99///
100/// Use this for ad-hoc regexes that are not already defined in this module.
101///
102/// # Panics
103///
104/// This macro will panic at initialization if the regex pattern is invalid.
105/// This is intentional for compile-time constant patterns - we want to catch
106/// invalid patterns during development, not at runtime.
107///
108/// # Example
109///
110/// ```
111/// use std::sync::LazyLock;
112/// use rumdl_lib::regex_lazy;
113/// let my_re = regex_lazy!(r"^foo.*bar$");
114/// assert!(my_re.is_match("foobar"));
115/// ```
116#[macro_export]
117macro_rules! regex_lazy {
118    ($pattern:expr) => {{
119        static REGEX: LazyLock<regex::Regex> = LazyLock::new(|| regex::Regex::new($pattern).unwrap());
120        &*REGEX
121    }};
122}
123
124/// Macro for getting regex from global cache.
125///
126/// # Panics
127///
128/// Panics if the regex pattern is invalid. This is acceptable for static patterns
129/// where we want to fail fast during development.
130#[macro_export]
131macro_rules! regex_cached {
132    ($pattern:expr) => {{ $crate::utils::regex_cache::get_cached_regex($pattern).expect("Failed to compile regex") }};
133}
134
135// Also make the macro available directly from this module
136pub use crate::regex_lazy;
137
138// =============================================================================
139// URL REGEX PATTERNS - Centralized URL Detection
140// =============================================================================
141//
142// ## Pattern Hierarchy (use the most specific pattern for your needs):
143//
144// | Pattern              | Use Case                                    | Parens | Trailing Punct |
145// |----------------------|---------------------------------------------|--------|----------------|
146// | URL_STANDARD_REGEX   | MD034 bare URL detection with auto-fix      | Yes    | Captured*      |
147// | URL_WWW_REGEX        | www.domain URLs without protocol            | Yes    | Captured*      |
148// | URL_IPV6_REGEX       | IPv6 URLs like https://[::1]/path           | Yes    | Captured*      |
149// | URL_QUICK_CHECK_REGEX| Fast early-exit check (contains URL?)       | N/A    | N/A            |
150// | URL_SIMPLE_REGEX     | Content detection, line length exemption    | No     | Excluded       |
151//
152// *Trailing punctuation is captured by the regex; use trim_trailing_punctuation() to clean.
153//
154// ## Design Principles:
155// 1. Parentheses in paths are allowed for Wikipedia-style URLs (Issue #240)
156// 2. Host portion excludes / so path is captured separately
157// 3. Unbalanced trailing parens are handled by trim_trailing_punctuation()
158// 4. All patterns exclude angle brackets <> to avoid matching autolinks
159//
160// ## URL Structure: protocol://host[:port][/path][?query][#fragment]
161
162/// Pattern for standard HTTP(S)/FTP(S) URLs with full path support.
163///
164/// Use this for bare URL detection where you need the complete URL including
165/// Wikipedia-style parentheses in paths. Trailing punctuation like `,;.!?` may
166/// be captured and should be trimmed by the caller.
167///
168/// # Examples
169/// - `https://example.com/path_(with_parens)?query#fragment`
170/// - `https://en.wikipedia.org/wiki/Rust_(programming_language)`
171pub const URL_STANDARD_STR: &str = concat!(
172    r#"(?:https?|ftps?|ftp)://"#, // Protocol
173    r#"(?:"#,
174    r#"\[[0-9a-fA-F:%.\-a-zA-Z]+\]"#, // IPv6 host OR
175    r#"|"#,
176    r#"[^\s<>\[\]()\\'\"`/]+"#, // Standard host (no parens, no /)
177    r#")"#,
178    r#"(?::\d+)?"#,                 // Optional port
179    r#"(?:/[^\s<>\[\]\\'\"`]*)?"#,  // Optional path (allows parens)
180    r#"(?:\?[^\s<>\[\]\\'\"`]*)?"#, // Optional query (allows parens)
181    r#"(?:#[^\s<>\[\]\\'\"`]*)?"#,  // Optional fragment (allows parens)
182);
183
184/// Pattern for www URLs without protocol.
185///
186/// Matches URLs starting with `www.` that lack a protocol prefix.
187/// These should be converted to proper URLs or flagged as bare URLs.
188/// Supports port, path, query string, and fragment like URL_STANDARD_STR.
189///
190/// # Examples
191/// - `www.example.com`
192/// - `www.example.com:8080`
193/// - `www.example.com/path`
194/// - `www.example.com?query=value`
195/// - `www.example.com#section`
196pub const URL_WWW_STR: &str = concat!(
197    r#"www\.(?:[a-zA-Z0-9][-a-zA-Z0-9]*\.)+[a-zA-Z]{2,}"#, // www.domain.tld
198    r#"(?::\d+)?"#,                                        // Optional port
199    r#"(?:/[^\s<>\[\]\\'\"`]*)?"#,                         // Optional path (allows parens)
200    r#"(?:\?[^\s<>\[\]\\'\"`]*)?"#,                        // Optional query (allows parens)
201    r#"(?:#[^\s<>\[\]\\'\"`]*)?"#,                         // Optional fragment (allows parens)
202);
203
204/// Pattern for IPv6 URLs specifically.
205///
206/// Matches URLs with IPv6 addresses in brackets, including zone identifiers.
207/// Examples: `https://[::1]/path`, `https://[fe80::1%eth0]:8080/`
208pub const URL_IPV6_STR: &str = concat!(
209    r#"(?:https?|ftps?|ftp)://"#,
210    r#"\[[0-9a-fA-F:%.\-a-zA-Z]+\]"#, // IPv6 host in brackets
211    r#"(?::\d+)?"#,                   // Optional port
212    r#"(?:/[^\s<>\[\]\\'\"`]*)?"#,    // Optional path
213    r#"(?:\?[^\s<>\[\]\\'\"`]*)?"#,   // Optional query
214    r#"(?:#[^\s<>\[\]\\'\"`]*)?"#,    // Optional fragment
215);
216
217/// Pattern for XMPP URIs per GFM extended autolinks specification.
218///
219/// XMPP URIs use the format `xmpp:user@domain/resource` (without `://`).
220/// Reference: <https://github.github.com/gfm/#autolinks-extension->
221///
222/// # Examples
223/// - `xmpp:foo@bar.baz`
224/// - `xmpp:foo@bar.baz/txt`
225pub const XMPP_URI_STR: &str = r#"xmpp:[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:/[^\s<>\[\]\\'\"`]*)?"#;
226
227/// Quick check pattern for early exits.
228///
229/// Use this for fast pre-filtering before running more expensive patterns.
230/// Matches if the text likely contains a URL or email address.
231/// Includes `xmpp:` for GFM extended autolinks.
232pub const URL_QUICK_CHECK_STR: &str = r#"(?:https?|ftps?|ftp|xmpp)://|xmpp:|@|www\."#;
233
234/// Simple URL pattern for content detection.
235///
236/// Less strict pattern that excludes trailing sentence punctuation (.,).
237/// Use for line length exemption checks or content characteristic detection
238/// where you just need to know if a URL exists, not extract it precisely.
239pub const URL_SIMPLE_STR: &str = r#"(?:https?|ftps?|ftp)://[^\s<>]+[^\s<>.,]"#;
240
241// Pre-compiled static patterns for performance
242
243/// Standard URL regex - primary pattern for bare URL detection (MD034).
244/// See [`URL_STANDARD_STR`] for documentation.
245pub static URL_STANDARD_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(URL_STANDARD_STR).unwrap());
246
247/// WWW URL regex - for URLs starting with www. without protocol.
248/// See [`URL_WWW_STR`] for documentation.
249pub static URL_WWW_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(URL_WWW_STR).unwrap());
250
251/// IPv6 URL regex - for URLs with IPv6 addresses.
252/// See [`URL_IPV6_STR`] for documentation.
253pub static URL_IPV6_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(URL_IPV6_STR).unwrap());
254
255/// Quick check regex - fast early-exit test.
256/// See [`URL_QUICK_CHECK_STR`] for documentation.
257pub static URL_QUICK_CHECK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(URL_QUICK_CHECK_STR).unwrap());
258
259/// Simple URL regex - for content detection and line length exemption.
260/// See [`URL_SIMPLE_STR`] for documentation.
261pub static URL_SIMPLE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(URL_SIMPLE_STR).unwrap());
262
263/// Alias for `URL_SIMPLE_REGEX`. Used by MD013 for line length exemption.
264pub static URL_PATTERN: LazyLock<Regex> = LazyLock::new(|| URL_SIMPLE_REGEX.clone());
265
266/// XMPP URI regex - for GFM extended autolinks.
267/// See [`XMPP_URI_STR`] for documentation.
268pub static XMPP_URI_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(XMPP_URI_STR).unwrap());
269
270// Heading patterns
271pub static ATX_HEADING_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(#{1,6})(\s+|$)").unwrap());
272
273// List patterns
274pub static UNORDERED_LIST_MARKER_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)([*+-])(\s+)").unwrap());
275pub static ORDERED_LIST_MARKER_REGEX: LazyLock<Regex> =
276    LazyLock::new(|| Regex::new(r"^(\s*)(\d+)([.)])(\s+)").unwrap());
277
278// Emphasis patterns
279
280// MD037 specific emphasis patterns - improved to avoid false positives
281// Only match emphasis with spaces that are actually complete emphasis blocks
282// Use word boundaries and negative lookbehind/lookahead to avoid matching across emphasis boundaries
283pub static ASTERISK_EMPHASIS: LazyLock<Regex> =
284    LazyLock::new(|| Regex::new(r"(?:^|[^*])\*(\s+[^*]+\s*|\s*[^*]+\s+)\*(?:[^*]|$)").unwrap());
285pub static UNDERSCORE_EMPHASIS: LazyLock<Regex> =
286    LazyLock::new(|| Regex::new(r"(?:^|[^_])_(\s+[^_]+\s*|\s*[^_]+\s+)_(?:[^_]|$)").unwrap());
287pub static DOUBLE_UNDERSCORE_EMPHASIS: LazyLock<Regex> =
288    LazyLock::new(|| Regex::new(r"(?:^|[^_])__(\s+[^_]+\s*|\s*[^_]+\s+)__(?:[^_]|$)").unwrap());
289// Code block patterns
290pub static FENCED_CODE_BLOCK_START: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)```(?:[^`\r\n]*)$").unwrap());
291pub static FENCED_CODE_BLOCK_END: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)```\s*$").unwrap());
292
293// HTML patterns
294pub static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<([a-zA-Z][^>]*)>").unwrap());
295pub static HTML_TAG_QUICK_CHECK: LazyLock<Regex> = LazyLock::new(|| Regex::new("(?i)</?[a-zA-Z]").unwrap());
296
297// Image patterns
298pub static IMAGE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
299
300// Blockquote patterns
301pub static BLOCKQUOTE_PREFIX_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*>+\s*)").unwrap());
302
303/// Check if a line is blank in the context of blockquotes.
304///
305/// A line is considered "blank" if:
306/// - It's empty or contains only whitespace
307/// - It's a blockquote continuation line with no content (e.g., ">", ">>", "> ")
308///
309/// This is essential for rules like MD058 (blanks-around-tables), MD065 (blanks-around-horizontal-rules),
310/// and any other rule that needs to detect blank lines that might be inside blockquotes.
311///
312/// # Examples
313/// ```
314/// use rumdl_lib::utils::regex_cache::is_blank_in_blockquote_context;
315///
316/// assert!(is_blank_in_blockquote_context(""));           // Empty line
317/// assert!(is_blank_in_blockquote_context("   "));        // Whitespace only
318/// assert!(is_blank_in_blockquote_context(">"));          // Blockquote continuation
319/// assert!(is_blank_in_blockquote_context("> "));         // Blockquote with trailing space
320/// assert!(is_blank_in_blockquote_context(">>"));         // Nested blockquote
321/// assert!(is_blank_in_blockquote_context("> > "));       // Spaced nested blockquote
322/// assert!(!is_blank_in_blockquote_context("> text"));    // Blockquote with content
323/// assert!(!is_blank_in_blockquote_context("text"));      // Regular text
324/// ```
325pub fn is_blank_in_blockquote_context(line: &str) -> bool {
326    // Peel one `\s*>+\s*` prefix at a time. A line is blank-in-blockquote iff,
327    // after removing every blockquote prefix, nothing but whitespace remains.
328    // This is iterative rather than recursive so a line of hundreds of thousands
329    // of spaced markers ("> > > ...") cannot overflow the stack: each prefix
330    // match consumes at least one `>`, so `rest` strictly shrinks every pass.
331    let mut rest = line;
332    loop {
333        if rest.trim().is_empty() {
334            return true;
335        }
336        match BLOCKQUOTE_PREFIX_RE.find(rest) {
337            // `>+` guarantees the match consumes at least one byte; the explicit
338            // `end > 0` guard keeps the loop total even if the pattern changes.
339            Some(m) if m.end() > 0 => rest = &rest[m.end()..],
340            _ => return false,
341        }
342    }
343}
344
345// MD013 specific patterns
346pub static IMAGE_REF_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^!\[.*?\]\[.*?\]$").unwrap());
347pub static LINK_REF_PATTERN: LazyLock<Regex> =
348    LazyLock::new(|| Regex::new(r#"^\[.*?\]:\s*\S+(\s+["'(].*)?\s*$"#).unwrap());
349pub static ABBREVIATION: LazyLock<Regex> = LazyLock::new(|| {
350    Regex::new(r"\b(?:Mr|Mrs|Ms|Dr|Prof|Sr|Jr|vs|etc|i\.e|e\.g|Inc|Corp|Ltd|Co|St|Ave|Blvd|Rd|Ph\.D|M\.D|B\.A|M\.A|Ph\.D|U\.S|U\.K|U\.N|N\.Y|L\.A|D\.C)\.\s+[A-Z]").unwrap()
351});
352pub static LIST_ITEM: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\d+\.\s+").unwrap());
353
354// Email pattern
355pub static EMAIL_PATTERN: LazyLock<Regex> =
356    LazyLock::new(|| Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap());
357
358// Third lazy_static block for link and image patterns used by MD052 and text_reflow
359// Reference link patterns (shared by MD052 and text_reflow)
360// Pattern to match reference links: [text][reference] or [text][]
361pub static REF_LINK_REGEX: LazyLock<FancyRegex> =
362    LazyLock::new(|| FancyRegex::new(r"(?<!\\)\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\[([^\]]*)\]").unwrap());
363
364// Pattern for shortcut reference links: [reference]
365// Must not be preceded by ] or ) (to avoid matching second part of [text][ref])
366// Must not be followed by [ or ( (to avoid matching first part of [text][ref] or [text](url))
367// The capturing group handles nested brackets to support cases like [`Union[T, None]`]
368pub static SHORTCUT_REF_REGEX: LazyLock<FancyRegex> =
369    LazyLock::new(|| FancyRegex::new(r"(?<![\\)\]])\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\](?!\s*[\[\(])").unwrap());
370
371// Inline link with fancy regex for better escaping handling (used by text_reflow)
372pub static INLINE_LINK_FANCY_REGEX: LazyLock<FancyRegex> =
373    LazyLock::new(|| FancyRegex::new(r"(?<!\\)\[([^\]]+)\]\(([^)]+)\)").unwrap());
374
375// Inline image (used by MD052 and text_reflow)
376pub static INLINE_IMAGE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap());
377
378// Linked images (clickable badges) - all 4 variants
379// Must be detected before inline_image and inline_link to treat as atomic units
380//
381// Limitation: Alt text containing brackets like [![[v1.0]](img)](link) is not supported.
382// The [^\]]* pattern cannot match nested brackets. This is rare in practice.
383//
384// Pattern 1: Inline image in inline link - [![alt](img-url)](link-url)
385pub static LINKED_IMAGE_INLINE_INLINE: LazyLock<Regex> =
386    LazyLock::new(|| Regex::new(r"\[!\[([^\]]*)\]\(([^)]+)\)\]\(([^)]+)\)").unwrap());
387
388// Pattern 2: Reference image in inline link - [![alt][img-ref]](link-url)
389pub static LINKED_IMAGE_REF_INLINE: LazyLock<Regex> =
390    LazyLock::new(|| Regex::new(r"\[!\[([^\]]*)\]\[([^\]]*)\]\]\(([^)]+)\)").unwrap());
391
392// Pattern 3: Inline image in reference link - [![alt](img-url)][link-ref]
393pub static LINKED_IMAGE_INLINE_REF: LazyLock<Regex> =
394    LazyLock::new(|| Regex::new(r"\[!\[([^\]]*)\]\(([^)]+)\)\]\[([^\]]*)\]").unwrap());
395
396// Pattern 4: Reference image in reference link - [![alt][img-ref]][link-ref]
397pub static LINKED_IMAGE_REF_REF: LazyLock<Regex> =
398    LazyLock::new(|| Regex::new(r"\[!\[([^\]]*)\]\[([^\]]*)\]\]\[([^\]]*)\]").unwrap());
399
400// Reference image: ![alt][ref] or ![alt][]
401pub static REF_IMAGE_REGEX: LazyLock<Regex> =
402    LazyLock::new(|| Regex::new(r"!\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\[([^\]]*)\]").unwrap());
403
404// Footnote reference: [^note]
405pub static FOOTNOTE_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\^([^\]]+)\]").unwrap());
406
407// Wiki-style links: [[wiki]] or [[wiki|display text]]
408pub static WIKI_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap());
409
410// Math formulas: $inline$ or $$display$$
411pub static INLINE_MATH_REGEX: LazyLock<FancyRegex> =
412    LazyLock::new(|| FancyRegex::new(r"(?<!\$)\$(?!\$)([^\$]+)\$(?!\$)").unwrap());
413pub static DISPLAY_MATH_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$\$([^\$]+)\$\$").unwrap());
414
415// Emoji shortcodes: :emoji:
416pub static EMOJI_SHORTCODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r":([a-zA-Z0-9_+-]+):").unwrap());
417
418// HTML tags (opening, closing, self-closing)
419pub static HTML_TAG_PATTERN: LazyLock<Regex> =
420    LazyLock::new(|| Regex::new(r"</?[a-zA-Z][^>]*>|<[a-zA-Z][^>]*/\s*>").unwrap());
421
422// HTML entities: named (&nbsp;), decimal (&#59;) and hexadecimal (&#x3B; or &#X3B;)
423pub static HTML_ENTITY_REGEX: LazyLock<Regex> =
424    LazyLock::new(|| Regex::new(r"&[a-zA-Z][a-zA-Z0-9]*;|&#\d+;|&#[xX][0-9a-fA-F]+;").unwrap());
425
426// Hugo/Go template shortcodes: {{< figure ... >}} and {{% shortcode %}}
427// Matches both delimiters: {{< ... >}} (shortcode) and {{% ... %}} (template)
428// Handles multi-line content with embedded quotes and newlines
429pub static HUGO_SHORTCODE_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{\{[<%][\s\S]*?[%>]\}\}").unwrap());
430
431// HTML heading pattern (matches <h1> through <h6> tags)
432// Uses FancyRegex because the pattern requires a backreference (\1)
433pub static HTML_HEADING_PATTERN: LazyLock<FancyRegex> =
434    LazyLock::new(|| FancyRegex::new(r"^\s*<h([1-6])(?:\s[^>]*)?>.*</h\1>\s*$").unwrap());
435
436/// Escapes a string to be used in a regex pattern
437pub fn escape_regex(s: &str) -> String {
438    let mut result = String::with_capacity(s.len() * 2);
439
440    for c in s.chars() {
441        // Use matches! for O(1) lookup instead of array.contains() which is O(n)
442        if matches!(
443            c,
444            '.' | '+' | '*' | '?' | '^' | '$' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '\\'
445        ) {
446            result.push('\\');
447        }
448        result.push(c);
449    }
450
451    result
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn test_regex_cache_new() {
460        let cache = RegexCache::new();
461        assert!(cache.cache.is_empty());
462        assert!(cache.get_stats().is_empty());
463    }
464
465    #[test]
466    fn test_regex_cache_default() {
467        let cache = RegexCache::default();
468        assert!(cache.cache.is_empty());
469        assert!(cache.get_stats().is_empty());
470    }
471
472    #[test]
473    fn test_get_regex_compilation() {
474        let mut cache = RegexCache::new();
475
476        // First call compiles and caches
477        let regex1 = cache.get_regex(r"\d+").unwrap();
478        assert_eq!(cache.cache.len(), 1);
479        assert_eq!(cache.get_stats().get(r"\d+"), Some(&1));
480
481        // Second call returns cached version
482        let regex2 = cache.get_regex(r"\d+").unwrap();
483        assert_eq!(cache.cache.len(), 1);
484        assert_eq!(cache.get_stats().get(r"\d+"), Some(&2));
485
486        // Both should be the same Arc
487        assert!(Arc::ptr_eq(&regex1, &regex2));
488    }
489
490    #[test]
491    fn test_get_regex_invalid_pattern() {
492        let mut cache = RegexCache::new();
493        let result = cache.get_regex(r"[unterminated");
494        assert!(result.is_err());
495        assert!(cache.cache.is_empty());
496    }
497
498    #[test]
499    fn test_get_stats() {
500        let mut cache = RegexCache::new();
501
502        // Use some patterns
503        let _ = cache.get_regex(r"\d+").unwrap();
504        let _ = cache.get_regex(r"\d+").unwrap();
505        let _ = cache.get_regex(r"\w+").unwrap();
506
507        let stats = cache.get_stats();
508        assert_eq!(stats.get(r"\d+"), Some(&2));
509        assert_eq!(stats.get(r"\w+"), Some(&1));
510    }
511
512    #[test]
513    fn test_clear_cache() {
514        let mut cache = RegexCache::new();
515
516        // Add some patterns
517        let _ = cache.get_regex(r"\d+").unwrap();
518
519        assert!(!cache.cache.is_empty());
520        assert!(!cache.get_stats().is_empty());
521
522        // Clear cache
523        cache.clear();
524
525        assert!(cache.cache.is_empty());
526        assert!(cache.get_stats().is_empty());
527    }
528
529    #[test]
530    fn test_global_cache_functions() {
531        // Test get_cached_regex
532        let regex1 = get_cached_regex(r"\d{3}").unwrap();
533        let regex2 = get_cached_regex(r"\d{3}").unwrap();
534        assert!(Arc::ptr_eq(&regex1, &regex2));
535
536        // Test stats
537        let stats = get_cache_stats();
538        assert!(stats.contains_key(r"\d{3}"));
539    }
540
541    #[test]
542    fn test_regex_lazy_macro() {
543        let re = regex_lazy!(r"^test.*end$");
544        assert!(re.is_match("test something end"));
545        assert!(!re.is_match("test something"));
546
547        // The macro creates a new static for each invocation location,
548        // so we can't test pointer equality across different invocations
549        // But we can test that the regex works correctly
550        let re2 = regex_lazy!(r"^start.*finish$");
551        assert!(re2.is_match("start and finish"));
552        assert!(!re2.is_match("start without end"));
553    }
554
555    #[test]
556    fn test_escape_regex() {
557        assert_eq!(escape_regex("a.b"), "a\\.b");
558        assert_eq!(escape_regex("a+b*c"), "a\\+b\\*c");
559        assert_eq!(escape_regex("(test)"), "\\(test\\)");
560        assert_eq!(escape_regex("[a-z]"), "\\[a-z\\]");
561        assert_eq!(escape_regex("normal text"), "normal text");
562
563        // Test all special characters
564        assert_eq!(escape_regex(".$^{[(|)*+?\\"), "\\.\\$\\^\\{\\[\\(\\|\\)\\*\\+\\?\\\\");
565
566        // Test empty string
567        assert_eq!(escape_regex(""), "");
568
569        // Test mixed content
570        assert_eq!(escape_regex("test.com/path?query=1"), "test\\.com/path\\?query=1");
571    }
572
573    #[test]
574    fn test_static_regex_patterns() {
575        // Test URL patterns
576        assert!(URL_SIMPLE_REGEX.is_match("https://example.com"));
577        assert!(URL_SIMPLE_REGEX.is_match("http://test.org/path"));
578        assert!(URL_SIMPLE_REGEX.is_match("ftp://files.com"));
579        assert!(!URL_SIMPLE_REGEX.is_match("not a url"));
580
581        // Test heading patterns
582        assert!(ATX_HEADING_REGEX.is_match("# Heading"));
583        assert!(ATX_HEADING_REGEX.is_match("  ## Indented"));
584        assert!(ATX_HEADING_REGEX.is_match("### "));
585        assert!(!ATX_HEADING_REGEX.is_match("Not a heading"));
586
587        // Test list patterns
588        assert!(UNORDERED_LIST_MARKER_REGEX.is_match("* Item"));
589        assert!(UNORDERED_LIST_MARKER_REGEX.is_match("- Item"));
590        assert!(UNORDERED_LIST_MARKER_REGEX.is_match("+ Item"));
591        assert!(ORDERED_LIST_MARKER_REGEX.is_match("1. Item"));
592        assert!(ORDERED_LIST_MARKER_REGEX.is_match("99. Item"));
593
594        // Test HTML patterns
595        assert!(HTML_TAG_REGEX.is_match("<div>"));
596        assert!(HTML_TAG_REGEX.is_match("<span class='test'>"));
597
598        // Test blockquote pattern
599        assert!(BLOCKQUOTE_PREFIX_RE.is_match("> Quote"));
600        assert!(BLOCKQUOTE_PREFIX_RE.is_match("  > Indented quote"));
601        assert!(BLOCKQUOTE_PREFIX_RE.is_match(">> Nested"));
602    }
603
604    #[test]
605    fn test_thread_safety() {
606        use std::thread;
607
608        let handles: Vec<_> = (0..10)
609            .map(|i| {
610                thread::spawn(move || {
611                    let pattern = format!(r"\d{{{i}}}");
612                    let regex = get_cached_regex(&pattern).unwrap();
613                    assert!(regex.is_match(&"1".repeat(i)));
614                })
615            })
616            .collect();
617
618        for handle in handles {
619            handle.join().unwrap();
620        }
621    }
622
623    // ==========================================================================
624    // Comprehensive URL Regex Tests
625    // ==========================================================================
626
627    #[test]
628    fn test_url_standard_basic() {
629        // Basic HTTP/HTTPS URLs
630        assert!(URL_STANDARD_REGEX.is_match("https://example.com"));
631        assert!(URL_STANDARD_REGEX.is_match("http://example.com"));
632        assert!(URL_STANDARD_REGEX.is_match("https://example.com/"));
633        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path"));
634        assert!(URL_STANDARD_REGEX.is_match("ftp://files.example.com"));
635        assert!(URL_STANDARD_REGEX.is_match("ftps://secure.example.com"));
636
637        // Should not match non-URLs
638        assert!(!URL_STANDARD_REGEX.is_match("not a url"));
639        assert!(!URL_STANDARD_REGEX.is_match("example.com"));
640        assert!(!URL_STANDARD_REGEX.is_match("www.example.com"));
641    }
642
643    #[test]
644    fn test_url_standard_with_path() {
645        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path/to/page"));
646        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path/to/page.html"));
647        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path/to/page/"));
648    }
649
650    #[test]
651    fn test_url_standard_with_query() {
652        assert!(URL_STANDARD_REGEX.is_match("https://example.com?query=value"));
653        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path?query=value"));
654        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path?a=1&b=2"));
655    }
656
657    #[test]
658    fn test_url_standard_with_fragment() {
659        assert!(URL_STANDARD_REGEX.is_match("https://example.com#section"));
660        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path#section"));
661        assert!(URL_STANDARD_REGEX.is_match("https://example.com/path?query=value#section"));
662    }
663
664    #[test]
665    fn test_url_standard_with_port() {
666        assert!(URL_STANDARD_REGEX.is_match("https://example.com:8080"));
667        assert!(URL_STANDARD_REGEX.is_match("https://example.com:443/path"));
668        assert!(URL_STANDARD_REGEX.is_match("http://localhost:3000"));
669        assert!(URL_STANDARD_REGEX.is_match("https://192.168.1.1:8080/path"));
670    }
671
672    #[test]
673    fn test_url_standard_wikipedia_style_parentheses() {
674        // Wikipedia-style URLs with parentheses in path (Issue #240)
675        let url = "https://en.wikipedia.org/wiki/Rust_(programming_language)";
676        assert!(URL_STANDARD_REGEX.is_match(url));
677
678        // Verify the full URL is captured
679        let cap = URL_STANDARD_REGEX.find(url).unwrap();
680        assert_eq!(cap.as_str(), url);
681
682        // Multiple parentheses pairs
683        let url2 = "https://example.com/path_(foo)_(bar)";
684        let cap2 = URL_STANDARD_REGEX.find(url2).unwrap();
685        assert_eq!(cap2.as_str(), url2);
686    }
687
688    #[test]
689    fn test_url_standard_ipv6() {
690        // IPv6 addresses in URLs
691        assert!(URL_STANDARD_REGEX.is_match("https://[::1]/path"));
692        assert!(URL_STANDARD_REGEX.is_match("https://[2001:db8::1]:8080/path"));
693        assert!(URL_STANDARD_REGEX.is_match("http://[fe80::1%eth0]/"));
694    }
695
696    #[test]
697    fn test_url_www_basic() {
698        // www URLs without protocol
699        assert!(URL_WWW_REGEX.is_match("www.example.com"));
700        assert!(URL_WWW_REGEX.is_match("www.example.co.uk"));
701        assert!(URL_WWW_REGEX.is_match("www.sub.example.com"));
702
703        // Should not match plain domains without www
704        assert!(!URL_WWW_REGEX.is_match("example.com"));
705
706        // Note: https://www.example.com DOES match because it contains "www."
707        // The URL_WWW_REGEX is designed to find www. URLs that lack a protocol
708        // Use URL_STANDARD_REGEX for full URLs with protocols
709        assert!(URL_WWW_REGEX.is_match("https://www.example.com"));
710    }
711
712    #[test]
713    fn test_url_www_with_path() {
714        assert!(URL_WWW_REGEX.is_match("www.example.com/path"));
715        assert!(URL_WWW_REGEX.is_match("www.example.com/path/to/page"));
716        assert!(URL_WWW_REGEX.is_match("www.example.com/path_(with_parens)"));
717    }
718
719    #[test]
720    fn test_url_ipv6_basic() {
721        // IPv6 specific patterns
722        assert!(URL_IPV6_REGEX.is_match("https://[::1]/"));
723        assert!(URL_IPV6_REGEX.is_match("http://[2001:db8::1]/path"));
724        assert!(URL_IPV6_REGEX.is_match("https://[fe80::1]:8080/path"));
725        assert!(URL_IPV6_REGEX.is_match("ftp://[::ffff:192.168.1.1]/file"));
726    }
727
728    #[test]
729    fn test_url_ipv6_with_zone_id() {
730        // IPv6 with zone identifiers
731        assert!(URL_IPV6_REGEX.is_match("https://[fe80::1%eth0]/path"));
732        assert!(URL_IPV6_REGEX.is_match("http://[fe80::1%25eth0]:8080/"));
733    }
734
735    #[test]
736    fn test_url_simple_detection() {
737        // Simple pattern for content characteristic detection
738        assert!(URL_SIMPLE_REGEX.is_match("https://example.com"));
739        assert!(URL_SIMPLE_REGEX.is_match("http://test.org/path"));
740        assert!(URL_SIMPLE_REGEX.is_match("ftp://files.com/file.zip"));
741        assert!(!URL_SIMPLE_REGEX.is_match("not a url"));
742    }
743
744    #[test]
745    fn test_url_quick_check() {
746        // Quick check pattern for early exits
747        assert!(URL_QUICK_CHECK_REGEX.is_match("https://example.com"));
748        assert!(URL_QUICK_CHECK_REGEX.is_match("http://example.com"));
749        assert!(URL_QUICK_CHECK_REGEX.is_match("ftp://files.com"));
750        assert!(URL_QUICK_CHECK_REGEX.is_match("www.example.com"));
751        assert!(URL_QUICK_CHECK_REGEX.is_match("user@example.com"));
752        assert!(!URL_QUICK_CHECK_REGEX.is_match("just plain text"));
753    }
754
755    #[test]
756    fn test_url_edge_cases() {
757        // URLs with special characters that should be excluded
758        let url = "https://example.com/path";
759        assert!(URL_STANDARD_REGEX.is_match(url));
760
761        // URL followed by punctuation - the regex captures trailing punctuation
762        // because trimming is done by `trim_trailing_punctuation()` in the rule
763        let text = "Check https://example.com, it's great!";
764        let cap = URL_STANDARD_REGEX.find(text).unwrap();
765        // The comma IS captured by the regex - rule-level trimming handles this
766        assert!(cap.as_str().ends_with(','));
767
768        // URL in angle brackets should still be found
769        let text2 = "See <https://example.com> for more";
770        assert!(URL_STANDARD_REGEX.is_match(text2));
771
772        // URL ending at angle bracket should stop at >
773        let cap2 = URL_STANDARD_REGEX.find(text2).unwrap();
774        assert!(!cap2.as_str().contains('>'));
775    }
776
777    #[test]
778    fn test_url_with_complex_paths() {
779        // Complex real-world URLs
780        let urls = [
781            "https://github.com/owner/repo/blob/main/src/file.rs#L123",
782            "https://docs.example.com/api/v2/endpoint?format=json&page=1",
783            "https://cdn.example.com/assets/images/logo.png?v=2023",
784            "https://search.example.com/results?q=test+query&filter=all",
785        ];
786
787        for url in urls {
788            assert!(URL_STANDARD_REGEX.is_match(url), "Should match: {url}");
789        }
790    }
791
792    #[test]
793    fn test_url_pattern_strings_are_valid() {
794        // Verify patterns compile into valid regexes by accessing them
795        assert!(URL_STANDARD_REGEX.is_match("https://example.com"));
796        assert!(URL_WWW_REGEX.is_match("www.example.com"));
797        assert!(URL_IPV6_REGEX.is_match("https://[::1]/"));
798        assert!(URL_QUICK_CHECK_REGEX.is_match("https://example.com"));
799        assert!(URL_SIMPLE_REGEX.is_match("https://example.com"));
800    }
801
802    // =========================================================================
803    // Tests for is_blank_in_blockquote_context
804    // This is a shared utility used by MD058, MD065, and other rules that need
805    // to detect blank lines inside blockquotes (Issue #305)
806    // =========================================================================
807
808    #[test]
809    fn test_is_blank_in_blockquote_context_regular_blanks() {
810        // Regular blank lines
811        assert!(is_blank_in_blockquote_context(""));
812        assert!(is_blank_in_blockquote_context("   "));
813        assert!(is_blank_in_blockquote_context("\t"));
814        assert!(is_blank_in_blockquote_context("  \t  "));
815    }
816
817    #[test]
818    fn test_is_blank_in_blockquote_context_blockquote_blanks() {
819        // Blockquote continuation lines with no content (should be treated as blank)
820        assert!(is_blank_in_blockquote_context(">"));
821        assert!(is_blank_in_blockquote_context("> "));
822        assert!(is_blank_in_blockquote_context(">  "));
823        assert!(is_blank_in_blockquote_context(">>"));
824        assert!(is_blank_in_blockquote_context(">> "));
825        assert!(is_blank_in_blockquote_context(">>>"));
826        assert!(is_blank_in_blockquote_context(">>> "));
827    }
828
829    #[test]
830    fn test_is_blank_in_blockquote_context_spaced_nested() {
831        // Spaced nested blockquotes ("> > " style)
832        assert!(is_blank_in_blockquote_context("> > "));
833        assert!(is_blank_in_blockquote_context("> > > "));
834        assert!(is_blank_in_blockquote_context(">  >  "));
835    }
836
837    #[test]
838    fn test_is_blank_in_blockquote_context_with_leading_space() {
839        // Blockquote with leading whitespace
840        assert!(is_blank_in_blockquote_context("  >"));
841        assert!(is_blank_in_blockquote_context("  > "));
842        assert!(is_blank_in_blockquote_context("  >>"));
843    }
844
845    #[test]
846    fn test_is_blank_in_blockquote_context_not_blank() {
847        // Lines with actual content (should NOT be treated as blank)
848        assert!(!is_blank_in_blockquote_context("text"));
849        assert!(!is_blank_in_blockquote_context("> text"));
850        assert!(!is_blank_in_blockquote_context(">> text"));
851        assert!(!is_blank_in_blockquote_context("> | table |"));
852        assert!(!is_blank_in_blockquote_context("| table |"));
853        assert!(!is_blank_in_blockquote_context("> # Heading"));
854        assert!(!is_blank_in_blockquote_context(">text")); // No space after > but has text
855    }
856
857    #[test]
858    fn test_is_blank_in_blockquote_context_edge_cases() {
859        // Edge cases
860        assert!(!is_blank_in_blockquote_context(">a")); // Content immediately after >
861        assert!(!is_blank_in_blockquote_context("> a")); // Single char content
862        assert!(is_blank_in_blockquote_context(">   ")); // Multiple spaces after >
863        assert!(!is_blank_in_blockquote_context(">  text")); // Multiple spaces before content
864    }
865
866    #[test]
867    fn test_is_blank_in_blockquote_context_deeply_nested_no_stack_overflow() {
868        // Adversarial input: a line built from hundreds of thousands of spaced
869        // blockquote markers. Each "> " is a separate prefix match, so a
870        // recursive implementation overflows the stack and aborts the process.
871        // The iterative implementation returns in bounded stack. The test
872        // completing at all is the assertion; the return value is also checked.
873        let blank_markers = "> ".repeat(500_000);
874        assert!(is_blank_in_blockquote_context(&blank_markers));
875
876        // Same depth, but with trailing content: must be recognized as non-blank
877        // without overflowing either.
878        let markers_with_content = format!("{}text", "> ".repeat(500_000));
879        assert!(!is_blank_in_blockquote_context(&markers_with_content));
880    }
881}