Skip to main content

rumdl_lib/rules/
md044_proper_names.rs

1use crate::utils::fast_hash;
2use crate::utils::regex_cache::{escape_regex, get_cached_regex};
3
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::byte_to_char_count;
6use std::collections::{HashMap, HashSet};
7use std::sync::{Arc, Mutex};
8
9mod md044_config;
10pub(super) use md044_config::MD044Config;
11
12type WarningPosition = (usize, usize, String); // (line, column, found_name)
13
14/// Rule MD044: Proper names should be capitalized
15///
16/// See [docs/md044.md](../../docs/md044.md) for full documentation, configuration, and examples.
17///
18/// This rule is triggered when proper names are not capitalized correctly in the document.
19/// For example, if you have defined "JavaScript" as a proper name, the rule will flag any
20/// occurrences of "javascript" or "Javascript" as violations.
21///
22/// ## Purpose
23///
24/// Ensuring consistent capitalization of proper names improves document quality and
25/// professionalism. This is especially important for technical documentation where
26/// product names, programming languages, and technologies often have specific
27/// capitalization conventions.
28///
29/// ## Configuration Options
30///
31/// The rule supports the following configuration options:
32///
33/// ```yaml
34/// MD044:
35///   names: []                # List of proper names to check for correct capitalization
36///   code-blocks: false       # Whether to check code blocks (default: false)
37/// ```
38///
39/// Example configuration:
40///
41/// ```yaml
42/// MD044:
43///   names: ["JavaScript", "Node.js", "TypeScript"]
44///   code-blocks: true
45/// ```
46///
47/// ## Performance Optimizations
48///
49/// This rule implements several performance optimizations:
50///
51/// 1. **Regex Caching**: Pre-compiles and caches regex patterns for each proper name
52/// 2. **Content Caching**: Caches results based on content hashing for repeated checks
53/// 3. **Efficient Text Processing**: Uses optimized algorithms to avoid redundant text processing
54/// 4. **Smart Code Block Detection**: Efficiently identifies and optionally excludes code blocks
55///
56/// ## Edge Cases Handled
57///
58/// - **Word Boundaries**: Only matches complete words, not substrings within other words
59/// - **Case Sensitivity**: Properly handles case-specific matching
60/// - **Code Blocks**: Optionally checks code blocks (controlled by code-blocks setting)
61/// - **Markdown Formatting**: Handles proper names within Markdown formatting elements
62///
63/// ## Fix Behavior
64///
65/// When fixing issues, this rule replaces incorrect capitalization with the correct form
66/// as defined in the configuration.
67///
68/// Check if a trimmed line is an inline config comment from a linting tool.
69/// Recognized tools: rumdl, markdownlint, Vale, and remark-lint.
70fn is_inline_config_comment(trimmed: &str) -> bool {
71    trimmed.starts_with("<!-- rumdl-")
72        || trimmed.starts_with("<!-- markdownlint-")
73        || trimmed.starts_with("<!-- vale off")
74        || trimmed.starts_with("<!-- vale on")
75        || (trimmed.starts_with("<!-- vale ") && trimmed.contains(" = "))
76        || trimmed.starts_with("<!-- vale style")
77        || trimmed.starts_with("<!-- lint disable ")
78        || trimmed.starts_with("<!-- lint enable ")
79        || trimmed.starts_with("<!-- lint ignore ")
80}
81
82#[derive(Clone)]
83pub struct MD044ProperNames {
84    config: MD044Config,
85    // Cache the combined regex pattern string
86    combined_pattern: Option<String>,
87    // Precomputed lowercase name variants for fast pre-checks
88    name_variants: Vec<String>,
89    // Memoizes name violations keyed by content hash. Deliberately behind an
90    // `Arc<Mutex<..>>` so it is SHARED across clones: rule instances are cloned
91    // per config group and recreated for inline-config overrides, and the same
92    // file's content is frequently re-checked (check then fix), so a shared
93    // cache avoids recomputing. `check()` stays observationally pure (same ctx
94    // in, same warnings out); the cache only affects how fast that answer is
95    // produced. The lock is held only for the map get/insert, never across the
96    // regex scan.
97    content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
98}
99
100impl MD044ProperNames {
101    pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
102        let config = MD044Config {
103            names,
104            code_blocks,
105            html_elements: true, // Default to checking HTML elements
106            html_comments: true, // Default to checking HTML comments
107        };
108        let combined_pattern = Self::create_combined_pattern(&config);
109        let name_variants = Self::build_name_variants(&config);
110        Self {
111            config,
112            combined_pattern,
113            name_variants,
114            content_cache: Arc::new(Mutex::new(HashMap::new())),
115        }
116    }
117
118    // Helper function for consistent ASCII normalization
119    fn ascii_normalize(s: &str) -> String {
120        s.replace(['é', 'è', 'ê', 'ë'], "e")
121            .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
122            .replace(['ï', 'î', 'í', 'ì'], "i")
123            .replace(['ü', 'ú', 'ù', 'û'], "u")
124            .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
125            .replace('ñ', "n")
126            .replace('ç', "c")
127    }
128
129    pub fn from_config_struct(config: MD044Config) -> Self {
130        let combined_pattern = Self::create_combined_pattern(&config);
131        let name_variants = Self::build_name_variants(&config);
132        Self {
133            config,
134            combined_pattern,
135            name_variants,
136            content_cache: Arc::new(Mutex::new(HashMap::new())),
137        }
138    }
139
140    // Create a combined regex pattern for all proper names
141    fn create_combined_pattern(config: &MD044Config) -> Option<String> {
142        if config.names.is_empty() {
143            return None;
144        }
145
146        // Create patterns for all names and their variations
147        let mut patterns: Vec<String> = config
148            .names
149            .iter()
150            .flat_map(|name| {
151                let mut variations = vec![];
152                let lower_name = name.to_lowercase();
153
154                // Add the lowercase version
155                variations.push(escape_regex(&lower_name));
156
157                // Add version without dots
158                let lower_name_no_dots = lower_name.replace('.', "");
159                if lower_name != lower_name_no_dots {
160                    variations.push(escape_regex(&lower_name_no_dots));
161                }
162
163                // Add ASCII-normalized versions for common accented characters
164                let ascii_normalized = Self::ascii_normalize(&lower_name);
165
166                if ascii_normalized != lower_name {
167                    variations.push(escape_regex(&ascii_normalized));
168
169                    // Also add version without dots
170                    let ascii_no_dots = ascii_normalized.replace('.', "");
171                    if ascii_normalized != ascii_no_dots {
172                        variations.push(escape_regex(&ascii_no_dots));
173                    }
174                }
175
176                variations
177            })
178            .collect();
179
180        // Sort patterns by length (longest first) to avoid shorter patterns matching within longer ones
181        patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
182
183        // Combine all patterns into a single regex with capture groups
184        // Don't use \b as it doesn't work with Unicode - we'll check boundaries manually
185        Some(format!(r"(?i)({})", patterns.join("|")))
186    }
187
188    fn build_name_variants(config: &MD044Config) -> Vec<String> {
189        let mut variants = HashSet::new();
190        for name in &config.names {
191            let lower_name = name.to_lowercase();
192            variants.insert(lower_name.clone());
193
194            let lower_no_dots = lower_name.replace('.', "");
195            if lower_name != lower_no_dots {
196                variants.insert(lower_no_dots);
197            }
198
199            let ascii_normalized = Self::ascii_normalize(&lower_name);
200            if ascii_normalized != lower_name {
201                variants.insert(ascii_normalized.clone());
202
203                let ascii_no_dots = ascii_normalized.replace('.', "");
204                if ascii_normalized != ascii_no_dots {
205                    variants.insert(ascii_no_dots);
206                }
207            }
208        }
209
210        variants.into_iter().collect()
211    }
212
213    // Find all name violations in the content and return positions.
214    // `content_lower` is the pre-computed lowercase version of `content` to avoid redundant allocations.
215    fn find_name_violations(
216        &self,
217        content: &str,
218        ctx: &crate::lint_context::LintContext,
219        content_lower: &str,
220    ) -> Vec<WarningPosition> {
221        // Early return: if no names configured or content is empty
222        if self.config.names.is_empty() || content.is_empty() || self.combined_pattern.is_none() {
223            return Vec::new();
224        }
225
226        // Early return: quick check if any of the configured names might be in content
227        let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
228
229        if !has_potential_matches {
230            return Vec::new();
231        }
232
233        // Check if we have cached results
234        let hash = fast_hash(content);
235        {
236            // Use a separate scope for borrowing to minimize lock time
237            if let Ok(cache) = self.content_cache.lock()
238                && let Some(cached) = cache.get(&hash)
239            {
240                return cached.clone();
241            }
242        }
243
244        let mut violations = Vec::new();
245
246        // Get the regex from global cache
247        let combined_regex = match &self.combined_pattern {
248            Some(pattern) => match get_cached_regex(pattern) {
249                Ok(regex) => regex,
250                Err(_) => return Vec::new(),
251            },
252            None => return Vec::new(),
253        };
254
255        // Use ctx.lines for better performance
256        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
257            let line_num = line_idx + 1;
258            let line = line_info.content(ctx.content);
259
260            // Skip code fence lines (```language or ~~~language)
261            let trimmed = line.trim_start();
262            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
263                continue;
264            }
265
266            // Skip if in code block (when code_blocks = false)
267            if !self.config.code_blocks && line_info.in_code_block {
268                continue;
269            }
270
271            // Skip if in HTML block (when html_elements = false)
272            if !self.config.html_elements && line_info.in_html_block {
273                continue;
274            }
275
276            // Skip HTML comments using pre-computed line flag
277            if !self.config.html_comments && line_info.in_html_comment {
278                continue;
279            }
280
281            // Skip JSX expressions and MDX comments (MDX flavor)
282            if line_info.in_jsx_expression || line_info.in_mdx_comment {
283                continue;
284            }
285
286            // Skip Obsidian comments (Obsidian flavor)
287            if line_info.in_obsidian_comment {
288                continue;
289            }
290
291            // For frontmatter lines, determine offset where checkable value content starts.
292            // YAML keys should not be checked against proper names - only values.
293            let fm_value_offset = if line_info.in_front_matter {
294                Self::frontmatter_value_offset(line)
295            } else {
296                0
297            };
298            if fm_value_offset == usize::MAX {
299                continue;
300            }
301
302            // Skip inline config comments (rumdl, markdownlint, Vale, remark-lint directives)
303            if is_inline_config_comment(trimmed) {
304                continue;
305            }
306
307            // Early return: skip lines that don't contain any potential matches
308            let line_lower = line.to_lowercase();
309            let has_line_matches = self.name_variants.iter().any(|name| line_lower.contains(name));
310
311            if !has_line_matches {
312                continue;
313            }
314
315            // Use the combined regex to find all matches in one pass
316            for cap in combined_regex.find_iter(line) {
317                let found_name = &line[cap.start()..cap.end()];
318
319                // Check word boundaries manually for Unicode support
320                let start_pos = cap.start();
321                let end_pos = cap.end();
322
323                // Skip matches in the key portion of frontmatter lines
324                if start_pos < fm_value_offset {
325                    continue;
326                }
327
328                // Skip matches inside HTML tag attributes (handles multi-line tags)
329                let byte_pos = line_info.byte_offset + start_pos;
330                if ctx.is_in_html_tag(byte_pos) {
331                    continue;
332                }
333
334                if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
335                {
336                    continue; // Not at word boundary
337                }
338
339                // Skip if in inline code when code_blocks is false
340                if !self.config.code_blocks {
341                    if ctx.is_in_code_block_or_span(byte_pos) {
342                        continue;
343                    }
344                    // pulldown-cmark doesn't parse markdown syntax inside HTML
345                    // comments, HTML blocks, or frontmatter, so backtick-wrapped
346                    // text isn't detected by is_in_code_block_or_span. Check directly.
347                    if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
348                        && Self::is_in_backtick_code_in_line(line, start_pos)
349                    {
350                        continue;
351                    }
352                }
353
354                // Skip if in link URL or reference definition
355                if Self::is_in_link(ctx, byte_pos) {
356                    continue;
357                }
358
359                // Skip if inside an angle-bracket URL (e.g., <https://...>)
360                // The link parser skips autolinks inside HTML comments,
361                // so we detect them directly in the line text.
362                if Self::is_in_angle_bracket_url(line, start_pos) {
363                    continue;
364                }
365
366                // Skip if inside a Markdown inline link URL in contexts where
367                // pulldown-cmark doesn't parse Markdown syntax (HTML comments,
368                // HTML blocks, frontmatter).
369                if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
370                    && Self::is_in_markdown_link_url(line, start_pos)
371                {
372                    continue;
373                }
374
375                // Skip if inside the URL portion of a WikiLink followed by a
376                // parenthesised destination — [[text]](url). pulldown-cmark
377                // registers [[text]] as a WikiLink in ctx.links but leaves the
378                // (url) as plain text, so is_in_link() misses those bytes.
379                if Self::is_in_wikilink_url(ctx, byte_pos) {
380                    continue;
381                }
382
383                // Find which proper name this matches
384                if let Some(proper_name) = self.get_proper_name_for(found_name) {
385                    // Only flag if it's not already correct
386                    if found_name != proper_name {
387                        violations.push((line_num, cap.start() + 1, found_name.to_string()));
388                    }
389                }
390            }
391        }
392
393        // Store in cache (ignore if mutex is poisoned)
394        if let Ok(mut cache) = self.content_cache.lock() {
395            cache.insert(hash, violations.clone());
396        }
397        violations
398    }
399
400    /// Check if a byte position is within a link URL (not link text)
401    ///
402    /// Link text should be checked for proper names, but URLs should be skipped.
403    /// For `[text](url)` - check text, skip url
404    /// For `[text][ref]` - check text, skip reference portion
405    /// For `[[text]]` (WikiLinks) - check text, skip brackets
406    fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
407        use pulldown_cmark::LinkType;
408
409        // Binary search links (sorted by byte_offset) to find candidate containing byte_pos
410        let link_idx = ctx.links.partition_point(|link| link.byte_offset <= byte_pos);
411        if link_idx > 0 {
412            let link = &ctx.links[link_idx - 1];
413            if byte_pos < link.byte_end {
414                // WikiLinks [[text]] start with '[[', regular links [text] start with '['
415                let text_start = if matches!(link.link_type, LinkType::WikiLink { .. }) {
416                    link.byte_offset + 2
417                } else {
418                    link.byte_offset + 1
419                };
420                let text_end = text_start + link.text.len();
421
422                // If position is within the text portion, skip only if text is a URL.
423                // WikiLinks use the page name as both text and url; never treat them
424                // as bare-domain URLs regardless of whether the name contains dots.
425                if byte_pos >= text_start && byte_pos < text_end {
426                    let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
427                    return Self::link_text_is_url(&link.text)
428                        || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url));
429                }
430                // Position is in the URL/reference portion, skip it
431                return true;
432            }
433        }
434
435        // Binary search images (sorted by byte_offset) to find candidate containing byte_pos
436        let image_idx = ctx.images.partition_point(|img| img.byte_offset <= byte_pos);
437        if image_idx > 0 {
438            let image = &ctx.images[image_idx - 1];
439            if byte_pos < image.byte_end {
440                // Image starts with '![' so alt text starts at byte_offset + 2
441                let alt_start = image.byte_offset + 2;
442                let alt_end = alt_start + image.alt_text.len();
443
444                // If position is within the alt text portion, don't skip
445                if byte_pos >= alt_start && byte_pos < alt_end {
446                    return false;
447                }
448                // Position is in the URL/reference portion, skip it
449                return true;
450            }
451        }
452
453        // Check pre-computed reference definitions
454        ctx.is_in_reference_def(byte_pos)
455    }
456
457    /// Check if link text is a URL that should not have proper name corrections.
458    fn link_text_is_url(text: &str) -> bool {
459        let lower = text.trim().to_ascii_lowercase();
460        lower.starts_with("http://")
461            || lower.starts_with("https://")
462            || lower.starts_with("www.")
463            || lower.starts_with("//")
464    }
465
466    /// Check if link text is the bare hostname/path of its destination URL.
467    ///
468    /// When the display text is the URL with the scheme stripped (e.g.,
469    /// `[example.github.io](https://example.github.io)`), the text is a domain
470    /// label, not a prose reference to a product, and should not be corrected.
471    ///
472    /// Requires the text to contain a dot, which distinguishes domain-like display
473    /// text from single-word WikiLink targets (e.g. `[[javascript]]`) where
474    /// `url == text` but neither is a domain name. Dotted WikiLink targets are
475    /// excluded separately via the `!is_wikilink` guard in `is_in_link`. Comparison
476    /// is case-insensitive because URL schemes and hostnames are case-insensitive.
477    fn link_text_matches_link_url(text: &str, url: &str) -> bool {
478        let text = text.trim();
479        // Only domain-like text (containing a dot) can be a bare hostname.
480        if !text.contains('.') {
481            return false;
482        }
483        let url_lower = url.to_ascii_lowercase();
484        let url_without_scheme = url_lower
485            .strip_prefix("https://")
486            .or_else(|| url_lower.strip_prefix("http://"))
487            .or_else(|| url_lower.strip_prefix("//"))
488            .unwrap_or(&url_lower);
489        let text_lower = text.to_ascii_lowercase();
490        // Exact match: text equals the URL with the scheme removed.
491        if url_without_scheme == text_lower.as_str() {
492            return true;
493        }
494        // Prefix match: text is the hostname portion and the URL has a path/query/fragment.
495        url_without_scheme.len() > text_lower.len()
496            && url_without_scheme.starts_with(text_lower.as_str())
497            && matches!(
498                url_without_scheme.as_bytes().get(text_lower.len()),
499                Some(b'/') | Some(b'?') | Some(b'#')
500            )
501    }
502
503    /// Check if a position within a line falls inside an angle-bracket URL (`<scheme://...>`).
504    ///
505    /// The link parser skips autolinks inside HTML comments, so `ctx.links` won't
506    /// contain them. This function detects angle-bracket URLs directly in the line
507    /// text, covering both HTML comments and regular text as a safety net.
508    fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
509        let bytes = line.as_bytes();
510        let len = bytes.len();
511        let mut i = 0;
512        while i < len {
513            if bytes[i] == b'<' {
514                let after_open = i + 1;
515                // Check for a valid URI scheme per CommonMark autolink spec:
516                // scheme = [a-zA-Z][a-zA-Z0-9+.-]{0,31}
517                // followed by ':'
518                if after_open < len && bytes[after_open].is_ascii_alphabetic() {
519                    let mut s = after_open + 1;
520                    let scheme_max = (after_open + 32).min(len);
521                    while s < scheme_max
522                        && (bytes[s].is_ascii_alphanumeric()
523                            || bytes[s] == b'+'
524                            || bytes[s] == b'-'
525                            || bytes[s] == b'.')
526                    {
527                        s += 1;
528                    }
529                    if s < len && bytes[s] == b':' {
530                        // Valid scheme found; scan for closing '>' with no spaces or '<'
531                        let mut j = s + 1;
532                        let mut found_close = false;
533                        while j < len {
534                            match bytes[j] {
535                                b'>' => {
536                                    found_close = true;
537                                    break;
538                                }
539                                b' ' | b'<' => break,
540                                _ => j += 1,
541                            }
542                        }
543                        if found_close && pos >= i && pos <= j {
544                            return true;
545                        }
546                        if found_close {
547                            i = j + 1;
548                            continue;
549                        }
550                    }
551                }
552            }
553            i += 1;
554        }
555        false
556    }
557
558    /// Check if `byte_pos` falls inside the URL of a `[[text]](url)` construct.
559    ///
560    /// pulldown-cmark with WikiLinks enabled parses `[[text]]` as a WikiLink and
561    /// records it in `ctx.links`, but the immediately following `(url)` is left as
562    /// plain text and is therefore absent from `ctx.links`. This function detects
563    /// that gap by looking for a WikiLink entry whose `byte_end` falls exactly on a
564    /// `(` in the raw content, then checking whether `byte_pos` lies inside the
565    /// matching parenthesised URL span.
566    ///
567    /// Unlike `is_in_markdown_link_url`, this function is anchored to real parser
568    /// output (`ctx.links`) and will not suppress violations in text that merely
569    /// looks like a link (e.g. `[foo](github x)` with a space in the URL).
570    fn is_in_wikilink_url(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
571        use pulldown_cmark::LinkType;
572        let content = ctx.content.as_bytes();
573
574        // ctx.links is sorted by byte_offset; only links that start at or before
575        // byte_pos can have a URL that encloses it.
576        let end = ctx.links.partition_point(|l| l.byte_offset <= byte_pos);
577
578        for link in &ctx.links[..end] {
579            if !matches!(link.link_type, LinkType::WikiLink { .. }) {
580                continue;
581            }
582            let wiki_end = link.byte_end;
583            // The WikiLink must end before byte_pos and be immediately followed by '('.
584            if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
585                continue;
586            }
587            // Scan to the matching ')' tracking nested parens and backslash escapes.
588            // Per CommonMark, an unquoted inline link destination cannot contain
589            // spaces, tabs, or newlines. If we encounter one, this is parenthesised
590            // prose rather than a URL, and pulldown-cmark will not parse it as a link.
591            let mut depth: u32 = 1;
592            let mut k = wiki_end + 1;
593            let mut valid_destination = true;
594            while k < content.len() && depth > 0 {
595                match content[k] {
596                    b'\\' => {
597                        k += 1; // skip escaped character
598                    }
599                    b'(' => depth += 1,
600                    b')' => depth -= 1,
601                    b' ' | b'\t' | b'\n' | b'\r' => {
602                        valid_destination = false;
603                        break;
604                    }
605                    _ => {}
606                }
607                k += 1;
608            }
609            // byte_pos is inside the URL if it falls between '(' and the matching ')'
610            // and the destination is valid (no unescaped whitespace).
611            if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
612                return true;
613            }
614        }
615        false
616    }
617
618    /// Check if a position within a line falls inside a Markdown link's
619    /// non-text portion (URL or reference label).
620    ///
621    /// Used as a text-level fallback for HTML comments, HTML blocks, and
622    /// frontmatter where pulldown-cmark skips link parsing entirely. Operates on
623    /// raw line bytes and therefore cannot distinguish real links from text that
624    /// merely resembles link syntax; do not call on regular markdown lines.
625    /// - `[text](url)` — returns true if `pos` is within `(...)`
626    /// - `[text][ref]` — returns true if `pos` is within the second `[...]`
627    fn is_in_markdown_link_url(line: &str, pos: usize) -> bool {
628        let bytes = line.as_bytes();
629        let len = bytes.len();
630        let mut i = 0;
631
632        while i < len {
633            // Look for unescaped '[' (handle double-escaped \\[ as unescaped)
634            if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
635                // Find matching ']' handling nested brackets
636                let mut depth: u32 = 1;
637                let mut j = i + 1;
638                while j < len && depth > 0 {
639                    match bytes[j] {
640                        b'\\' => {
641                            j += 1; // skip escaped char
642                        }
643                        b'[' => depth += 1,
644                        b']' => depth -= 1,
645                        _ => {}
646                    }
647                    j += 1;
648                }
649
650                // j is now one past the ']'
651                if depth == 0 && j < len {
652                    if bytes[j] == b'(' {
653                        // Inline link: [text](url)
654                        let url_start = j;
655                        let mut paren_depth: u32 = 1;
656                        let mut k = j + 1;
657                        while k < len && paren_depth > 0 {
658                            match bytes[k] {
659                                b'\\' => {
660                                    k += 1; // skip escaped char
661                                }
662                                b'(' => paren_depth += 1,
663                                b')' => paren_depth -= 1,
664                                _ => {}
665                            }
666                            k += 1;
667                        }
668
669                        if paren_depth == 0 {
670                            if pos > url_start && pos < k {
671                                return true;
672                            }
673                            i = k;
674                            continue;
675                        }
676                    } else if bytes[j] == b'[' {
677                        // Reference link: [text][ref]
678                        let ref_start = j;
679                        let mut ref_depth: u32 = 1;
680                        let mut k = j + 1;
681                        while k < len && ref_depth > 0 {
682                            match bytes[k] {
683                                b'\\' => {
684                                    k += 1;
685                                }
686                                b'[' => ref_depth += 1,
687                                b']' => ref_depth -= 1,
688                                _ => {}
689                            }
690                            k += 1;
691                        }
692
693                        if ref_depth == 0 {
694                            if pos > ref_start && pos < k {
695                                return true;
696                            }
697                            i = k;
698                            continue;
699                        }
700                    }
701                }
702            }
703            i += 1;
704        }
705        false
706    }
707
708    /// Check if a position within a line falls inside backtick-delimited code.
709    ///
710    /// pulldown-cmark does not parse markdown syntax inside HTML comments, so
711    /// `ctx.is_in_code_block_or_span` returns false for backtick-wrapped text
712    /// within comments. This function detects backtick code spans directly in
713    /// the line text following CommonMark rules: a code span starts with N
714    /// backticks and ends with exactly N backticks.
715    fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
716        let bytes = line.as_bytes();
717        let len = bytes.len();
718        let mut i = 0;
719        while i < len {
720            if bytes[i] == b'`' {
721                // Count the opening backtick sequence length
722                let open_start = i;
723                while i < len && bytes[i] == b'`' {
724                    i += 1;
725                }
726                let tick_len = i - open_start;
727
728                // Scan forward for a closing sequence of exactly tick_len backticks
729                while i < len {
730                    if bytes[i] == b'`' {
731                        let close_start = i;
732                        while i < len && bytes[i] == b'`' {
733                            i += 1;
734                        }
735                        if i - close_start == tick_len {
736                            // Matched pair found; the code span content is between
737                            // the end of the opening backticks and the start of the
738                            // closing backticks (exclusive of the backticks themselves).
739                            let content_start = open_start + tick_len;
740                            let content_end = close_start;
741                            if pos >= content_start && pos < content_end {
742                                return true;
743                            }
744                            // Continue scanning after this pair
745                            break;
746                        }
747                        // Not the right length; keep scanning
748                    } else {
749                        i += 1;
750                    }
751                }
752            } else {
753                i += 1;
754            }
755        }
756        false
757    }
758
759    // Check if a character is a word boundary (handles Unicode)
760    fn is_word_boundary_char(c: char) -> bool {
761        !c.is_alphanumeric()
762    }
763
764    // Check if position is at a word boundary using byte-level lookups.
765    fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
766        if is_start {
767            if pos == 0 {
768                return true;
769            }
770            match content[..pos].chars().next_back() {
771                None => true,
772                Some(c) => Self::is_word_boundary_char(c),
773            }
774        } else {
775            if pos >= content.len() {
776                return true;
777            }
778            match content[pos..].chars().next() {
779                None => true,
780                Some(c) => Self::is_word_boundary_char(c),
781            }
782        }
783    }
784
785    /// For a frontmatter line, return the byte offset where the checkable
786    /// value portion starts. Returns `usize::MAX` if the entire line should be
787    /// skipped (frontmatter delimiters, key-only lines, YAML comments, flow constructs).
788    fn frontmatter_value_offset(line: &str) -> usize {
789        let trimmed = line.trim();
790
791        // Skip frontmatter delimiters and empty lines
792        if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
793            return usize::MAX;
794        }
795
796        // Skip YAML comments
797        if trimmed.starts_with('#') {
798            return usize::MAX;
799        }
800
801        // YAML list item: "  - item" or "  - key: value"
802        let stripped = line.trim_start();
803        if let Some(after_dash) = stripped.strip_prefix("- ") {
804            let leading = line.len() - stripped.len();
805            // Check if the list item contains a mapping (e.g., "- key: value")
806            if let Some(result) = Self::kv_value_offset(line, after_dash, leading + 2) {
807                return result;
808            }
809            // Bare list item value (no colon) - check content after "- "
810            return leading + 2;
811        }
812        if stripped == "-" {
813            return usize::MAX;
814        }
815
816        // Key-value pair with colon separator (YAML): "key: value"
817        if let Some(result) = Self::kv_value_offset(line, stripped, line.len() - stripped.len()) {
818            return result;
819        }
820
821        // Key-value pair with equals separator (TOML): "key = value"
822        if let Some(eq_pos) = line.find('=') {
823            let after_eq = eq_pos + 1;
824            if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
825                let value_start = after_eq + 1;
826                let value_slice = &line[value_start..];
827                let value_trimmed = value_slice.trim();
828                if value_trimmed.is_empty() {
829                    return usize::MAX;
830                }
831                // For quoted values, skip the opening quote character
832                if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
833                    || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
834                {
835                    let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
836                    return value_start + quote_offset + 1;
837                }
838                return value_start;
839            }
840            // Equals with no space after or at end of line -> no value to check
841            return usize::MAX;
842        }
843
844        // No separator found - continuation line or bare value, check the whole line
845        0
846    }
847
848    /// Parse a key-value pair using colon separator within `content` that starts
849    /// at `base_offset` in the original line. Returns `Some(offset)` if a colon
850    /// separator is found, `None` if no colon is present.
851    fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
852        let colon_pos = content.find(':')?;
853        let abs_colon = base_offset + colon_pos;
854        let after_colon = abs_colon + 1;
855        if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
856            let value_start = after_colon + 1;
857            let value_slice = &line[value_start..];
858            let value_trimmed = value_slice.trim();
859            if value_trimmed.is_empty() {
860                return Some(usize::MAX);
861            }
862            // Skip flow mappings and flow sequences - too complex for heuristic parsing
863            if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
864                return Some(usize::MAX);
865            }
866            // For quoted values, skip the opening quote character
867            if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
868                || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
869            {
870                let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
871                return Some(value_start + quote_offset + 1);
872            }
873            return Some(value_start);
874        }
875        // Colon with no space after or at end of line -> no value to check
876        Some(usize::MAX)
877    }
878
879    // Get the proper name that should be used for a found name
880    fn get_proper_name_for(&self, found_name: &str) -> Option<String> {
881        let found_lower = found_name.to_lowercase();
882
883        // Iterate through the configured proper names
884        for name in &self.config.names {
885            let lower_name = name.to_lowercase();
886            let lower_name_no_dots = lower_name.replace('.', "");
887
888            // Direct match
889            if found_lower == lower_name || found_lower == lower_name_no_dots {
890                return Some(name.clone());
891            }
892
893            // Check ASCII-normalized version
894            let ascii_normalized = Self::ascii_normalize(&lower_name);
895
896            let ascii_no_dots = ascii_normalized.replace('.', "");
897
898            if found_lower == ascii_normalized || found_lower == ascii_no_dots {
899                return Some(name.clone());
900            }
901        }
902        None
903    }
904}
905
906impl Rule for MD044ProperNames {
907    fn name(&self) -> &'static str {
908        "MD044"
909    }
910
911    fn description(&self) -> &'static str {
912        "Proper names should have the correct capitalization"
913    }
914
915    fn category(&self) -> RuleCategory {
916        RuleCategory::Other
917    }
918
919    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
920        if self.config.names.is_empty() {
921            return true;
922        }
923        // Quick check if any configured name variants exist (case-insensitive)
924        let content_lower = if ctx.content.is_ascii() {
925            ctx.content.to_ascii_lowercase()
926        } else {
927            ctx.content.to_lowercase()
928        };
929        !self.name_variants.iter().any(|name| content_lower.contains(name))
930    }
931
932    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
933        let content = ctx.content;
934        if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
935            return Ok(Vec::new());
936        }
937
938        // Compute lowercase content once and reuse across all checks
939        let content_lower = if content.is_ascii() {
940            content.to_ascii_lowercase()
941        } else {
942            content.to_lowercase()
943        };
944
945        // Early return: use pre-computed name_variants for the quick check
946        let has_potential_matches = self.name_variants.iter().any(|name| content_lower.contains(name));
947
948        if !has_potential_matches {
949            return Ok(Vec::new());
950        }
951
952        let line_index = &ctx.line_index;
953        let violations = self.find_name_violations(content, ctx, &content_lower);
954
955        let warnings = violations
956            .into_iter()
957            .filter_map(|(line, column, found_name)| {
958                self.get_proper_name_for(&found_name).map(|proper_name| {
959                    // `column` is a 1-indexed byte offset into the line (from regex .start() + 1).
960                    // Build the Fix range directly in bytes to avoid the character-based
961                    // line_col_to_byte_range_with_length function, which would misinterpret
962                    // the byte offset as a character count on lines with multi-byte content.
963                    let line_start = line_index.get_line_start_byte(line).unwrap_or(0);
964                    let byte_start = line_start + (column - 1);
965                    let byte_end = byte_start + found_name.len();
966                    // The displayed columns are character offsets; convert from the byte
967                    // offset within the line so they are correct on multi-byte lines.
968                    let line_text = ctx.line_info(line).map_or("", |li| li.content(ctx.content));
969                    let char_col = byte_to_char_count(line_text, column - 1);
970                    LintWarning {
971                        rule_name: Some(self.name().to_string()),
972                        line,
973                        column: char_col,
974                        end_line: line,
975                        end_column: char_col + found_name.chars().count(),
976                        message: format!("Proper name '{found_name}' should be '{proper_name}'"),
977                        severity: Severity::Warning,
978                        fix: Some(Fix::new(byte_start..byte_end, proper_name)),
979                    }
980                })
981            })
982            .collect();
983
984        Ok(warnings)
985    }
986
987    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
988        if self.should_skip(ctx) {
989            return Ok(ctx.content.to_string());
990        }
991        let warnings = self.check(ctx)?;
992        if warnings.is_empty() {
993            return Ok(ctx.content.to_string());
994        }
995        let warnings =
996            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
997        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
998            .map_err(crate::rule::LintError::InvalidInput)
999    }
1000
1001    fn as_any(&self) -> &dyn std::any::Any {
1002        self
1003    }
1004
1005    crate::impl_rule_config_methods!(MD044Config);
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011    use crate::lint_context::LintContext;
1012
1013    fn create_context(content: &str) -> LintContext<'_> {
1014        LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1015    }
1016
1017    #[test]
1018    fn test_correctly_capitalized_names() {
1019        let rule = MD044ProperNames::new(
1020            vec![
1021                "JavaScript".to_string(),
1022                "TypeScript".to_string(),
1023                "Node.js".to_string(),
1024            ],
1025            true,
1026        );
1027
1028        let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1029        let ctx = create_context(content);
1030        let result = rule.check(&ctx).unwrap();
1031        assert!(result.is_empty(), "Should not flag correctly capitalized names");
1032    }
1033
1034    #[test]
1035    fn test_incorrectly_capitalized_names() {
1036        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1037
1038        let content = "This document uses javascript and typescript incorrectly.";
1039        let ctx = create_context(content);
1040        let result = rule.check(&ctx).unwrap();
1041
1042        assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1043        assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1044        assert_eq!(result[0].line, 1);
1045        assert_eq!(result[0].column, 20);
1046        assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1047        assert_eq!(result[1].line, 1);
1048        assert_eq!(result[1].column, 35);
1049    }
1050
1051    #[test]
1052    fn test_names_at_beginning_of_sentences() {
1053        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1054
1055        let content = "javascript is a great language. python is also popular.";
1056        let ctx = create_context(content);
1057        let result = rule.check(&ctx).unwrap();
1058
1059        assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1060        assert_eq!(result[0].line, 1);
1061        assert_eq!(result[0].column, 1);
1062        assert_eq!(result[1].line, 1);
1063        assert_eq!(result[1].column, 33);
1064    }
1065
1066    #[test]
1067    fn test_names_in_code_blocks_checked_by_default() {
1068        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1069
1070        let content = r#"Here is some text with JavaScript.
1071
1072```javascript
1073// This javascript should be checked
1074const lang = "javascript";
1075```
1076
1077But this javascript should be flagged."#;
1078
1079        let ctx = create_context(content);
1080        let result = rule.check(&ctx).unwrap();
1081
1082        assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1083        assert_eq!(result[0].line, 4);
1084        assert_eq!(result[1].line, 5);
1085        assert_eq!(result[2].line, 8);
1086    }
1087
1088    #[test]
1089    fn test_names_in_code_blocks_ignored_when_disabled() {
1090        let rule = MD044ProperNames::new(
1091            vec!["JavaScript".to_string()],
1092            false, // code_blocks = false means skip code blocks
1093        );
1094
1095        let content = r#"```
1096javascript in code block
1097```"#;
1098
1099        let ctx = create_context(content);
1100        let result = rule.check(&ctx).unwrap();
1101
1102        assert_eq!(
1103            result.len(),
1104            0,
1105            "Should not flag javascript in code blocks when code_blocks is false"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_names_in_inline_code_checked_by_default() {
1111        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1112
1113        let content = "This is `javascript` in inline code and javascript outside.";
1114        let ctx = create_context(content);
1115        let result = rule.check(&ctx).unwrap();
1116
1117        // When code_blocks=true, inline code should be checked
1118        assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1119        assert_eq!(result[0].column, 10); // javascript in inline code
1120        assert_eq!(result[1].column, 41); // javascript outside
1121    }
1122
1123    #[test]
1124    fn test_multiple_names_in_same_line() {
1125        let rule = MD044ProperNames::new(
1126            vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1127            true,
1128        );
1129
1130        let content = "I use javascript, typescript, and react in my projects.";
1131        let ctx = create_context(content);
1132        let result = rule.check(&ctx).unwrap();
1133
1134        assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1135        assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1136        assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1137        assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1138    }
1139
1140    #[test]
1141    fn test_case_sensitivity() {
1142        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1143
1144        let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1145        let ctx = create_context(content);
1146        let result = rule.check(&ctx).unwrap();
1147
1148        assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1149        // JavaScript (correct) should not be flagged
1150        assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1151    }
1152
1153    #[test]
1154    fn test_configuration_with_custom_name_list() {
1155        let config = MD044Config {
1156            names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1157            code_blocks: true,
1158            html_elements: true,
1159            html_comments: true,
1160        };
1161        let rule = MD044ProperNames::from_config_struct(config);
1162
1163        let content = "We use github, gitlab, and devops for our workflow.";
1164        let ctx = create_context(content);
1165        let result = rule.check(&ctx).unwrap();
1166
1167        assert_eq!(result.len(), 3, "Should flag all custom names");
1168        assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1169        assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1170        assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1171    }
1172
1173    #[test]
1174    fn test_empty_configuration() {
1175        let rule = MD044ProperNames::new(vec![], true);
1176
1177        let content = "This has javascript and typescript but no configured names.";
1178        let ctx = create_context(content);
1179        let result = rule.check(&ctx).unwrap();
1180
1181        assert!(result.is_empty(), "Should not flag anything with empty configuration");
1182    }
1183
1184    #[test]
1185    fn test_names_with_special_characters() {
1186        let rule = MD044ProperNames::new(
1187            vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1188            true,
1189        );
1190
1191        let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1192        let ctx = create_context(content);
1193        let result = rule.check(&ctx).unwrap();
1194
1195        // nodejs should match Node.js (dotless variation)
1196        // asp.net should be flagged (wrong case)
1197        // ASP.NET should not be flagged (correct)
1198        // c++ should be flagged
1199        assert_eq!(result.len(), 3, "Should handle special characters correctly");
1200
1201        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1202        assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1203        assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1204        assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1205    }
1206
1207    #[test]
1208    fn test_word_boundaries() {
1209        let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1210
1211        let content = "JavaScript is not java or script, but Java and Script are separate.";
1212        let ctx = create_context(content);
1213        let result = rule.check(&ctx).unwrap();
1214
1215        // Should only flag lowercase "java" and "script" as separate words
1216        assert_eq!(result.len(), 2, "Should respect word boundaries");
1217        assert!(result.iter().any(|w| w.column == 19)); // "java" position
1218        assert!(result.iter().any(|w| w.column == 27)); // "script" position
1219    }
1220
1221    #[test]
1222    fn test_fix_method() {
1223        let rule = MD044ProperNames::new(
1224            vec![
1225                "JavaScript".to_string(),
1226                "TypeScript".to_string(),
1227                "Node.js".to_string(),
1228            ],
1229            true,
1230        );
1231
1232        let content = "I love javascript, typescript, and nodejs!";
1233        let ctx = create_context(content);
1234        let fixed = rule.fix(&ctx).unwrap();
1235
1236        assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1237    }
1238
1239    #[test]
1240    fn test_fix_multiple_occurrences() {
1241        let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1242
1243        let content = "python is great. I use python daily. PYTHON is powerful.";
1244        let ctx = create_context(content);
1245        let fixed = rule.fix(&ctx).unwrap();
1246
1247        assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1248    }
1249
1250    #[test]
1251    fn test_fix_checks_code_blocks_by_default() {
1252        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1253
1254        let content = r#"I love javascript.
1255
1256```
1257const lang = "javascript";
1258```
1259
1260More javascript here."#;
1261
1262        let ctx = create_context(content);
1263        let fixed = rule.fix(&ctx).unwrap();
1264
1265        let expected = r#"I love JavaScript.
1266
1267```
1268const lang = "JavaScript";
1269```
1270
1271More JavaScript here."#;
1272
1273        assert_eq!(fixed, expected);
1274    }
1275
1276    #[test]
1277    fn test_multiline_content() {
1278        let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1279
1280        let content = r#"First line with rust.
1281Second line with python.
1282Third line with RUST and PYTHON."#;
1283
1284        let ctx = create_context(content);
1285        let result = rule.check(&ctx).unwrap();
1286
1287        assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1288        assert_eq!(result[0].line, 1);
1289        assert_eq!(result[1].line, 2);
1290        assert_eq!(result[2].line, 3);
1291        assert_eq!(result[3].line, 3);
1292    }
1293
1294    #[test]
1295    fn test_default_config() {
1296        let config = MD044Config::default();
1297        assert!(config.names.is_empty());
1298        assert!(!config.code_blocks);
1299        assert!(config.html_elements);
1300        assert!(config.html_comments);
1301    }
1302
1303    #[test]
1304    fn test_default_config_checks_html_comments() {
1305        let config = MD044Config {
1306            names: vec!["JavaScript".to_string()],
1307            ..MD044Config::default()
1308        };
1309        let rule = MD044ProperNames::from_config_struct(config);
1310
1311        let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1312        let ctx = create_context(content);
1313        let result = rule.check(&ctx).unwrap();
1314
1315        assert_eq!(result.len(), 1, "Default config should check HTML comments");
1316        assert_eq!(result[0].line, 3);
1317    }
1318
1319    #[test]
1320    fn test_default_config_skips_code_blocks() {
1321        let config = MD044Config {
1322            names: vec!["JavaScript".to_string()],
1323            ..MD044Config::default()
1324        };
1325        let rule = MD044ProperNames::from_config_struct(config);
1326
1327        let content = "# Guide\n\n```\njavascript in code\n```\n";
1328        let ctx = create_context(content);
1329        let result = rule.check(&ctx).unwrap();
1330
1331        assert_eq!(result.len(), 0, "Default config should skip code blocks");
1332    }
1333
1334    #[test]
1335    fn test_standalone_html_comment_checked() {
1336        let config = MD044Config {
1337            names: vec!["Test".to_string()],
1338            ..MD044Config::default()
1339        };
1340        let rule = MD044ProperNames::from_config_struct(config);
1341
1342        let content = "# Heading\n\n<!-- this is a test example -->\n";
1343        let ctx = create_context(content);
1344        let result = rule.check(&ctx).unwrap();
1345
1346        assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1347        assert_eq!(result[0].line, 3);
1348    }
1349
1350    #[test]
1351    fn test_inline_config_comments_not_flagged() {
1352        let config = MD044Config {
1353            names: vec!["RUMDL".to_string()],
1354            ..MD044Config::default()
1355        };
1356        let rule = MD044ProperNames::from_config_struct(config);
1357
1358        // Lines 1, 3, 4, 6 are inline config comments — should not be flagged.
1359        // Lines 2, 5 contain "rumdl" in regular text — flagged by rule.check(),
1360        // but would be suppressed by the linting engine's inline config filtering.
1361        let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1362        let ctx = create_context(content);
1363        let result = rule.check(&ctx).unwrap();
1364
1365        assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1366        assert_eq!(result[0].line, 2);
1367        assert_eq!(result[1].line, 5);
1368    }
1369
1370    #[test]
1371    fn test_html_comment_skipped_when_disabled() {
1372        let config = MD044Config {
1373            names: vec!["Test".to_string()],
1374            code_blocks: true,
1375            html_elements: true,
1376            html_comments: false,
1377        };
1378        let rule = MD044ProperNames::from_config_struct(config);
1379
1380        let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1381        let ctx = create_context(content);
1382        let result = rule.check(&ctx).unwrap();
1383
1384        assert_eq!(
1385            result.len(),
1386            1,
1387            "Should only flag 'test' outside HTML comment when html_comments=false"
1388        );
1389        assert_eq!(result[0].line, 5);
1390    }
1391
1392    #[test]
1393    fn test_fix_corrects_html_comment_content() {
1394        let config = MD044Config {
1395            names: vec!["JavaScript".to_string()],
1396            ..MD044Config::default()
1397        };
1398        let rule = MD044ProperNames::from_config_struct(config);
1399
1400        let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1401        let ctx = create_context(content);
1402        let fixed = rule.fix(&ctx).unwrap();
1403
1404        assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1405    }
1406
1407    #[test]
1408    fn test_fix_does_not_modify_inline_config_comments() {
1409        let config = MD044Config {
1410            names: vec!["RUMDL".to_string()],
1411            ..MD044Config::default()
1412        };
1413        let rule = MD044ProperNames::from_config_struct(config);
1414
1415        let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1416        let ctx = create_context(content);
1417        let fixed = rule.fix(&ctx).unwrap();
1418
1419        // Config comments should be untouched
1420        assert!(fixed.contains("<!-- rumdl-disable -->"));
1421        assert!(fixed.contains("<!-- rumdl-enable -->"));
1422        // Body text inside disable block should NOT be fixed (rule is disabled)
1423        assert!(
1424            fixed.contains("Some rumdl text."),
1425            "Line inside rumdl-disable block should not be modified by fix()"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_fix_respects_inline_disable_partial() {
1431        let config = MD044Config {
1432            names: vec!["RUMDL".to_string()],
1433            ..MD044Config::default()
1434        };
1435        let rule = MD044ProperNames::from_config_struct(config);
1436
1437        let content =
1438            "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1439        let ctx = create_context(content);
1440        let fixed = rule.fix(&ctx).unwrap();
1441
1442        // Line inside disable block should be preserved
1443        assert!(
1444            fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
1445            "Line inside disable block should not be modified"
1446        );
1447        // Line outside disable block should be fixed
1448        assert!(
1449            fixed.contains("Some RUMDL text outside."),
1450            "Line outside disable block should be fixed"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_performance_with_many_names() {
1456        let mut names = vec![];
1457        for i in 0..50 {
1458            names.push(format!("ProperName{i}"));
1459        }
1460
1461        let rule = MD044ProperNames::new(names, true);
1462
1463        let content = "This has propername0, propername25, and propername49 incorrectly.";
1464        let ctx = create_context(content);
1465        let result = rule.check(&ctx).unwrap();
1466
1467        assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
1468    }
1469
1470    #[test]
1471    fn test_large_name_count_performance() {
1472        // Verify MD044 can handle large numbers of names without regex limitations
1473        // This test confirms that fancy-regex handles large patterns well
1474        let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
1475
1476        let rule = MD044ProperNames::new(names, true);
1477
1478        // The combined pattern should be created successfully
1479        assert!(rule.combined_pattern.is_some());
1480
1481        // Should be able to check content without errors
1482        let content = "This has propername0 and propername999 in it.";
1483        let ctx = create_context(content);
1484        let result = rule.check(&ctx).unwrap();
1485
1486        // Should detect both incorrect names
1487        assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
1488    }
1489
1490    #[test]
1491    fn test_cache_behavior() {
1492        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1493
1494        let content = "Using javascript here.";
1495        let ctx = create_context(content);
1496
1497        // First check
1498        let result1 = rule.check(&ctx).unwrap();
1499        assert_eq!(result1.len(), 1);
1500
1501        // Second check should use cache
1502        let result2 = rule.check(&ctx).unwrap();
1503        assert_eq!(result2.len(), 1);
1504
1505        // Results should be identical
1506        assert_eq!(result1[0].line, result2[0].line);
1507        assert_eq!(result1[0].column, result2[0].column);
1508    }
1509
1510    #[test]
1511    fn test_html_comments_not_checked_when_disabled() {
1512        let config = MD044Config {
1513            names: vec!["JavaScript".to_string()],
1514            code_blocks: true,    // Check code blocks
1515            html_elements: true,  // Check HTML elements
1516            html_comments: false, // Don't check HTML comments
1517        };
1518        let rule = MD044ProperNames::from_config_struct(config);
1519
1520        let content = r#"Regular javascript here.
1521<!-- This javascript in HTML comment should be ignored -->
1522More javascript outside."#;
1523
1524        let ctx = create_context(content);
1525        let result = rule.check(&ctx).unwrap();
1526
1527        assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
1528        assert_eq!(result[0].line, 1);
1529        assert_eq!(result[1].line, 3);
1530    }
1531
1532    #[test]
1533    fn test_html_comments_checked_when_enabled() {
1534        let config = MD044Config {
1535            names: vec!["JavaScript".to_string()],
1536            code_blocks: true,   // Check code blocks
1537            html_elements: true, // Check HTML elements
1538            html_comments: true, // Check HTML comments
1539        };
1540        let rule = MD044ProperNames::from_config_struct(config);
1541
1542        let content = r#"Regular javascript here.
1543<!-- This javascript in HTML comment should be checked -->
1544More javascript outside."#;
1545
1546        let ctx = create_context(content);
1547        let result = rule.check(&ctx).unwrap();
1548
1549        assert_eq!(
1550            result.len(),
1551            3,
1552            "Should flag all javascript occurrences including in HTML comments"
1553        );
1554    }
1555
1556    #[test]
1557    fn test_multiline_html_comments() {
1558        let config = MD044Config {
1559            names: vec!["Python".to_string(), "JavaScript".to_string()],
1560            code_blocks: true,    // Check code blocks
1561            html_elements: true,  // Check HTML elements
1562            html_comments: false, // Don't check HTML comments
1563        };
1564        let rule = MD044ProperNames::from_config_struct(config);
1565
1566        let content = r#"Regular python here.
1567<!--
1568This is a multiline comment
1569with javascript and python
1570that should be ignored
1571-->
1572More javascript outside."#;
1573
1574        let ctx = create_context(content);
1575        let result = rule.check(&ctx).unwrap();
1576
1577        assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
1578        assert_eq!(result[0].line, 1); // python
1579        assert_eq!(result[1].line, 7); // javascript
1580    }
1581
1582    #[test]
1583    fn test_fix_preserves_html_comments_when_disabled() {
1584        let config = MD044Config {
1585            names: vec!["JavaScript".to_string()],
1586            code_blocks: true,    // Check code blocks
1587            html_elements: true,  // Check HTML elements
1588            html_comments: false, // Don't check HTML comments
1589        };
1590        let rule = MD044ProperNames::from_config_struct(config);
1591
1592        let content = r#"javascript here.
1593<!-- javascript in comment -->
1594More javascript."#;
1595
1596        let ctx = create_context(content);
1597        let fixed = rule.fix(&ctx).unwrap();
1598
1599        let expected = r#"JavaScript here.
1600<!-- javascript in comment -->
1601More JavaScript."#;
1602
1603        assert_eq!(
1604            fixed, expected,
1605            "Should not fix names inside HTML comments when disabled"
1606        );
1607    }
1608
1609    #[test]
1610    fn test_proper_names_in_link_text_are_flagged() {
1611        let rule = MD044ProperNames::new(
1612            vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
1613            true,
1614        );
1615
1616        let content = r#"Check this [javascript documentation](https://javascript.info) for info.
1617
1618Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
1619
1620Real javascript should be flagged.
1621
1622Also see the [typescript guide][ts-ref] for more.
1623
1624Real python should be flagged too.
1625
1626[ts-ref]: https://typescript.org/handbook"#;
1627
1628        let ctx = create_context(content);
1629        let result = rule.check(&ctx).unwrap();
1630
1631        // Link text should be checked, URLs should not be checked
1632        // Line 1: [javascript documentation] - "javascript" should be flagged
1633        // Line 3: [node.js homepage] - "node.js" should be flagged (matches "Node.js")
1634        // Line 3: [python tutorial] - "python" should be flagged
1635        // Line 5: standalone javascript
1636        // Line 9: standalone python
1637        assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
1638
1639        // Verify line numbers for link text warnings
1640        let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
1641        assert_eq!(line_1_warnings.len(), 1);
1642        assert!(
1643            line_1_warnings[0]
1644                .message
1645                .contains("'javascript' should be 'JavaScript'")
1646        );
1647
1648        let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1649        assert_eq!(line_3_warnings.len(), 2); // node.js and python
1650
1651        // Standalone warnings
1652        assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
1653        assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
1654    }
1655
1656    #[test]
1657    fn test_link_urls_not_flagged() {
1658        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1659
1660        // URL contains "javascript" but should NOT be flagged
1661        let content = r#"[Link Text](https://javascript.info/guide)"#;
1662
1663        let ctx = create_context(content);
1664        let result = rule.check(&ctx).unwrap();
1665
1666        // URL should not be checked
1667        assert!(result.is_empty(), "URLs should not be checked for proper names");
1668    }
1669
1670    #[test]
1671    fn test_proper_names_in_image_alt_text_are_flagged() {
1672        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1673
1674        let content = r#"Here is a ![javascript logo](javascript.png "javascript icon") image.
1675
1676Real javascript should be flagged."#;
1677
1678        let ctx = create_context(content);
1679        let result = rule.check(&ctx).unwrap();
1680
1681        // Image alt text should be checked, URL and title should not be checked
1682        // Line 1: ![javascript logo] - "javascript" should be flagged
1683        // Line 3: standalone javascript
1684        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
1685        assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
1686        assert!(result[0].line == 1); // "![javascript logo]"
1687        assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
1688        assert!(result[1].line == 3); // "Real javascript should be flagged."
1689    }
1690
1691    #[test]
1692    fn test_image_urls_not_flagged() {
1693        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1694
1695        // URL contains "javascript" but should NOT be flagged
1696        let content = r#"![Logo](https://javascript.info/logo.png)"#;
1697
1698        let ctx = create_context(content);
1699        let result = rule.check(&ctx).unwrap();
1700
1701        // Image URL should not be checked
1702        assert!(result.is_empty(), "Image URLs should not be checked for proper names");
1703    }
1704
1705    #[test]
1706    fn test_reference_link_text_flagged_but_definition_not() {
1707        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1708
1709        let content = r#"Check the [javascript guide][js-ref] for details.
1710
1711Real javascript should be flagged.
1712
1713[js-ref]: https://javascript.info/typescript/guide"#;
1714
1715        let ctx = create_context(content);
1716        let result = rule.check(&ctx).unwrap();
1717
1718        // Link text should be checked, reference definitions should not
1719        // Line 1: [javascript guide] - should be flagged
1720        // Line 3: standalone javascript - should be flagged
1721        // Line 5: reference definition - should NOT be flagged
1722        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
1723        assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
1724        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1725    }
1726
1727    #[test]
1728    fn test_reference_definitions_not_flagged() {
1729        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1730
1731        // Reference definition should NOT be flagged
1732        let content = r#"[js-ref]: https://javascript.info/guide"#;
1733
1734        let ctx = create_context(content);
1735        let result = rule.check(&ctx).unwrap();
1736
1737        // Reference definition URLs should not be checked
1738        assert!(result.is_empty(), "Reference definitions should not be checked");
1739    }
1740
1741    #[test]
1742    fn test_wikilinks_text_is_flagged() {
1743        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1744
1745        // WikiLinks [[destination]] should have their text checked
1746        let content = r#"[[javascript]]
1747
1748Regular javascript here.
1749
1750[[JavaScript|display text]]"#;
1751
1752        let ctx = create_context(content);
1753        let result = rule.check(&ctx).unwrap();
1754
1755        // Line 1: [[javascript]] - should be flagged (WikiLink text)
1756        // Line 3: standalone javascript - should be flagged
1757        // Line 5: [[JavaScript|display text]] - correct capitalization, no flag
1758        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
1759        assert!(
1760            result
1761                .iter()
1762                .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
1763        );
1764        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
1765    }
1766
1767    #[test]
1768    fn test_url_link_text_not_flagged() {
1769        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1770
1771        // Link text that is itself a URL should not be flagged
1772        let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1773
1774[http://github.com/org/repo](http://github.com/org/repo)
1775
1776[www.github.com/org/repo](https://www.github.com/org/repo)"#;
1777
1778        let ctx = create_context(content);
1779        let result = rule.check(&ctx).unwrap();
1780
1781        assert!(
1782            result.is_empty(),
1783            "URL-like link text should not be flagged, got: {result:?}"
1784        );
1785    }
1786
1787    #[test]
1788    fn test_url_link_text_with_leading_space_not_flagged() {
1789        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1790
1791        // Leading/trailing whitespace in link text should be trimmed before URL check
1792        let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
1793
1794        let ctx = create_context(content);
1795        let result = rule.check(&ctx).unwrap();
1796
1797        assert!(
1798            result.is_empty(),
1799            "URL-like link text with leading space should not be flagged, got: {result:?}"
1800        );
1801    }
1802
1803    #[test]
1804    fn test_url_link_text_uppercase_scheme_not_flagged() {
1805        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1806
1807        let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
1808
1809        let ctx = create_context(content);
1810        let result = rule.check(&ctx).unwrap();
1811
1812        assert!(
1813            result.is_empty(),
1814            "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
1815        );
1816    }
1817
1818    #[test]
1819    fn test_non_url_link_text_still_flagged() {
1820        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1821
1822        // Only prose descriptions in link text should be flagged.
1823        // Bare-domain, protocol-relative, and scheme-prefixed link texts that
1824        // match the destination URL are all URLs and must not be corrected.
1825        let content = r#"[github.com/org/repo](https://github.com/org/repo)
1826
1827[Visit github](https://github.com/org/repo)
1828
1829[//github.com/org/repo](//github.com/org/repo)
1830
1831[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
1832
1833        let ctx = create_context(content);
1834        let result = rule.check(&ctx).unwrap();
1835
1836        // Line 1: bare-domain text matches destination — not flagged
1837        // Line 3: prose description — flagged
1838        // Line 5: protocol-relative URL text — not flagged
1839        // Line 7: ftp:// URL text matches destination — not flagged
1840        assert_eq!(
1841            result.len(),
1842            1,
1843            "Only prose link text should be flagged, got: {result:?}"
1844        );
1845        assert!(
1846            result.iter().any(|w| w.line == 3),
1847            "Expected 'Visit github' on line 3 to be flagged"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_url_link_text_fix_not_applied() {
1853        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1854
1855        let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
1856
1857        let ctx = create_context(content);
1858        let result = rule.fix(&ctx).unwrap();
1859
1860        assert_eq!(result, content, "Fix should not modify URL-like link text");
1861    }
1862
1863    #[test]
1864    fn test_mixed_url_and_regular_link_text() {
1865        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
1866
1867        // Mix of URL link text (should skip) and regular text (should flag)
1868        let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
1869
1870Visit [github documentation](https://github.com/docs) for details.
1871
1872[www.github.com/pricing](https://www.github.com/pricing)"#;
1873
1874        let ctx = create_context(content);
1875        let result = rule.check(&ctx).unwrap();
1876
1877        // Only line 3 should be flagged ("github documentation" is not a URL)
1878        assert_eq!(
1879            result.len(),
1880            1,
1881            "Only non-URL link text should be flagged, got: {result:?}"
1882        );
1883        assert_eq!(result[0].line, 3);
1884    }
1885
1886    #[test]
1887    fn test_html_attribute_values_not_flagged() {
1888        // Matches inside HTML tag attributes (between `<` and `>`) are not flagged.
1889        // Attribute values are not prose — they hold URLs, class names, data values, etc.
1890        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1891        let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
1892        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893        let result = rule.check(&ctx).unwrap();
1894
1895        // Nothing on line 5 should be flagged — everything is inside the `<img ...>` tag
1896        let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
1897        assert!(
1898            line5_violations.is_empty(),
1899            "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
1900        );
1901
1902        // Plain text on line 3 is still flagged
1903        let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
1904        assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
1905    }
1906
1907    #[test]
1908    fn test_html_text_content_still_flagged() {
1909        // Text between HTML tags (not inside `<...>`) is still checked.
1910        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1911        let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
1912        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913        let result = rule.check(&ctx).unwrap();
1914
1915        // "example.test" in the href attribute → not flagged (inside `<...>`)
1916        // "test link" in the anchor text → flagged (between `>` and `<`)
1917        assert_eq!(
1918            result.len(),
1919            1,
1920            "Should flag only 'test' in anchor text, not in href: {result:?}"
1921        );
1922        assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
1923    }
1924
1925    #[test]
1926    fn test_html_attribute_various_not_flagged() {
1927        // All attribute types are ignored: src, href, alt, class, data-*, title, etc.
1928        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1929        let content = concat!(
1930            "# Heading\n\n",
1931            "<img src=\"test.png\" alt=\"test image\">\n",
1932            "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
1933        );
1934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935        let result = rule.check(&ctx).unwrap();
1936
1937        // Only "test content" (between tags on line 4) should be flagged
1938        assert_eq!(
1939            result.len(),
1940            1,
1941            "Should flag only 'test content' between tags: {result:?}"
1942        );
1943        assert_eq!(result[0].line, 4);
1944    }
1945
1946    #[test]
1947    fn test_plain_text_underscore_boundary_unchanged() {
1948        // Plain text (outside HTML tags) still uses original word boundary semantics where
1949        // underscore is a boundary character, matching markdownlint's behavior via AST splitting.
1950        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1951        let content = "# Heading\n\ntest_image is here and just_test ends here\n";
1952        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1953        let result = rule.check(&ctx).unwrap();
1954
1955        // Both "test_image" (test at start) and "just_test" (test at end) are flagged
1956        // because in plain text, "_" is a word boundary
1957        assert_eq!(
1958            result.len(),
1959            2,
1960            "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
1961        );
1962        let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
1963        assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
1964        assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
1965    }
1966
1967    #[test]
1968    fn test_frontmatter_yaml_keys_not_flagged() {
1969        // YAML keys in frontmatter should NOT be checked for proper name violations.
1970        // Only values should be checked.
1971        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1972
1973        let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
1974        let ctx = create_context(content);
1975        let result = rule.check(&ctx).unwrap();
1976
1977        // "test" in the YAML key (line 3) should NOT be flagged
1978        // "Test" in the YAML value (line 3) is correct capitalization, no flag
1979        // "Test" in body (line 6) is correct capitalization, no flag
1980        assert!(
1981            result.is_empty(),
1982            "Should not flag YAML keys or correctly capitalized values: {result:?}"
1983        );
1984    }
1985
1986    #[test]
1987    fn test_frontmatter_yaml_values_flagged() {
1988        // Incorrectly capitalized names in YAML values should be flagged.
1989        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
1990
1991        let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
1992        let ctx = create_context(content);
1993        let result = rule.check(&ctx).unwrap();
1994
1995        // "test" in the YAML value (line 3) SHOULD be flagged
1996        assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
1997        assert_eq!(result[0].line, 3);
1998        assert_eq!(result[0].column, 8); // "key: a " = 7 chars, then "test" at column 8
1999    }
2000
2001    #[test]
2002    fn test_frontmatter_key_matches_name_not_flagged() {
2003        // A YAML key that happens to match a configured name should NOT be flagged.
2004        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2005
2006        let content = "---\ntest: other value\n---\n\nBody text\n";
2007        let ctx = create_context(content);
2008        let result = rule.check(&ctx).unwrap();
2009
2010        assert!(
2011            result.is_empty(),
2012            "Should not flag YAML key that matches configured name: {result:?}"
2013        );
2014    }
2015
2016    #[test]
2017    fn test_frontmatter_empty_value_not_flagged() {
2018        // YAML key with no value should be skipped entirely.
2019        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2020
2021        let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2022        let ctx = create_context(content);
2023        let result = rule.check(&ctx).unwrap();
2024
2025        assert!(
2026            result.is_empty(),
2027            "Should not flag YAML keys with empty values: {result:?}"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_frontmatter_nested_yaml_key_not_flagged() {
2033        // Nested/indented YAML keys should also be skipped.
2034        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2035
2036        let content = "---\nparent:\n  test: nested value\n---\n\nBody text\n";
2037        let ctx = create_context(content);
2038        let result = rule.check(&ctx).unwrap();
2039
2040        // "test" as a nested key should NOT be flagged
2041        assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2042    }
2043
2044    #[test]
2045    fn test_frontmatter_list_items_checked() {
2046        // YAML list items are values and should be checked for proper names.
2047        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2048
2049        let content = "---\ntags:\n  - test\n  - other\n---\n\nBody text\n";
2050        let ctx = create_context(content);
2051        let result = rule.check(&ctx).unwrap();
2052
2053        // "test" as a list item value SHOULD be flagged
2054        assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2055        assert_eq!(result[0].line, 3);
2056    }
2057
2058    #[test]
2059    fn test_frontmatter_value_with_multiple_colons() {
2060        // For "key: value: more", key is before first colon.
2061        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2062
2063        let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2064        let ctx = create_context(content);
2065        let result = rule.check(&ctx).unwrap();
2066
2067        // "test" as key should NOT be flagged
2068        // "test" in value portion ("description: a test thing") SHOULD be flagged
2069        assert_eq!(
2070            result.len(),
2071            1,
2072            "Should flag 'test' in value after first colon: {result:?}"
2073        );
2074        assert_eq!(result[0].line, 2);
2075        assert!(result[0].column > 6, "Violation column should be in value portion");
2076    }
2077
2078    #[test]
2079    fn test_frontmatter_does_not_affect_body() {
2080        // Body text after frontmatter should still be fully checked.
2081        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2082
2083        let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2084        let ctx = create_context(content);
2085        let result = rule.check(&ctx).unwrap();
2086
2087        assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2088        assert_eq!(result[0].line, 5);
2089    }
2090
2091    #[test]
2092    fn test_frontmatter_fix_corrects_values_preserves_keys() {
2093        // Fix should correct YAML values but preserve keys.
2094        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2095
2096        let content = "---\ntest: a test value\n---\n\ntest here\n";
2097        let ctx = create_context(content);
2098        let fixed = rule.fix(&ctx).unwrap();
2099
2100        // Key "test" should remain lowercase; value "test" should become "Test"
2101        assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2102    }
2103
2104    #[test]
2105    fn test_frontmatter_multiword_value_flagged() {
2106        // Multiple proper names in a single YAML value should all be flagged.
2107        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2108
2109        let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2110        let ctx = create_context(content);
2111        let result = rule.check(&ctx).unwrap();
2112
2113        assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2114        assert!(result.iter().all(|w| w.line == 2));
2115    }
2116
2117    #[test]
2118    fn test_frontmatter_yaml_comments_not_checked() {
2119        // YAML comments inside frontmatter should be skipped entirely.
2120        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2121
2122        let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2123        let ctx = create_context(content);
2124        let result = rule.check(&ctx).unwrap();
2125
2126        assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2127    }
2128
2129    #[test]
2130    fn test_frontmatter_delimiters_not_checked() {
2131        // Frontmatter delimiter lines (--- or +++) should never be checked.
2132        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2133
2134        let content = "---\ntitle: Heading\n---\n\ntest here\n";
2135        let ctx = create_context(content);
2136        let result = rule.check(&ctx).unwrap();
2137
2138        // Only the body "test" on line 5 should be flagged
2139        assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2140        assert_eq!(result[0].line, 5);
2141    }
2142
2143    #[test]
2144    fn test_frontmatter_continuation_lines_checked() {
2145        // Continuation lines (indented, no colon) are value content and should be checked.
2146        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2147
2148        let content = "---\ndescription: >\n  a test value\n  continued here\n---\n\nBody\n";
2149        let ctx = create_context(content);
2150        let result = rule.check(&ctx).unwrap();
2151
2152        // "test" on the continuation line should be flagged
2153        assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2154        assert_eq!(result[0].line, 3);
2155    }
2156
2157    #[test]
2158    fn test_frontmatter_quoted_values_checked() {
2159        // Quoted YAML values should have their content checked (inside the quotes).
2160        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2161
2162        let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2163        let ctx = create_context(content);
2164        let result = rule.check(&ctx).unwrap();
2165
2166        assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2167        assert_eq!(result[0].line, 2);
2168    }
2169
2170    #[test]
2171    fn test_frontmatter_single_quoted_values_checked() {
2172        // Single-quoted YAML values should have their content checked.
2173        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2174
2175        let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2176        let ctx = create_context(content);
2177        let result = rule.check(&ctx).unwrap();
2178
2179        assert_eq!(
2180            result.len(),
2181            1,
2182            "Should flag 'test' in single-quoted YAML value: {result:?}"
2183        );
2184        assert_eq!(result[0].line, 2);
2185    }
2186
2187    #[test]
2188    fn test_frontmatter_fix_multiword_values() {
2189        // Fix should correct all proper names in frontmatter values.
2190        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2191
2192        let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2193        let ctx = create_context(content);
2194        let fixed = rule.fix(&ctx).unwrap();
2195
2196        assert_eq!(
2197            fixed,
2198            "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2199        );
2200    }
2201
2202    #[test]
2203    fn test_frontmatter_fix_preserves_yaml_structure() {
2204        // Fix should preserve YAML structure while correcting values.
2205        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2206
2207        let content = "---\ntags:\n  - test\n  - other\ntitle: a test doc\n---\n\ntest body\n";
2208        let ctx = create_context(content);
2209        let fixed = rule.fix(&ctx).unwrap();
2210
2211        assert_eq!(
2212            fixed,
2213            "---\ntags:\n  - Test\n  - other\ntitle: a Test doc\n---\n\nTest body\n"
2214        );
2215    }
2216
2217    #[test]
2218    fn test_frontmatter_toml_delimiters_not_checked() {
2219        // TOML frontmatter with +++ delimiters should also be handled.
2220        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2221
2222        let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2223        let ctx = create_context(content);
2224        let result = rule.check(&ctx).unwrap();
2225
2226        // "title" as TOML key should NOT be flagged
2227        // "test" in TOML quoted value SHOULD be flagged (line 2)
2228        // "test" in body SHOULD be flagged (line 5)
2229        assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2230        let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2231        assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2232        let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2233        assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2234    }
2235
2236    #[test]
2237    fn test_frontmatter_toml_key_not_flagged() {
2238        // TOML keys should NOT be flagged, only values.
2239        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2240
2241        let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2242        let ctx = create_context(content);
2243        let result = rule.check(&ctx).unwrap();
2244
2245        assert!(
2246            result.is_empty(),
2247            "Should not flag TOML key that matches configured name: {result:?}"
2248        );
2249    }
2250
2251    #[test]
2252    fn test_frontmatter_toml_fix_preserves_keys() {
2253        // Fix should correct TOML values but preserve keys.
2254        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2255
2256        let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2257        let ctx = create_context(content);
2258        let fixed = rule.fix(&ctx).unwrap();
2259
2260        // Key "test" should remain lowercase; value "test" should become "Test"
2261        assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2262    }
2263
2264    #[test]
2265    fn test_frontmatter_list_item_mapping_key_not_flagged() {
2266        // In "- test: nested value", "test" is a YAML key within a list-item mapping.
2267        // The key should NOT be flagged; only the value should be checked.
2268        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2269
2270        let content = "---\nitems:\n  - test: nested value\n---\n\nBody text\n";
2271        let ctx = create_context(content);
2272        let result = rule.check(&ctx).unwrap();
2273
2274        assert!(
2275            result.is_empty(),
2276            "Should not flag YAML key in list-item mapping: {result:?}"
2277        );
2278    }
2279
2280    #[test]
2281    fn test_frontmatter_list_item_mapping_value_flagged() {
2282        // In "- key: test value", the value portion should be checked.
2283        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2284
2285        let content = "---\nitems:\n  - key: a test value\n---\n\nBody text\n";
2286        let ctx = create_context(content);
2287        let result = rule.check(&ctx).unwrap();
2288
2289        assert_eq!(
2290            result.len(),
2291            1,
2292            "Should flag 'test' in list-item mapping value: {result:?}"
2293        );
2294        assert_eq!(result[0].line, 3);
2295    }
2296
2297    #[test]
2298    fn test_frontmatter_bare_list_item_still_flagged() {
2299        // Bare list items without a colon (e.g., "- test") are values and should be flagged.
2300        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2301
2302        let content = "---\ntags:\n  - test\n  - other\n---\n\nBody text\n";
2303        let ctx = create_context(content);
2304        let result = rule.check(&ctx).unwrap();
2305
2306        assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2307        assert_eq!(result[0].line, 3);
2308    }
2309
2310    #[test]
2311    fn test_frontmatter_flow_mapping_not_flagged() {
2312        // Flow mappings like {test: value} contain YAML keys that should not be flagged.
2313        // The entire flow construct should be skipped.
2314        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2315
2316        let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2317        let ctx = create_context(content);
2318        let result = rule.check(&ctx).unwrap();
2319
2320        assert!(
2321            result.is_empty(),
2322            "Should not flag names inside flow mappings: {result:?}"
2323        );
2324    }
2325
2326    #[test]
2327    fn test_frontmatter_flow_sequence_not_flagged() {
2328        // Flow sequences like [test, other] should also be skipped.
2329        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2330
2331        let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2332        let ctx = create_context(content);
2333        let result = rule.check(&ctx).unwrap();
2334
2335        assert!(
2336            result.is_empty(),
2337            "Should not flag names inside flow sequences: {result:?}"
2338        );
2339    }
2340
2341    #[test]
2342    fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2343        // Fix should correct values in list-item mappings but preserve keys.
2344        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2345
2346        let content = "---\nitems:\n  - test: a test value\n---\n\ntest here\n";
2347        let ctx = create_context(content);
2348        let fixed = rule.fix(&ctx).unwrap();
2349
2350        // "test" as list-item key should remain lowercase;
2351        // "test" in value portion should become "Test"
2352        assert_eq!(fixed, "---\nitems:\n  - test: a Test value\n---\n\nTest here\n");
2353    }
2354
2355    #[test]
2356    fn test_frontmatter_backtick_code_not_flagged() {
2357        // Names inside backticks in frontmatter should NOT be flagged when code_blocks=false.
2358        let config = MD044Config {
2359            names: vec!["GoodApplication".to_string()],
2360            code_blocks: false,
2361            ..MD044Config::default()
2362        };
2363        let rule = MD044ProperNames::from_config_struct(config);
2364
2365        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2366        let ctx = create_context(content);
2367        let result = rule.check(&ctx).unwrap();
2368
2369        // Neither the frontmatter nor the body backtick-wrapped name should be flagged
2370        assert!(
2371            result.is_empty(),
2372            "Should not flag names inside backticks in frontmatter or body: {result:?}"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_frontmatter_unquoted_backtick_code_not_flagged() {
2378        // Exact case from issue #513: unquoted YAML frontmatter with backticks
2379        let config = MD044Config {
2380            names: vec!["GoodApplication".to_string()],
2381            code_blocks: false,
2382            ..MD044Config::default()
2383        };
2384        let rule = MD044ProperNames::from_config_struct(config);
2385
2386        let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
2387        let ctx = create_context(content);
2388        let result = rule.check(&ctx).unwrap();
2389
2390        assert!(
2391            result.is_empty(),
2392            "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
2393        );
2394    }
2395
2396    #[test]
2397    fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
2398        // Names outside backticks in frontmatter should still be flagged.
2399        let config = MD044Config {
2400            names: vec!["GoodApplication".to_string()],
2401            code_blocks: false,
2402            ..MD044Config::default()
2403        };
2404        let rule = MD044ProperNames::from_config_struct(config);
2405
2406        let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
2407        let ctx = create_context(content);
2408        let result = rule.check(&ctx).unwrap();
2409
2410        // Only the bare "goodapplication" (before backticks) should be flagged
2411        assert_eq!(
2412            result.len(),
2413            1,
2414            "Should flag bare name but not backtick-wrapped name: {result:?}"
2415        );
2416        assert_eq!(result[0].line, 2);
2417        assert_eq!(result[0].column, 8); // "title: " = 7 chars, name at column 8
2418    }
2419
2420    #[test]
2421    fn test_frontmatter_backtick_code_with_code_blocks_true() {
2422        // When code_blocks=true, names inside backticks ARE checked.
2423        let config = MD044Config {
2424            names: vec!["GoodApplication".to_string()],
2425            code_blocks: true,
2426            ..MD044Config::default()
2427        };
2428        let rule = MD044ProperNames::from_config_struct(config);
2429
2430        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
2431        let ctx = create_context(content);
2432        let result = rule.check(&ctx).unwrap();
2433
2434        // With code_blocks=true, backtick-wrapped name SHOULD be flagged
2435        assert_eq!(
2436            result.len(),
2437            1,
2438            "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
2439        );
2440        assert_eq!(result[0].line, 2);
2441    }
2442
2443    #[test]
2444    fn test_frontmatter_fix_preserves_backtick_code() {
2445        // Fix should NOT change names inside backticks in frontmatter.
2446        let config = MD044Config {
2447            names: vec!["GoodApplication".to_string()],
2448            code_blocks: false,
2449            ..MD044Config::default()
2450        };
2451        let rule = MD044ProperNames::from_config_struct(config);
2452
2453        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
2454        let ctx = create_context(content);
2455        let fixed = rule.fix(&ctx).unwrap();
2456
2457        // Neither backtick-wrapped occurrence should be changed
2458        assert_eq!(
2459            fixed, content,
2460            "Fix should not modify names inside backticks in frontmatter"
2461        );
2462    }
2463
2464    // --- Angle-bracket URL tests (issue #457) ---
2465
2466    #[test]
2467    fn test_angle_bracket_url_in_html_comment_not_flagged() {
2468        // Angle-bracket URLs inside HTML comments should be skipped
2469        let config = MD044Config {
2470            names: vec!["Test".to_string()],
2471            ..MD044Config::default()
2472        };
2473        let rule = MD044ProperNames::from_config_struct(config);
2474
2475        let content = "---\ntitle: Level 1 heading\n---\n\n<https://www.example.test>\n\n<!-- This is a Test https://www.example.test -->\n<!-- This is a Test <https://www.example.test> -->\n";
2476        let ctx = create_context(content);
2477        let result = rule.check(&ctx).unwrap();
2478
2479        // Line 7: "Test" in comment prose before bare URL -- already correct capitalization
2480        // Line 7: "test" in bare URL (not in angle brackets) -- but "test" is in URL domain, not prose.
2481        //   However, .example.test has "test" at a word boundary (after '.'), so it IS flagged.
2482        // Line 8: "Test" in comment prose -- correct capitalization, not flagged
2483        // Line 8: "test" in <https://www.example.test> -- inside angle-bracket URL, NOT flagged
2484
2485        // The key assertion: line 8's angle-bracket URL should NOT produce a warning
2486        let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
2487        assert!(
2488            line8_warnings.is_empty(),
2489            "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_bare_url_in_html_comment_still_flagged() {
2495        // Bare URLs (not in angle brackets) inside HTML comments should still be checked
2496        let config = MD044Config {
2497            names: vec!["Test".to_string()],
2498            ..MD044Config::default()
2499        };
2500        let rule = MD044ProperNames::from_config_struct(config);
2501
2502        let content = "<!-- This is a test https://www.example.test -->\n";
2503        let ctx = create_context(content);
2504        let result = rule.check(&ctx).unwrap();
2505
2506        // "test" appears as prose text before URL and also in the bare URL domain
2507        // At minimum, the prose "test" should be flagged
2508        assert!(
2509            !result.is_empty(),
2510            "Should flag 'test' in prose text of HTML comment with bare URL"
2511        );
2512    }
2513
2514    #[test]
2515    fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
2516        // Angle-bracket URLs in regular markdown are already handled by the link parser,
2517        // but the angle-bracket check provides a safety net
2518        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2519
2520        let content = "<https://www.example.test>\n";
2521        let ctx = create_context(content);
2522        let result = rule.check(&ctx).unwrap();
2523
2524        assert!(
2525            result.is_empty(),
2526            "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_multiple_angle_bracket_urls_in_one_comment() {
2532        let config = MD044Config {
2533            names: vec!["Test".to_string()],
2534            ..MD044Config::default()
2535        };
2536        let rule = MD044ProperNames::from_config_struct(config);
2537
2538        let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
2539        let ctx = create_context(content);
2540        let result = rule.check(&ctx).unwrap();
2541
2542        // Both URLs are inside angle brackets, so "test" inside them should NOT be flagged
2543        assert!(
2544            result.is_empty(),
2545            "Should not flag names inside multiple angle-bracket URLs: {result:?}"
2546        );
2547    }
2548
2549    #[test]
2550    fn test_angle_bracket_non_url_still_flagged() {
2551        // <Test> is NOT a URL (no scheme), so is_in_angle_bracket_url does NOT protect it.
2552        // Whether it gets flagged depends on HTML tag detection, not on our URL check.
2553        assert!(
2554            !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
2555            "is_in_angle_bracket_url should return false for non-URL angle brackets"
2556        );
2557    }
2558
2559    #[test]
2560    fn test_angle_bracket_mailto_url_not_flagged() {
2561        let config = MD044Config {
2562            names: vec!["Test".to_string()],
2563            ..MD044Config::default()
2564        };
2565        let rule = MD044ProperNames::from_config_struct(config);
2566
2567        let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
2568        let ctx = create_context(content);
2569        let result = rule.check(&ctx).unwrap();
2570
2571        assert!(
2572            result.is_empty(),
2573            "Should not flag names inside angle-bracket mailto URLs: {result:?}"
2574        );
2575    }
2576
2577    #[test]
2578    fn test_angle_bracket_ftp_url_not_flagged() {
2579        let config = MD044Config {
2580            names: vec!["Test".to_string()],
2581            ..MD044Config::default()
2582        };
2583        let rule = MD044ProperNames::from_config_struct(config);
2584
2585        let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
2586        let ctx = create_context(content);
2587        let result = rule.check(&ctx).unwrap();
2588
2589        assert!(
2590            result.is_empty(),
2591            "Should not flag names inside angle-bracket FTP URLs: {result:?}"
2592        );
2593    }
2594
2595    #[test]
2596    fn test_angle_bracket_url_fix_preserves_url() {
2597        // Fix should not modify text inside angle-bracket URLs
2598        let config = MD044Config {
2599            names: vec!["Test".to_string()],
2600            ..MD044Config::default()
2601        };
2602        let rule = MD044ProperNames::from_config_struct(config);
2603
2604        let content = "<!-- test text <https://www.example.test> -->\n";
2605        let ctx = create_context(content);
2606        let fixed = rule.fix(&ctx).unwrap();
2607
2608        // "test" in prose should be fixed, URL should be preserved
2609        assert!(
2610            fixed.contains("<https://www.example.test>"),
2611            "Fix should preserve angle-bracket URLs: {fixed}"
2612        );
2613        assert!(
2614            fixed.contains("Test text"),
2615            "Fix should correct prose 'test' to 'Test': {fixed}"
2616        );
2617    }
2618
2619    #[test]
2620    fn test_is_in_angle_bracket_url_helper() {
2621        // Direct tests of the helper function
2622        let line = "text <https://example.test> more text";
2623
2624        // Inside the URL
2625        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 5)); // '<'
2626        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 6)); // 'h'
2627        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 15)); // middle of URL
2628        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 26)); // '>'
2629
2630        // Outside the URL
2631        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 0)); // 't' at start
2632        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 4)); // space before '<'
2633        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 27)); // space after '>'
2634
2635        // Non-URL angle brackets
2636        assert!(!MD044ProperNames::is_in_angle_bracket_url("<notaurl>", 1));
2637
2638        // mailto scheme
2639        assert!(MD044ProperNames::is_in_angle_bracket_url(
2640            "<mailto:test@example.com>",
2641            10
2642        ));
2643
2644        // ftp scheme
2645        assert!(MD044ProperNames::is_in_angle_bracket_url(
2646            "<ftp://test.example.com>",
2647            10
2648        ));
2649    }
2650
2651    #[test]
2652    fn test_is_in_angle_bracket_url_uppercase_scheme() {
2653        // RFC 3986: URI schemes are case-insensitive
2654        assert!(MD044ProperNames::is_in_angle_bracket_url(
2655            "<HTTPS://test.example.com>",
2656            10
2657        ));
2658        assert!(MD044ProperNames::is_in_angle_bracket_url(
2659            "<Http://test.example.com>",
2660            10
2661        ));
2662    }
2663
2664    #[test]
2665    fn test_is_in_angle_bracket_url_uncommon_schemes() {
2666        // ssh scheme
2667        assert!(MD044ProperNames::is_in_angle_bracket_url(
2668            "<ssh://test@example.com>",
2669            10
2670        ));
2671        // file scheme
2672        assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
2673        // data scheme (no authority, just colon)
2674        assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
2675    }
2676
2677    #[test]
2678    fn test_is_in_angle_bracket_url_unclosed() {
2679        // Unclosed angle bracket should NOT match
2680        assert!(!MD044ProperNames::is_in_angle_bracket_url(
2681            "<https://test.example.com",
2682            10
2683        ));
2684    }
2685
2686    #[test]
2687    fn test_vale_inline_config_comments_not_flagged() {
2688        let config = MD044Config {
2689            names: vec!["Vale".to_string(), "JavaScript".to_string()],
2690            ..MD044Config::default()
2691        };
2692        let rule = MD044ProperNames::from_config_struct(config);
2693
2694        let content = "\
2695<!-- vale off -->
2696Some javascript text here.
2697<!-- vale on -->
2698<!-- vale Style.Rule = NO -->
2699More javascript text.
2700<!-- vale Style.Rule = YES -->
2701<!-- vale JavaScript.Grammar = NO -->
2702";
2703        let ctx = create_context(content);
2704        let result = rule.check(&ctx).unwrap();
2705
2706        // Only the body text lines (2, 5) should be flagged for "javascript"
2707        assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
2708        assert_eq!(result[0].line, 2);
2709        assert_eq!(result[1].line, 5);
2710    }
2711
2712    #[test]
2713    fn test_remark_lint_inline_config_comments_not_flagged() {
2714        let config = MD044Config {
2715            names: vec!["JavaScript".to_string()],
2716            ..MD044Config::default()
2717        };
2718        let rule = MD044ProperNames::from_config_struct(config);
2719
2720        let content = "\
2721<!-- lint disable remark-lint-some-rule -->
2722Some javascript text here.
2723<!-- lint enable remark-lint-some-rule -->
2724<!-- lint ignore remark-lint-some-rule -->
2725More javascript text.
2726";
2727        let ctx = create_context(content);
2728        let result = rule.check(&ctx).unwrap();
2729
2730        assert_eq!(
2731            result.len(),
2732            2,
2733            "Should only flag body lines, not remark-lint config comments"
2734        );
2735        assert_eq!(result[0].line, 2);
2736        assert_eq!(result[1].line, 5);
2737    }
2738
2739    #[test]
2740    fn test_fix_does_not_modify_vale_remark_lint_comments() {
2741        let config = MD044Config {
2742            names: vec!["JavaScript".to_string(), "Vale".to_string()],
2743            ..MD044Config::default()
2744        };
2745        let rule = MD044ProperNames::from_config_struct(config);
2746
2747        let content = "\
2748<!-- vale off -->
2749Some javascript text.
2750<!-- vale on -->
2751<!-- lint disable remark-lint-some-rule -->
2752More javascript text.
2753<!-- lint enable remark-lint-some-rule -->
2754";
2755        let ctx = create_context(content);
2756        let fixed = rule.fix(&ctx).unwrap();
2757
2758        // Config directive lines must be preserved unchanged
2759        assert!(fixed.contains("<!-- vale off -->"));
2760        assert!(fixed.contains("<!-- vale on -->"));
2761        assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
2762        assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
2763        // Body text should be fixed
2764        assert!(fixed.contains("Some JavaScript text."));
2765        assert!(fixed.contains("More JavaScript text."));
2766    }
2767
2768    #[test]
2769    fn test_mixed_tool_directives_all_skipped() {
2770        let config = MD044Config {
2771            names: vec!["JavaScript".to_string(), "Vale".to_string()],
2772            ..MD044Config::default()
2773        };
2774        let rule = MD044ProperNames::from_config_struct(config);
2775
2776        let content = "\
2777<!-- rumdl-disable MD044 -->
2778Some javascript text.
2779<!-- markdownlint-disable -->
2780More javascript text.
2781<!-- vale off -->
2782Even more javascript text.
2783<!-- lint disable some-rule -->
2784Final javascript text.
2785<!-- rumdl-enable MD044 -->
2786<!-- markdownlint-enable -->
2787<!-- vale on -->
2788<!-- lint enable some-rule -->
2789";
2790        let ctx = create_context(content);
2791        let result = rule.check(&ctx).unwrap();
2792
2793        // Only body text lines should be flagged (lines 2, 4, 6, 8)
2794        assert_eq!(
2795            result.len(),
2796            4,
2797            "Should only flag body lines, not any tool directive comments"
2798        );
2799        assert_eq!(result[0].line, 2);
2800        assert_eq!(result[1].line, 4);
2801        assert_eq!(result[2].line, 6);
2802        assert_eq!(result[3].line, 8);
2803    }
2804
2805    #[test]
2806    fn test_vale_remark_lint_edge_cases_not_matched() {
2807        let config = MD044Config {
2808            names: vec!["JavaScript".to_string(), "Vale".to_string()],
2809            ..MD044Config::default()
2810        };
2811        let rule = MD044ProperNames::from_config_struct(config);
2812
2813        // These are regular HTML comments, NOT tool directives:
2814        // - "<!-- vale -->" is not a valid Vale directive (no action keyword)
2815        // - "<!-- vale is a tool -->" starts with "vale" but is prose, not a directive
2816        // - "<!-- valedictorian javascript -->" does not start with "<!-- vale "
2817        // - "<!-- linting javascript tips -->" does not start with "<!-- lint "
2818        // - "<!-- vale javascript -->" starts with "vale" but has no action keyword
2819        // - "<!-- lint your javascript code -->" starts with "lint" but has no action keyword
2820        let content = "\
2821<!-- vale -->
2822<!-- vale is a tool for writing -->
2823<!-- valedictorian javascript -->
2824<!-- linting javascript tips -->
2825<!-- vale javascript -->
2826<!-- lint your javascript code -->
2827";
2828        let ctx = create_context(content);
2829        let result = rule.check(&ctx).unwrap();
2830
2831        // Line 1: "<!-- vale -->" contains "vale" (wrong case for "Vale") -> flagged
2832        // Line 2: "<!-- vale is a tool for writing -->" contains "vale" -> flagged
2833        // Line 3: "<!-- valedictorian javascript -->" contains "javascript" -> flagged
2834        // Line 4: "<!-- linting javascript tips -->" contains "javascript" -> flagged
2835        // Line 5: "<!-- vale javascript -->" contains "vale" and "javascript" -> flagged for both
2836        // Line 6: "<!-- lint your javascript code -->" contains "javascript" -> flagged
2837        assert_eq!(
2838            result.len(),
2839            7,
2840            "Should flag proper names in non-directive HTML comments: got {result:?}"
2841        );
2842        assert_eq!(result[0].line, 1); // "vale" in <!-- vale -->
2843        assert_eq!(result[1].line, 2); // "vale" in <!-- vale is a tool -->
2844        assert_eq!(result[2].line, 3); // "javascript" in <!-- valedictorian javascript -->
2845        assert_eq!(result[3].line, 4); // "javascript" in <!-- linting javascript tips -->
2846        assert_eq!(result[4].line, 5); // "vale" in <!-- vale javascript -->
2847        assert_eq!(result[5].line, 5); // "javascript" in <!-- vale javascript -->
2848        assert_eq!(result[6].line, 6); // "javascript" in <!-- lint your javascript code -->
2849    }
2850
2851    #[test]
2852    fn test_vale_style_directives_skipped() {
2853        let config = MD044Config {
2854            names: vec!["JavaScript".to_string(), "Vale".to_string()],
2855            ..MD044Config::default()
2856        };
2857        let rule = MD044ProperNames::from_config_struct(config);
2858
2859        // These ARE valid Vale directives and should be skipped:
2860        let content = "\
2861<!-- vale style = MyStyle -->
2862<!-- vale styles = Style1, Style2 -->
2863<!-- vale MyRule.Name = YES -->
2864<!-- vale MyRule.Name = NO -->
2865Some javascript text.
2866";
2867        let ctx = create_context(content);
2868        let result = rule.check(&ctx).unwrap();
2869
2870        // Only line 5 (body text) should be flagged
2871        assert_eq!(
2872            result.len(),
2873            1,
2874            "Should only flag body lines, not Vale style/rule directives: got {result:?}"
2875        );
2876        assert_eq!(result[0].line, 5);
2877    }
2878
2879    // --- is_in_backtick_code_in_line unit tests ---
2880
2881    #[test]
2882    fn test_backtick_code_single_backticks() {
2883        let line = "hello `world` bye";
2884        // 'w' is at index 7, inside the backtick span (content between backticks at 6 and 12)
2885        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
2886        // 'h' at index 0 is outside
2887        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2888        // 'b' at index 14 is outside
2889        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
2890    }
2891
2892    #[test]
2893    fn test_backtick_code_double_backticks() {
2894        let line = "a ``code`` b";
2895        // 'c' is at index 4, inside ``...``
2896        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2897        // 'a' at index 0 is outside
2898        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2899        // 'b' at index 11 is outside
2900        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
2901    }
2902
2903    #[test]
2904    fn test_backtick_code_unclosed() {
2905        let line = "a `code b";
2906        // No closing backtick, so nothing is a code span
2907        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2908    }
2909
2910    #[test]
2911    fn test_backtick_code_mismatched_count() {
2912        // Single backtick opening, double backtick is not a match
2913        let line = "a `code`` b";
2914        // The single ` at index 2 doesn't match `` at index 7-8
2915        // So 'c' at index 3 is NOT in a code span
2916        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
2917    }
2918
2919    #[test]
2920    fn test_backtick_code_multiple_spans() {
2921        let line = "`first` and `second`";
2922        // 'f' at index 1 (inside first span)
2923        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2924        // 'a' at index 8 (between spans)
2925        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
2926        // 's' at index 13 (inside second span)
2927        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
2928    }
2929
2930    #[test]
2931    fn test_backtick_code_on_backtick_boundary() {
2932        let line = "`code`";
2933        // Position 0 is the opening backtick itself, not inside the span
2934        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
2935        // Position 5 is the closing backtick, not inside the span
2936        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
2937        // Position 1-4 are inside the span
2938        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
2939        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
2940    }
2941
2942    // Double-bracket WikiLink + URL: [[text]](url)
2943    // pulldown-cmark parses [[text]] as a WikiLink but leaves the (url)
2944    // as plain text, so ctx.links does not cover the URL portion.
2945    // MD044 must fall back to is_in_markdown_link_url for all lines.
2946
2947    #[test]
2948    fn test_double_bracket_link_url_not_flagged() {
2949        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2950        // Exact reproduction from issue #564
2951        let content = "[[rumdl]](https://github.com/rvben/rumdl)";
2952        let ctx = create_context(content);
2953        let result = rule.check(&ctx).unwrap();
2954        assert!(
2955            result.is_empty(),
2956            "URL inside [[text]](url) must not be flagged, got: {result:?}"
2957        );
2958    }
2959
2960    #[test]
2961    fn test_double_bracket_link_url_not_fixed() {
2962        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2963        let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
2964        let ctx = create_context(content);
2965        let fixed = rule.fix(&ctx).unwrap();
2966        assert_eq!(
2967            fixed, content,
2968            "fix() must leave the URL inside [[text]](url) unchanged"
2969        );
2970    }
2971
2972    #[test]
2973    fn test_double_bracket_link_text_still_flagged() {
2974        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2975        // The link text portion [[github]](url) should still be checked.
2976        let content = "[[github]](https://example.com)";
2977        let ctx = create_context(content);
2978        let result = rule.check(&ctx).unwrap();
2979        assert_eq!(
2980            result.len(),
2981            1,
2982            "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
2983        );
2984        assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
2985    }
2986
2987    #[test]
2988    fn test_double_bracket_link_mixed_line() {
2989        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
2990        // URL must be skipped, standalone text must be flagged.
2991        let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
2992        let ctx = create_context(content);
2993        let result = rule.check(&ctx).unwrap();
2994        assert_eq!(
2995            result.len(),
2996            1,
2997            "Only the standalone 'github' after the link should be flagged, got: {result:?}"
2998        );
2999        assert!(result[0].message.contains("'github'"));
3000        // "See " (4) + "[[rumdl]](https://github.com/rvben/rumdl)" (42) + " and " (4) = column 51
3001        assert_eq!(
3002            result[0].column, 51,
3003            "Flagged column should be the trailing 'github', not the one in the URL"
3004        );
3005    }
3006
3007    #[test]
3008    fn test_regular_link_url_still_not_flagged() {
3009        // Confirm existing [text](url) behavior is unaffected by the fix.
3010        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3011        let content = "[rumdl](https://github.com/rvben/rumdl)";
3012        let ctx = create_context(content);
3013        let result = rule.check(&ctx).unwrap();
3014        assert!(
3015            result.is_empty(),
3016            "URL inside regular [text](url) must still not be flagged, got: {result:?}"
3017        );
3018    }
3019
3020    #[test]
3021    fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
3022        // When code-blocks = true the user explicitly opts into checking code spans.
3023        // A code span containing link-like text (`[foo](https://github.com)`) must
3024        // NOT be silently suppressed by is_in_markdown_link_url: the content is
3025        // literal characters, not a real Markdown link.
3026        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
3027        let content = "`[foo](https://github.com/org/repo)`";
3028        let ctx = create_context(content);
3029        let result = rule.check(&ctx).unwrap();
3030        assert_eq!(
3031            result.len(),
3032            1,
3033            "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
3034        );
3035        assert!(result[0].message.contains("'github'"));
3036    }
3037
3038    #[test]
3039    fn test_malformed_link_not_treated_as_url() {
3040        // [text](url with spaces) is NOT a valid Markdown link; pulldown-cmark
3041        // does not parse it, so the name inside must still be flagged.
3042        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3043        let content = "See [rumdl](github repo) for details.";
3044        let ctx = create_context(content);
3045        let result = rule.check(&ctx).unwrap();
3046        assert_eq!(
3047            result.len(),
3048            1,
3049            "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
3050        );
3051        assert!(result[0].message.contains("'github'"));
3052    }
3053
3054    #[test]
3055    fn test_wikilink_followed_by_prose_parens_still_flagged() {
3056        // [[note]](github repo) — WikiLink followed by parenthesised prose, NOT
3057        // a valid link URL (space in destination). pulldown-cmark does not parse
3058        // it as a link, so the name inside must still be flagged.
3059        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3060        let content = "[[note]](github repo)";
3061        let ctx = create_context(content);
3062        let result = rule.check(&ctx).unwrap();
3063        assert_eq!(
3064            result.len(),
3065            1,
3066            "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
3067        );
3068        assert!(result[0].message.contains("'github'"));
3069    }
3070
3071    /// Roundtrip safety: fix() output must produce zero warnings on re-check.
3072    #[test]
3073    fn test_roundtrip_fix_then_check_basic() {
3074        let rule = MD044ProperNames::new(
3075            vec![
3076                "JavaScript".to_string(),
3077                "TypeScript".to_string(),
3078                "Node.js".to_string(),
3079            ],
3080            true,
3081        );
3082        let content = "I love javascript, typescript, and nodejs!";
3083        let ctx = create_context(content);
3084        let fixed = rule.fix(&ctx).unwrap();
3085        let ctx2 = create_context(&fixed);
3086        let warnings = rule.check(&ctx2).unwrap();
3087        assert!(
3088            warnings.is_empty(),
3089            "Re-check after fix should produce zero warnings, got: {warnings:?}"
3090        );
3091    }
3092
3093    /// Roundtrip safety: fix() output must produce zero warnings for multiline content.
3094    #[test]
3095    fn test_roundtrip_fix_then_check_multiline() {
3096        let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
3097        let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
3098        let ctx = create_context(content);
3099        let fixed = rule.fix(&ctx).unwrap();
3100        let ctx2 = create_context(&fixed);
3101        let warnings = rule.check(&ctx2).unwrap();
3102        assert!(
3103            warnings.is_empty(),
3104            "Re-check after fix should produce zero warnings, got: {warnings:?}"
3105        );
3106    }
3107
3108    /// Roundtrip safety: fix() with inline config disable blocks.
3109    #[test]
3110    fn test_roundtrip_fix_then_check_inline_config() {
3111        let config = MD044Config {
3112            names: vec!["RUMDL".to_string()],
3113            ..MD044Config::default()
3114        };
3115        let rule = MD044ProperNames::from_config_struct(config);
3116        let content =
3117            "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
3118        let ctx = create_context(content);
3119        let fixed = rule.fix(&ctx).unwrap();
3120        // The disabled block should be preserved, the outside text fixed
3121        assert!(
3122            fixed.contains("Some rumdl text.\n"),
3123            "Disabled block text should be preserved"
3124        );
3125        assert!(
3126            fixed.contains("Some RUMDL text outside."),
3127            "Outside text should be fixed"
3128        );
3129    }
3130
3131    /// Roundtrip safety: fix() with HTML comment content.
3132    #[test]
3133    fn test_roundtrip_fix_then_check_html_comments() {
3134        let config = MD044Config {
3135            names: vec!["JavaScript".to_string()],
3136            ..MD044Config::default()
3137        };
3138        let rule = MD044ProperNames::from_config_struct(config);
3139        let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
3140        let ctx = create_context(content);
3141        let fixed = rule.fix(&ctx).unwrap();
3142        let ctx2 = create_context(&fixed);
3143        let warnings = rule.check(&ctx2).unwrap();
3144        assert!(
3145            warnings.is_empty(),
3146            "Re-check after fix should produce zero warnings, got: {warnings:?}"
3147        );
3148    }
3149
3150    /// Roundtrip safety: fix() preserves content when no violations exist.
3151    #[test]
3152    fn test_roundtrip_no_op_when_correct() {
3153        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
3154        let content = "This uses JavaScript and TypeScript correctly.\n";
3155        let ctx = create_context(content);
3156        let fixed = rule.fix(&ctx).unwrap();
3157        assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
3158    }
3159
3160    // --- Bare-domain link text: display text is the destination URL with scheme stripped ---
3161
3162    #[test]
3163    fn test_bare_domain_link_text_not_flagged() {
3164        // `[ravencentric.github.io](https://ravencentric.github.io)` — the display text
3165        // is the URL with the scheme stripped; "github" here is a domain label, not a
3166        // reference to "GitHub" the product, and must not be corrected.
3167        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3168        let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3169        let ctx = create_context(content);
3170        let result = rule.check(&ctx).unwrap();
3171        assert!(
3172            result.is_empty(),
3173            "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
3174        );
3175    }
3176
3177    #[test]
3178    fn test_bare_domain_link_text_not_fixed() {
3179        // fix() must not rewrite the link text when it is the bare URL hostname.
3180        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3181        let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
3182        let ctx = create_context(content);
3183        let fixed = rule.fix(&ctx).unwrap();
3184        assert_eq!(
3185            fixed, content,
3186            "fix() must not alter bare-domain link text that matches the destination URL"
3187        );
3188    }
3189
3190    #[test]
3191    fn test_bare_domain_link_text_with_path_not_flagged() {
3192        // Display text is the hostname only; destination has a path.
3193        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3194        let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
3195        let ctx = create_context(content);
3196        let result = rule.check(&ctx).unwrap();
3197        assert!(
3198            result.is_empty(),
3199            "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
3200        );
3201    }
3202
3203    #[test]
3204    fn test_bare_domain_link_text_full_path_not_flagged() {
3205        // Display text is the full URL-without-scheme including a path.
3206        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3207        let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
3208        let ctx = create_context(content);
3209        let result = rule.check(&ctx).unwrap();
3210        assert!(
3211            result.is_empty(),
3212            "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
3213        );
3214    }
3215
3216    #[test]
3217    fn test_github_product_name_in_link_text_still_flagged() {
3218        // `[github pages](https://pages.github.com)` — the display text is a human
3219        // description, not a bare domain; "github" should still be corrected to "GitHub".
3220        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3221        let content = "Hosted on [github pages](https://pages.github.com).\n";
3222        let ctx = create_context(content);
3223        let result = rule.check(&ctx).unwrap();
3224        assert!(
3225            !result.is_empty(),
3226            "Should still flag 'github' in descriptive link text that does not match the destination URL"
3227        );
3228    }
3229
3230    #[test]
3231    fn test_protocol_relative_bare_domain_link_text_not_flagged() {
3232        // Protocol-relative URL `[github.io](//github.io)`.
3233        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3234        let content = "See [github.io](//github.io).\n";
3235        let ctx = create_context(content);
3236        let result = rule.check(&ctx).unwrap();
3237        assert!(
3238            result.is_empty(),
3239            "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
3240        );
3241    }
3242
3243    #[test]
3244    fn test_dotted_wikilink_target_still_flagged() {
3245        // `[[node.js]]` is a WikiLink whose page name contains a dot.
3246        // The dot guard alone does not protect it because text == url == "node.js".
3247        // The is_in_link WikiLink guard must prevent bare-domain suppression,
3248        // so the improper capitalization is still caught.
3249        let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
3250        let content = "See [[node.js]] for details.\n";
3251        let ctx = create_context(content);
3252        let result = rule.check(&ctx).unwrap();
3253        assert!(
3254            !result.is_empty(),
3255            "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
3256        );
3257    }
3258
3259    #[test]
3260    fn test_bare_domain_link_text_case_insensitive_url() {
3261        // URL with uppercase scheme `[github.io](HTTPS://github.io)` — the scheme is
3262        // case-insensitive, so the display text should still be recognised as a bare domain.
3263        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
3264        let content = "See [github.io](HTTPS://github.io).\n";
3265        let ctx = create_context(content);
3266        let result = rule.check(&ctx).unwrap();
3267        assert!(
3268            result.is_empty(),
3269            "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
3270        );
3271    }
3272}