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