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