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