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::lint_context::SourcePositions;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::frontmatter_values;
7use std::collections::{HashMap, HashSet};
8use std::sync::{Arc, Mutex};
9
10mod md044_config;
11pub(super) use md044_config::MD044Config;
12
13type WarningPosition = (usize, usize, String, usize); // (line, column, found_name, proper_name_index)
14
15/// Bound repeated delimiter scans in one line. Ordinary links need no index.
16/// Queries can start after an opening byte that was escaped in the full line,
17/// so the index also records those starts at their surrounding nesting depth.
18#[derive(Default)]
19struct FallbackDelimiters {
20    scanned_bytes: usize,
21    pairs: Option<HashMap<usize, usize>>,
22}
23
24impl FallbackDelimiters {
25    fn find(&mut self, bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
26        debug_assert!(from > 0 && bytes.get(from - 1) == Some(&open));
27        if self.pairs.is_none() && self.scanned_bytes >= bytes.len().saturating_mul(2) {
28            let mut pairs = HashMap::new();
29            let mut pending = Vec::new();
30            let mut depth = 0usize;
31            let mut pos = 0;
32            while pos < bytes.len() {
33                match bytes[pos] {
34                    b'\\' => {
35                        if bytes.get(pos + 1) == Some(&open) {
36                            pending.push((depth, pos + 2));
37                        }
38                        pos += 1;
39                    }
40                    byte if byte == open => {
41                        depth += 1;
42                        pending.push((depth, pos + 1));
43                    }
44                    byte if byte == close => {
45                        while pending.last().is_some_and(|&(level, _)| level == depth) {
46                            let (_, start) = pending.pop().unwrap();
47                            pairs.insert(start, pos + 1);
48                        }
49                        depth = depth.saturating_sub(1);
50                    }
51                    _ => {}
52                }
53                pos += 1;
54            }
55            self.pairs = Some(pairs);
56        }
57        if let Some(pairs) = &self.pairs {
58            return pairs.get(&from).copied();
59        }
60        let end = Self::scan(bytes, from, open, close);
61        self.scanned_bytes = self.scanned_bytes.saturating_add(end.unwrap_or(bytes.len()) - from);
62        end
63    }
64
65    fn scan(bytes: &[u8], from: usize, open: u8, close: u8) -> Option<usize> {
66        let mut depth = 1usize;
67        let mut pos = from;
68        while pos < bytes.len() {
69            match bytes[pos] {
70                b'\\' => pos += 1,
71                byte if byte == open => depth += 1,
72                byte if byte == close => {
73                    depth -= 1;
74                    if depth == 0 {
75                        return Some(pos + 1);
76                    }
77                }
78                _ => {}
79            }
80            pos += 1;
81        }
82        None
83    }
84}
85
86/// Rule MD044: Proper names should be capitalized
87///
88/// See [docs/md044.md](../../docs/md044.md) for full documentation, configuration, and examples.
89///
90/// This rule is triggered when proper names are not capitalized correctly in the document.
91/// For example, if you have defined "JavaScript" as a proper name, the rule will flag any
92/// occurrences of "javascript" or "Javascript" as violations.
93///
94/// ## Purpose
95///
96/// Ensuring consistent capitalization of proper names improves document quality and
97/// professionalism. This is especially important for technical documentation where
98/// product names, programming languages, and technologies often have specific
99/// capitalization conventions.
100///
101/// ## Configuration Options
102///
103/// The rule supports the following configuration options:
104///
105/// ```yaml
106/// MD044:
107///   names: []                # List of proper names to check for correct capitalization
108///   code-blocks: false       # Whether to check code blocks (default: false)
109///   whole-words: false       # Treat hyphen/snake-case compounds as whole tokens
110/// ```
111///
112/// Example configuration:
113///
114/// ```yaml
115/// MD044:
116///   names: ["JavaScript", "Node.js", "TypeScript"]
117///   code-blocks: true
118/// ```
119///
120/// ## Performance Optimizations
121///
122/// This rule implements several performance optimizations:
123///
124/// 1. **Regex Caching**: Pre-compiles and caches regex patterns for each proper name
125/// 2. **Content Caching**: Caches results based on content hashing for repeated checks
126/// 3. **Efficient Text Processing**: Uses optimized algorithms to avoid redundant text processing
127/// 4. **Smart Code Block Detection**: Efficiently identifies and optionally excludes code blocks
128///
129/// ## Edge Cases Handled
130///
131/// - **Word Boundaries**: Only matches complete words, not substrings within other words
132/// - **Case Sensitivity**: Properly handles case-specific matching
133/// - **Code Blocks**: Optionally checks code blocks (controlled by code-blocks setting)
134/// - **Markdown Formatting**: Handles proper names within Markdown formatting elements
135///
136/// ## Fix Behavior
137///
138/// When fixing issues, this rule replaces incorrect capitalization with the correct form
139/// as defined in the configuration.
140///
141#[derive(Clone)]
142pub struct MD044ProperNames {
143    config: MD044Config,
144    // Cache the combined regex pattern string
145    combined_pattern: Option<String>,
146    // Lowercase variants map to the first configured spelling that accepts them.
147    name_variants: HashMap<String, usize>,
148    /// Lowercased `ignore_frontmatter_fields`, for case-insensitive lookup.
149    ignore_fields: HashSet<String>,
150    // Memoizes name violations keyed by content hash. Deliberately behind an
151    // `Arc<Mutex<..>>` so it is SHARED across clones: rule instances are cloned
152    // per config group and recreated for inline-config overrides, and the same
153    // file's content is frequently re-checked (check then fix), so a shared
154    // cache avoids recomputing. `check()` stays observationally pure (same ctx
155    // in, same warnings out); the cache only affects how fast that answer is
156    // produced. The lock is held only for the map get/insert, never across the
157    // regex scan.
158    content_cache: Arc<Mutex<HashMap<u64, Vec<WarningPosition>>>>,
159}
160
161impl MD044ProperNames {
162    pub fn new(names: Vec<String>, code_blocks: bool) -> Self {
163        let config = MD044Config {
164            names,
165            code_blocks,
166            ..Default::default()
167        };
168        Self::from_config_struct(config)
169    }
170
171    // Helper function for consistent ASCII normalization
172    fn ascii_normalize(s: &str) -> String {
173        s.replace(['é', 'è', 'ê', 'ë'], "e")
174            .replace(['à', 'á', 'â', 'ä', 'ã', 'å'], "a")
175            .replace(['ï', 'î', 'í', 'ì'], "i")
176            .replace(['ü', 'ú', 'ù', 'û'], "u")
177            .replace(['ö', 'ó', 'ò', 'ô', 'õ'], "o")
178            .replace('ñ', "n")
179            .replace('ç', "c")
180    }
181
182    pub fn from_config_struct(config: MD044Config) -> Self {
183        let combined_pattern = Self::create_combined_pattern(&config);
184        let name_variants = Self::build_name_variants(&config);
185        let ignore_fields = config
186            .ignore_frontmatter_fields
187            .iter()
188            .flatten()
189            .map(|f| f.to_lowercase())
190            .collect();
191        Self {
192            config,
193            combined_pattern,
194            name_variants,
195            ignore_fields,
196            content_cache: Arc::new(Mutex::new(HashMap::new())),
197        }
198    }
199
200    // Create a combined regex pattern for all proper names
201    fn create_combined_pattern(config: &MD044Config) -> Option<String> {
202        if config.names.is_empty() {
203            return None;
204        }
205
206        // Create patterns for all names and their variations
207        let mut patterns: Vec<String> = config
208            .names
209            .iter()
210            .flat_map(|name| {
211                let mut variations = vec![];
212                let lower_name = name.to_lowercase();
213
214                // Add the lowercase version
215                variations.push(escape_regex(&lower_name));
216
217                // Add version without dots
218                let lower_name_no_dots = lower_name.replace('.', "");
219                if lower_name != lower_name_no_dots {
220                    variations.push(escape_regex(&lower_name_no_dots));
221                }
222
223                // Add ASCII-normalized versions for common accented characters
224                let ascii_normalized = Self::ascii_normalize(&lower_name);
225
226                if ascii_normalized != lower_name {
227                    variations.push(escape_regex(&ascii_normalized));
228
229                    // Also add version without dots
230                    let ascii_no_dots = ascii_normalized.replace('.', "");
231                    if ascii_normalized != ascii_no_dots {
232                        variations.push(escape_regex(&ascii_no_dots));
233                    }
234                }
235
236                variations
237            })
238            .collect();
239
240        // Sort patterns by length (longest first) to avoid shorter patterns matching within longer ones
241        patterns.sort_by_key(|b| std::cmp::Reverse(b.len()));
242
243        // Combine all patterns into a single regex with capture groups
244        // Don't use \b as it doesn't work with Unicode - we'll check boundaries manually
245        Some(format!(r"(?i)({})", patterns.join("|")))
246    }
247
248    fn build_name_variants(config: &MD044Config) -> HashMap<String, usize> {
249        let mut variants = HashMap::new();
250        for (index, name) in config.names.iter().enumerate() {
251            let lower_name = name.to_lowercase();
252            variants.entry(lower_name.clone()).or_insert(index);
253
254            let lower_no_dots = lower_name.replace('.', "");
255            if lower_name != lower_no_dots {
256                variants.entry(lower_no_dots).or_insert(index);
257            }
258
259            let ascii_normalized = Self::ascii_normalize(&lower_name);
260            if ascii_normalized != lower_name {
261                variants.entry(ascii_normalized.clone()).or_insert(index);
262
263                let ascii_no_dots = ascii_normalized.replace('.', "");
264                if ascii_normalized != ascii_no_dots {
265                    variants.entry(ascii_no_dots).or_insert(index);
266                }
267            }
268        }
269
270        variants
271    }
272
273    // The caller has already checked for configured names present in the content.
274    fn find_name_violations(&self, ctx: &crate::lint_context::LintContext) -> Vec<WarningPosition> {
275        let content = ctx.content;
276        // Check if we have cached results
277        let hash = fast_hash(content);
278        {
279            // Use a separate scope for borrowing to minimize lock time
280            if let Ok(cache) = self.content_cache.lock()
281                && let Some(cached) = cache.get(&hash)
282            {
283                return cached.clone();
284            }
285        }
286
287        let mut violations = Vec::new();
288
289        // Get the regex from global cache
290        let combined_regex = match &self.combined_pattern {
291            Some(pattern) => match get_cached_regex(pattern) {
292                Ok(regex) => regex,
293                Err(_) => return Vec::new(),
294            },
295            None => return Vec::new(),
296        };
297
298        // Attribution is only needed when fields are actually excluded.
299        let field_map = if self.ignore_fields.is_empty() {
300            Vec::new()
301        } else {
302            frontmatter_values::field_map(ctx)
303        };
304
305        // Matches inside a linter directive comment (rumdl, markdownlint, Vale,
306        // remark-lint), whatever lines it spans, are skipped by position so a fix
307        // never rewrites a directive. Prose around the comment is still checked,
308        // and a comment shown in code is an example, not a directive.
309        let directive_comments =
310            crate::inline_config::config_comment_ranges(content, |open| ctx.is_in_code_block_or_span(open));
311
312        let mut wikilink_urls = None;
313
314        // Use ctx.lines for better performance
315        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
316            let line_num = line_idx + 1;
317            let line = line_info.content(ctx.content);
318
319            // Skip code fence lines (```language or ~~~language)
320            let trimmed = line.trim_start();
321            if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
322                continue;
323            }
324
325            // Skip if in code block (when code_blocks = false)
326            if !self.config.code_blocks && line_info.in_code_block {
327                continue;
328            }
329
330            // Skip if in HTML block (when html_elements = false)
331            if !self.config.html_elements && line_info.in_html_block {
332                continue;
333            }
334
335            // Skip HTML comments using pre-computed line flag
336            if !self.config.html_comments && line_info.in_html_comment {
337                continue;
338            }
339
340            // Skip JSX expressions and MDX comments (MDX flavor)
341            if line_info.in_jsx_expression || line_info.in_mdx_comment {
342                continue;
343            }
344
345            // Skip Obsidian comments (Obsidian flavor)
346            if line_info.in_obsidian_comment {
347                continue;
348            }
349
350            // Skip MyST comments, targets and block breaks. A target's label is an
351            // identifier that `{ref}` roles spell exactly, and a block break holds metadata.
352            if line_info.is_myst_structural_line() {
353                continue;
354            }
355
356            // For frontmatter lines, determine offset where checkable value content starts.
357            // YAML keys should not be checked against proper names - only values.
358            let fm_value_offset = if line_info.in_front_matter {
359                frontmatter_values::value_offset(line)
360            } else {
361                0
362            };
363            if fm_value_offset == usize::MAX {
364                continue;
365            }
366            if line_info.in_front_matter
367                && let Some(Some(field)) = field_map.get(line_idx)
368                && self.ignore_fields.contains(field)
369            {
370                continue;
371            }
372            let fm_value_span = if line_info.in_front_matter {
373                frontmatter_values::value_span(line)
374            } else {
375                None
376            };
377
378            // Early return: skip lines that don't contain any potential matches
379            let line_lower = line.to_lowercase();
380            let has_line_matches = self.name_variants.keys().any(|name| line_lower.contains(name));
381
382            if !has_line_matches {
383                continue;
384            }
385
386            // Reuse fallback ranges across all matches on this line.
387            let mut angle_urls = None;
388            let mut fallback_code = None;
389            let mut fallback_urls = None;
390
391            // Use the combined regex to find all matches in one pass
392            for cap in combined_regex.find_iter(line) {
393                let found_name = &line[cap.start()..cap.end()];
394
395                // Check word boundaries manually for Unicode support
396                let start_pos = cap.start();
397                let end_pos = cap.end();
398
399                // Skip matches in the key portion of frontmatter lines
400                if start_pos < fm_value_offset {
401                    continue;
402                }
403
404                // Skip matches inside HTML tag attributes (handles multi-line tags)
405                let byte_pos = line_info.byte_offset + start_pos;
406                if crate::inline_config::offset_in_ranges(&directive_comments, byte_pos) {
407                    continue;
408                }
409                if ctx.is_in_html_tag(byte_pos) {
410                    continue;
411                }
412
413                // Skip matches inside a template shortcode tag. A shortcode is
414                // resolved by name against a template, so `{{% nodejs %}}` names
415                // the shortcode to invoke rather than mentioning a product, and
416                // rewriting it points the invocation at a template that does not
417                // exist. The range covers the tag only, so the Markdown body
418                // between paired tags is still checked.
419                if ctx.is_in_shortcode(byte_pos) {
420                    continue;
421                }
422
423                if !Self::is_at_word_boundary(line, start_pos, true) || !Self::is_at_word_boundary(line, end_pos, false)
424                {
425                    continue; // Not at word boundary
426                }
427
428                if Self::is_file_extension_suffix(line, start_pos, found_name) {
429                    continue;
430                }
431
432                if self.config.whole_words
433                    && ((Self::is_joined_identifier_boundary(line, start_pos, true)
434                        && !Self::is_underscore_emphasis_boundary(ctx, byte_pos, true))
435                        || (Self::is_joined_identifier_boundary(line, end_pos, false)
436                            && !Self::is_underscore_emphasis_boundary(ctx, byte_pos + found_name.len(), false)))
437                {
438                    continue;
439                }
440
441                // Skip if in inline code when code_blocks is false
442                if !self.config.code_blocks {
443                    if ctx.is_in_code_block_or_span(byte_pos) {
444                        continue;
445                    }
446                    // pulldown-cmark doesn't parse markdown syntax inside HTML
447                    // comments, HTML blocks, or frontmatter, so backtick-wrapped
448                    // text isn't detected by is_in_code_block_or_span. Check directly.
449                    if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
450                        && crate::inline_config::offset_in_ranges(
451                            fallback_code.get_or_insert_with(|| Self::backtick_code_ranges(line)),
452                            start_pos,
453                        )
454                    {
455                        continue;
456                    }
457                }
458
459                // Skip if in link URL or reference definition
460                if Self::is_in_link(ctx, byte_pos) {
461                    continue;
462                }
463
464                // MkDocs resolves `[text][identifier]` as an autoref whose identifier is
465                // case-sensitive, so the label of an undefined full reference is a name.
466                if ctx.is_in_unresolved_autoref_identifier(byte_pos) {
467                    continue;
468                }
469
470                // Skip if inside an angle-bracket URL (e.g., <https://...>)
471                // The link parser skips autolinks inside HTML comments,
472                // so we detect them directly in the line text.
473                if crate::inline_config::offset_in_ranges(
474                    angle_urls.get_or_insert_with(|| Self::angle_bracket_url_ranges(line)),
475                    start_pos,
476                ) {
477                    continue;
478                }
479
480                // Skip if inside a Markdown inline link URL in contexts where
481                // pulldown-cmark doesn't parse Markdown syntax (HTML comments,
482                // HTML blocks, frontmatter).
483                if (line_info.in_html_comment || line_info.in_html_block || line_info.in_front_matter)
484                    && crate::inline_config::offset_in_ranges(
485                        fallback_urls.get_or_insert_with(|| Self::markdown_link_url_ranges(line)),
486                        start_pos,
487                    )
488                {
489                    continue;
490                }
491
492                // Skip if inside the URL portion of a WikiLink followed by a
493                // parenthesised destination — [[text]](url). pulldown-cmark
494                // registers [[text]] as a WikiLink in ctx.links() but leaves the
495                // (url) as plain text, so is_in_link() misses those bytes.
496                if crate::inline_config::offset_in_ranges(
497                    wikilink_urls.get_or_insert_with(|| Self::wikilink_url_ranges(ctx)),
498                    byte_pos,
499                ) {
500                    continue;
501                }
502
503                // Skip if inside a bare URL (https://foo.com in plain prose).
504                // Bare URLs are not in ctx.links() (flagging them is MD034's
505                // domain), but a URL is still a URL: domains match
506                // case-insensitively but paths are case-sensitive, so a
507                // proper-name "fix" inside one can break the link.
508                if ctx.is_in_bare_url(byte_pos) {
509                    continue;
510                }
511
512                // Skip if inside a file path within a frontmatter value. Domains
513                // match case-insensitively but paths are case-sensitive, so
514                // rewriting a name inside one breaks the reference it points at.
515                // Body prose is deliberately never consulted here; see
516                // `is_in_path_like_token` for why the exemption stops at the
517                // frontmatter boundary.
518                if let Some(fm_value) = fm_value_span
519                    && Self::is_in_path_like_token(line, start_pos, fm_value)
520                {
521                    continue;
522                }
523
524                // Find which proper name this matches
525                if let Some(&proper_name_index) = self.name_variants.get(&found_name.to_lowercase()) {
526                    // Only flag if it's not already correct
527                    if found_name != self.config.names[proper_name_index] {
528                        violations.push((line_num, cap.start() + 1, found_name.to_string(), proper_name_index));
529                    }
530                }
531            }
532        }
533
534        // Store in cache (ignore if mutex is poisoned)
535        if let Ok(mut cache) = self.content_cache.lock() {
536            cache.insert(hash, violations.clone());
537        }
538        violations
539    }
540
541    /// Check if a byte position is within a link URL (not link text)
542    ///
543    /// Link text should be checked for proper names, but URLs should be skipped.
544    /// For `[text](url)` - check text, skip url
545    /// For `[text][ref]` - check text, skip reference portion
546    /// For `[[page]]` (WikiLinks) - check the page name, skip brackets
547    /// For `[[page|text]]` - check text, skip the page name it resolves to
548    fn is_in_link(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
549        use pulldown_cmark::LinkType;
550
551        if let Some(link) = ctx.link_containing(byte_pos) {
552            // WikiLinks [[text]] start with '[[', regular links [text] start with '['
553            let (text_start, text_end) = if matches!(link.link_type, LinkType::WikiLink { .. }) {
554                // The displayed part of a wikilink runs to the closing `]]`, and
555                // starts after the pipe when there is one: in `[[target|display]]`
556                // the target is a reference like a URL, and renaming it changes
557                // what the link resolves to without changing what a reader sees.
558                // Measuring from the start instead would put the window over the
559                // target, which is what used to happen.
560                let span = &ctx.content[link.byte_offset..link.byte_end];
561                let start = match span.find('|') {
562                    Some(pipe) => link.byte_offset + pipe + 1,
563                    None => link.byte_offset + 2,
564                };
565                (start, link.byte_end.saturating_sub(2))
566            } else {
567                let start = link.byte_offset + 1;
568                (start, start + link.text.len())
569            };
570
571            // If position is within the text portion, skip only if text is a URL.
572            // WikiLinks use the page name as both text and url; never treat them
573            // as bare-domain URLs regardless of whether the name contains dots.
574            if byte_pos >= text_start && byte_pos < text_end {
575                let is_wikilink = matches!(link.link_type, LinkType::WikiLink { .. });
576                if Self::link_text_is_url(&link.text)
577                    || (!is_wikilink && Self::link_text_matches_link_url(&link.text, &link.url))
578                {
579                    return true;
580                }
581                // Displayed text can hold an image, and that image's destination is
582                // a URL like any other: renaming inside `[[Target|see
583                // ![alt](image.png)]]` breaks the image. Nested *links* need no such
584                // handling, since they are in `ctx.links()` too and the search above
585                // already picks the innermost one.
586                return Self::image_verdict(ctx, byte_pos).unwrap_or(false);
587            }
588            // Position is in the URL/reference portion, skip it
589            return true;
590        }
591
592        if let Some(verdict) = Self::image_verdict(ctx, byte_pos) {
593            return verdict;
594        }
595
596        // Check pre-computed reference definitions
597        ctx.is_in_reference_def(byte_pos)
598    }
599
600    /// Whether a byte position inside an image should be skipped.
601    ///
602    /// `Some(true)` for the URL or reference portion, `Some(false)` for the alt
603    /// text, and `None` when the position is in no image at all.
604    fn image_verdict(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> Option<bool> {
605        let image = ctx.image_containing(byte_pos)?;
606
607        // Image starts with '![' so alt text starts at byte_offset + 2
608        let alt_start = image.byte_offset + 2;
609        let alt_end = alt_start + image.alt_text.len();
610
611        // Inside the alt text, don't skip; otherwise this is the URL or reference
612        Some(!(byte_pos >= alt_start && byte_pos < alt_end))
613    }
614
615    /// Check if link text is a URL that should not have proper name corrections.
616    fn link_text_is_url(text: &str) -> bool {
617        let lower = text.trim().to_ascii_lowercase();
618        lower.starts_with("http://")
619            || lower.starts_with("https://")
620            || lower.starts_with("www.")
621            || lower.starts_with("//")
622    }
623
624    /// Check if link text is the bare hostname/path of its destination URL.
625    ///
626    /// When the display text is the URL with the scheme stripped (e.g.,
627    /// `[example.github.io](https://example.github.io)`), the text is a domain
628    /// label, not a prose reference to a product, and should not be corrected.
629    ///
630    /// Requires the text to contain a dot, which distinguishes domain-like display
631    /// text from single-word WikiLink targets (e.g. `[[javascript]]`) where
632    /// `url == text` but neither is a domain name. Dotted WikiLink targets are
633    /// excluded separately via the `!is_wikilink` guard in `is_in_link`. Comparison
634    /// is case-insensitive because URL schemes and hostnames are case-insensitive.
635    fn link_text_matches_link_url(text: &str, url: &str) -> bool {
636        let text = text.trim();
637        // Only domain-like text (containing a dot) can be a bare hostname.
638        if !text.contains('.') {
639            return false;
640        }
641        let url_lower = url.to_ascii_lowercase();
642        let url_without_scheme = url_lower
643            .strip_prefix("https://")
644            .or_else(|| url_lower.strip_prefix("http://"))
645            .or_else(|| url_lower.strip_prefix("//"))
646            .unwrap_or(&url_lower);
647        let text_lower = text.to_ascii_lowercase();
648        // Exact match: text equals the URL with the scheme removed.
649        if url_without_scheme == text_lower.as_str() {
650            return true;
651        }
652        // Prefix match: text is the hostname portion and the URL has a path/query/fragment.
653        url_without_scheme.len() > text_lower.len()
654            && url_without_scheme.starts_with(text_lower.as_str())
655            && matches!(
656                url_without_scheme.as_bytes().get(text_lower.len()),
657                Some(b'/') | Some(b'?') | Some(b'#')
658            )
659    }
660
661    /// Collect disjoint, source-ordered angle-bracket URL ranges (`<scheme://...>`).
662    ///
663    /// The link parser skips autolinks inside HTML comments, so `ctx.links()` won't
664    /// contain them. This function detects angle-bracket URLs directly in the line
665    /// text, covering both HTML comments and regular text as a safety net.
666    fn angle_bracket_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
667        let mut ranges = Vec::new();
668        let bytes = line.as_bytes();
669        let len = bytes.len();
670        let mut i = 0;
671        while i < len {
672            if bytes[i] == b'<' {
673                let after_open = i + 1;
674                // Check for a valid URI scheme per CommonMark autolink spec:
675                // scheme = [a-zA-Z][a-zA-Z0-9+.-]{0,31}
676                // followed by ':'
677                if after_open < len && bytes[after_open].is_ascii_alphabetic() {
678                    let mut s = after_open + 1;
679                    let scheme_max = (after_open + 32).min(len);
680                    while s < scheme_max
681                        && (bytes[s].is_ascii_alphanumeric()
682                            || bytes[s] == b'+'
683                            || bytes[s] == b'-'
684                            || bytes[s] == b'.')
685                    {
686                        s += 1;
687                    }
688                    if s < len && bytes[s] == b':' {
689                        // Valid scheme found; scan for closing '>' with no spaces or '<'
690                        let mut j = s + 1;
691                        let mut found_close = false;
692                        while j < len {
693                            match bytes[j] {
694                                b'>' => {
695                                    found_close = true;
696                                    break;
697                                }
698                                b' ' | b'<' => break,
699                                _ => j += 1,
700                            }
701                        }
702                        if found_close {
703                            ranges.push(i..j + 1);
704                            i = j + 1;
705                            continue;
706                        }
707                    }
708                }
709            }
710            i += 1;
711        }
712        ranges
713    }
714
715    #[cfg(test)]
716    fn is_in_angle_bracket_url(line: &str, pos: usize) -> bool {
717        crate::inline_config::offset_in_ranges(&Self::angle_bracket_url_ranges(line), pos)
718    }
719
720    /// Destinations immediately following parsed WikiLinks. The parser leaves
721    /// their parentheses as prose, so ordinary link ranges do not cover them.
722    /// Scan once for all requested openers, preserving the fallback's raw-byte
723    /// nesting, backslash escapes and rejection of unescaped whitespace.
724    fn wikilink_url_ranges(ctx: &crate::lint_context::LintContext) -> Vec<std::ops::Range<usize>> {
725        use pulldown_cmark::LinkType;
726        let content = ctx.content.as_bytes();
727        let starts: HashSet<_> = ctx
728            .links()
729            .iter()
730            .filter(|link| matches!(link.link_type, LinkType::WikiLink { .. }))
731            .map(|link| link.byte_end)
732            .filter(|&end| content.get(end) == Some(&b'('))
733            .collect();
734        let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
735        let Some(mut pos) = starts.iter().min().copied() else {
736            return ranges;
737        };
738        let last_start = *starts.iter().max().unwrap();
739        // Only requested destinations need stack entries; other parentheses
740        // contribute to depth without allocating per nesting level.
741        let mut stack = Vec::new();
742        let mut depth = 0usize;
743        while pos < content.len() {
744            match content[pos] {
745                b'\\' => pos += 1,
746                b'(' => {
747                    depth += 1;
748                    if starts.contains(&pos) {
749                        stack.push((pos, depth));
750                    }
751                }
752                b')' => {
753                    if stack.last().is_some_and(|&(_, level)| level == depth) {
754                        let (open, _) = stack.pop().unwrap();
755                        // Balanced destinations are nested or disjoint. An outer
756                        // destination subsumes the inner ranges at the end of the
757                        // vector, leaving source-ordered disjoint ranges for lookup.
758                        while ranges.last().is_some_and(|range| range.start > open) {
759                            ranges.pop();
760                        }
761                        ranges.push(open + 1..pos + 1);
762                    }
763                    depth = depth.saturating_sub(1);
764                }
765                // Every still-open destination crosses this whitespace and is invalid.
766                b' ' | b'\t' | b'\n' | b'\r' => {
767                    stack.clear();
768                    depth = 0;
769                }
770                _ => {}
771            }
772            if pos >= last_start && stack.is_empty() {
773                break;
774            }
775            pos += 1;
776        }
777        ranges
778    }
779
780    /// Collect a line's Markdown link destinations and reference labels.
781    ///
782    /// Used as a text-level fallback for HTML comments, HTML blocks, and
783    /// frontmatter where pulldown-cmark skips link parsing entirely. Operates on
784    /// raw line bytes and therefore cannot distinguish real links from text that
785    /// merely resembles link syntax; do not call on regular markdown lines.
786    /// - `[text](url)` — bytes after `(` through the closing `)`.
787    /// - `[text][ref]` — bytes after the second `[` through its closing `]`.
788    fn markdown_link_url_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
789        let mut ranges = Vec::new();
790        let bytes = line.as_bytes();
791        let mut brackets = FallbackDelimiters::default();
792        let mut parentheses = FallbackDelimiters::default();
793        let mut i = 0;
794        while i < bytes.len() {
795            // Preserve the fallback's existing treatment of double escapes.
796            if bytes[i] == b'['
797                && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\'))
798                && let Some(j) = brackets.find(bytes, i + 1, b'[', b']')
799            {
800                let end = match bytes.get(j) {
801                    Some(b'(') => parentheses.find(bytes, j + 1, b'(', b')'),
802                    Some(b'[') => brackets.find(bytes, j + 1, b'[', b']'),
803                    _ => None,
804                };
805                if let Some(end) = end {
806                    ranges.push(j + 1..end);
807                    i = end;
808                    continue;
809                }
810            }
811            i += 1;
812        }
813        ranges
814    }
815
816    /// Collect content ranges of backtick-delimited code on a line.
817    ///
818    /// pulldown-cmark does not parse markdown syntax inside HTML comments, so
819    /// `ctx.is_in_code_block_or_span` returns false for backtick-wrapped text
820    /// within comments. This function detects backtick code spans directly in
821    /// the line text, looking for matching runs of exactly the same length.
822    fn backtick_code_ranges(line: &str) -> Vec<std::ops::Range<usize>> {
823        let mut ranges = Vec::new();
824        let bytes = line.as_bytes();
825        let len = bytes.len();
826        let mut i = 0;
827        while i < len {
828            if bytes[i] == b'`' {
829                // Count the opening backtick sequence length
830                let open_start = i;
831                while i < len && bytes[i] == b'`' {
832                    i += 1;
833                }
834                let tick_len = i - open_start;
835
836                // Scan forward for a closing sequence of exactly tick_len backticks
837                while i < len {
838                    if bytes[i] == b'`' {
839                        let close_start = i;
840                        while i < len && bytes[i] == b'`' {
841                            i += 1;
842                        }
843                        if i - close_start == tick_len {
844                            // Matched pair found; the code span content is between
845                            // the end of the opening backticks and the start of the
846                            // closing backticks (exclusive of the backticks themselves).
847                            let content_start = open_start + tick_len;
848                            let content_end = close_start;
849                            ranges.push(content_start..content_end);
850                            // Continue scanning after this pair
851                            break;
852                        }
853                        // Not the right length; keep scanning
854                    } else {
855                        i += 1;
856                    }
857                }
858            } else {
859                i += 1;
860            }
861        }
862        ranges
863    }
864
865    #[cfg(test)]
866    fn is_in_backtick_code_in_line(line: &str, pos: usize) -> bool {
867        crate::inline_config::offset_in_ranges(&Self::backtick_code_ranges(line), pos)
868    }
869
870    // Check if a character is a word boundary (handles Unicode)
871    fn is_word_boundary_char(c: char) -> bool {
872        !c.is_alphanumeric()
873    }
874
875    // Check if position is at a word boundary using byte-level lookups.
876    fn is_at_word_boundary(content: &str, pos: usize, is_start: bool) -> bool {
877        if is_start {
878            if pos == 0 {
879                return true;
880            }
881            match content[..pos].chars().next_back() {
882                None => true,
883                Some(c) => Self::is_word_boundary_char(c),
884            }
885        } else {
886            if pos >= content.len() {
887                return true;
888            }
889            match content[pos..].chars().next() {
890                None => true,
891                Some(c) => Self::is_word_boundary_char(c),
892            }
893        }
894    }
895
896    /// Preserve a name immediately after a single dot, as in `favorite.toml`
897    /// or `.toml`. Check only the match and its immediate prefix: scanning for
898    /// a whole filename would collide with Markdown punctuation. The stem
899    /// stays checkable, and `node.js` still matches the whole name `Node.js`.
900    fn is_file_extension_suffix(line: &str, start: usize, name: &str) -> bool {
901        let Some(prefix) = line[..start].strip_suffix('.') else {
902            return false;
903        };
904
905        // An ellipsis can introduce prose without a space. Multiword names
906        // and names with other punctuation (such as `.NET`) are not suffixes.
907        !prefix.ends_with('.')
908            && name.starts_with(char::is_alphanumeric)
909            && name.ends_with(char::is_alphanumeric)
910            && name
911                .chars()
912                .all(|character| character.is_alphanumeric() || matches!(character, '.' | '-' | '_'))
913    }
914
915    /// Whether `pos` is separated from another Unicode-alphanumeric
916    /// identifier component by a run of `-` and/or `_`.
917    ///
918    /// Markdown emphasis is handled separately using the parser's emphasis
919    /// spans; raw separator scanning alone cannot distinguish `foo__name__bar`
920    /// (literal intraword underscores) from `foo-__name__-bar` (strong
921    /// emphasis between identifier-looking punctuation).
922    fn is_joined_identifier_boundary(content: &str, pos: usize, is_start: bool) -> bool {
923        if is_start {
924            let mut chars = content[..pos].char_indices().rev();
925            let mut saw_separator = false;
926            for (_, character) in &mut chars {
927                if character == '-' || character == '_' {
928                    saw_separator = true;
929                } else {
930                    return saw_separator && character.is_alphanumeric();
931                }
932            }
933            false
934        } else {
935            let mut saw_separator = false;
936            for character in content[pos..].chars() {
937                if character == '-' || character == '_' {
938                    saw_separator = true;
939                } else {
940                    return saw_separator && character.is_alphanumeric();
941                }
942            }
943            false
944        }
945    }
946
947    /// Whether this exact boundary touches an underscore emphasis delimiter.
948    /// Being anywhere inside emphasis is not enough: `_foo_abc_bar_` still
949    /// contains an identifier, whereas `foo-__abc__-bar` emphasizes just `abc`.
950    /// Check the two boundaries independently so `_abc_def_` remains protected.
951    fn is_underscore_emphasis_boundary(ctx: &crate::lint_context::LintContext, pos: usize, is_start: bool) -> bool {
952        ctx.source_emphasis_spans().iter().any(|span| {
953            if span.marker != '_' {
954                return false;
955            }
956            let width = if span.is_strong { 2 } else { 1 };
957            if is_start {
958                span.byte_offset + width == pos
959            } else {
960                span.byte_end.checked_sub(width) == Some(pos)
961            }
962        })
963    }
964
965    /// Whether the match at `match_start` sits inside a file path, which must
966    /// not be rewritten. `fm_value` is the semantic value span of the
967    /// frontmatter line the match was found on.
968    ///
969    /// This exemption is deliberately scoped to frontmatter values only and
970    /// is never applied to body prose. In frontmatter the value span is known
971    /// exactly (`frontmatter_values::value_span`), so token bounds can be clamped to
972    /// it with no risk of crossing into unrelated syntax. Body prose has no
973    /// such known span: a token there must be delimited by scanning the raw
974    /// line for whitespace and Markdown punctuation, and that scan
975    /// unavoidably collides with Markdown link/image/wikilink syntax (see the
976    /// module-level history of defects from trying this). Reusing this
977    /// function for body text is a structural mismatch, not a missing edge
978    /// case, so `fm_value` is required rather than optional: a caller cannot
979    /// accidentally invoke this for a line that has no known value span.
980    ///
981    /// A slash is mandatory for exempting a whole path token: a blanket
982    /// dotted-token exemption would swallow proper names such as `Node.js`.
983    /// Extension suffixes are protected separately without exempting the stem.
984    ///
985    /// The 3+ segment sole-value signal only fires for a genuinely
986    /// single-token value. A quoted value that collapsed from several
987    /// whitespace-separated words (`"myapp/gitlab github/bitbucket"`) must
988    /// instead satisfy the path-prefix or file-extension signal; otherwise
989    /// two unrelated slash-pairs joined by a space would vacuously look like
990    /// a 3-segment path. This means an extensionless path containing a
991    /// literal space (`docs/My App/myapp`) is no longer exempt, a deliberate
992    /// narrowing rather than an oversight.
993    fn is_in_path_like_token(line: &str, match_start: usize, fm_value: (usize, usize)) -> bool {
994        let (value_start, value_end) = fm_value;
995        if match_start < value_start || match_start >= value_end {
996            return false;
997        }
998
999        // A quoted scalar is one token even with spaces in it, but only when
1000        // every whitespace-separated word in it carries a slash (e.g. a path
1001        // containing a space, `docs/My App/myapp`). A quoted sentence with
1002        // ordinary prose words (`"We support github/gitlab/bitbucket now"`)
1003        // falls back to per-word tokenization instead, otherwise quoting
1004        // alone would make the token span the whole value and vacuously
1005        // satisfy the sole-value check below.
1006        let quoted_words: Vec<&str> = if frontmatter_values::value_is_quoted(line, value_start) {
1007            line[value_start..value_end].split_whitespace().collect()
1008        } else {
1009            Vec::new()
1010        };
1011        let is_single_quoted_path = !quoted_words.is_empty() && quoted_words.iter().all(|word| word.contains('/'));
1012        // Collapsing several whitespace-separated words into one token is
1013        // only safe evidence for signals (a) and (b): a shared slash prefix
1014        // or a real extension on the last segment. It is not evidence for
1015        // the segment-count signal below, which assumes a single word split
1016        // into path segments by '/'; two unrelated slash-pairs joined by a
1017        // space (`"myapp/gitlab github/bitbucket"`) would otherwise satisfy
1018        // that count vacuously.
1019        let is_multi_word_collapse = is_single_quoted_path && quoted_words.len() > 1;
1020
1021        let (raw_start, raw_end) = if is_single_quoted_path {
1022            (value_start, value_end)
1023        } else {
1024            frontmatter_values::token_bounds(line, match_start, value_start, value_end)
1025        };
1026
1027        let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
1028        if match_start < start || match_start >= end {
1029            return false;
1030        }
1031
1032        let token = &line[start..end];
1033        if !token.contains('/') {
1034            return false;
1035        }
1036        if token.starts_with('/') || token.starts_with("./") || token.starts_with("../") || token.starts_with("~/") {
1037            return true;
1038        }
1039        if token.rsplit('/').next().is_some_and(|seg| seg.contains('.')) {
1040            return true;
1041        }
1042
1043        if is_multi_word_collapse {
1044            return false;
1045        }
1046
1047        // Three or more segments is only a path signal when the token is the
1048        // entire frontmatter value. In prose, `github/gitlab/bitbucket` is
1049        // shorthand, not a path.
1050        let sole_value = {
1051            let (ts, te) = frontmatter_values::trim_token_bounds(line, value_start, value_end);
1052            ts == start && te == end
1053        };
1054        sole_value && token.split('/').filter(|s| !s.is_empty()).count() >= 3
1055    }
1056}
1057
1058impl Rule for MD044ProperNames {
1059    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
1060        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(44))
1061    }
1062
1063    fn name(&self) -> &'static str {
1064        "MD044"
1065    }
1066
1067    fn description(&self) -> &'static str {
1068        "Proper names should have the correct capitalization"
1069    }
1070
1071    fn category(&self) -> RuleCategory {
1072        RuleCategory::Other
1073    }
1074
1075    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1076        if self.config.names.is_empty() {
1077            return true;
1078        }
1079        // Quick check if any configured name variants exist (case-insensitive)
1080        let content_lower = if ctx.content.is_ascii() {
1081            ctx.content.to_ascii_lowercase()
1082        } else {
1083            ctx.content.to_lowercase()
1084        };
1085        !self.name_variants.keys().any(|name| content_lower.contains(name))
1086    }
1087
1088    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1089        let content = ctx.content;
1090        if content.is_empty() || self.config.names.is_empty() || self.combined_pattern.is_none() {
1091            return Ok(Vec::new());
1092        }
1093
1094        // Compute lowercase content once and reuse across all checks
1095        let content_lower = if content.is_ascii() {
1096            content.to_ascii_lowercase()
1097        } else {
1098            content.to_lowercase()
1099        };
1100
1101        // Early return: use pre-computed name_variants for the quick check
1102        let has_potential_matches = self.name_variants.keys().any(|name| content_lower.contains(name));
1103
1104        if !has_potential_matches {
1105            return Ok(Vec::new());
1106        }
1107        let violations = self.find_name_violations(ctx);
1108
1109        let mut positions = SourcePositions::new(content, &ctx.lines);
1110        let warnings = violations
1111            .into_iter()
1112            .map(|(line, column, found_name, proper_name_index)| {
1113                let proper_name = &self.config.names[proper_name_index];
1114                // `column` is a 1-indexed byte offset into the line (from regex .start() + 1).
1115                // Build the Fix range directly in bytes to avoid the character-based
1116                // line_col_to_byte_range_with_length function, which would misinterpret
1117                // the byte offset as a character count on lines with multi-byte content.
1118                let line_start = ctx.line_start_byte(line).unwrap_or(0);
1119                let byte_start = line_start + (column - 1);
1120                let byte_end = byte_start + found_name.len();
1121                // The displayed columns are character offsets; convert from the byte
1122                // offset within the line so they are correct on multi-byte lines.
1123                let char_col = positions.column_on_line(line - 1, byte_start) + 1;
1124                LintWarning {
1125                    rule_name: Some(self.name().to_string()),
1126                    line,
1127                    column: char_col,
1128                    end_line: line,
1129                    end_column: char_col + found_name.chars().count(),
1130                    message: format!("Proper name '{found_name}' should be '{proper_name}'"),
1131                    severity: Severity::Warning,
1132                    fix: Some(Fix::new(byte_start..byte_end, proper_name.clone())),
1133                }
1134            })
1135            .collect();
1136
1137        Ok(warnings)
1138    }
1139
1140    fn fixes_from_diagnostics(&self) -> bool {
1141        true
1142    }
1143
1144    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1145        if self.should_skip(ctx) {
1146            return Ok(ctx.content.to_string());
1147        }
1148        let warnings = self.check(ctx)?;
1149        if warnings.is_empty() {
1150            return Ok(ctx.content.to_string());
1151        }
1152        let warnings =
1153            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1154        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
1155            .map_err(crate::rule::LintError::InvalidInput)
1156    }
1157
1158    fn as_any(&self) -> &dyn std::any::Any {
1159        self
1160    }
1161
1162    crate::impl_rule_config_methods!(MD044Config);
1163}
1164
1165#[cfg(test)]
1166mod tests {
1167    use super::*;
1168    use crate::lint_context::LintContext;
1169
1170    fn create_context(content: &str) -> LintContext<'_> {
1171        LintContext::new(content, crate::config::MarkdownFlavor::Standard, None)
1172    }
1173
1174    fn field_map_for(content: &str) -> Vec<Option<String>> {
1175        let ctx = create_context(content);
1176        frontmatter_values::field_map(&ctx)
1177    }
1178
1179    fn whole_word_rule(names: &[&str]) -> MD044ProperNames {
1180        MD044ProperNames::from_config_struct(MD044Config {
1181            names: names.iter().map(|name| (*name).to_string()).collect(),
1182            whole_words: true,
1183            ..Default::default()
1184        })
1185    }
1186
1187    #[test]
1188    fn file_extension_suffixes_preserve_case_but_prose_is_checked() {
1189        let content = "I like toml in my favorite.toml file.\n\
1190                       Extensions: .toml (.toml) [.toml] \".toml\" '.toml' “.toml” ‘.toml’.\n\
1191                       Files: config.toml.example v2.toml café.toml my_conf.toml foo-.toml file(1).toml.\n\
1192                       Patterns: *.toml **.toml** _.toml_ /.toml \\.toml.\n\
1193                       Next.js uses js, and toml is a format.\n";
1194        let expected = content
1195            .replacen("I like toml", "I like TOML", 1)
1196            .replace("uses js, and toml", "uses JS, and TOML");
1197
1198        for whole_words in [false, true] {
1199            let rule = MD044ProperNames::from_config_struct(MD044Config {
1200                names: vec!["TOML".to_string(), "JS".to_string()],
1201                whole_words,
1202                ..Default::default()
1203            });
1204            let ctx = create_context(content);
1205            assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1206            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1207            assert!(rule.check(&create_context(&expected)).unwrap().is_empty());
1208            assert_eq!(rule.fix(&create_context(&expected)).unwrap(), expected);
1209        }
1210    }
1211
1212    #[test]
1213    fn file_extensions_keep_dotted_names_stems_and_function_calls_checkable() {
1214        let rule = MD044ProperNames::new(
1215            ["TOML", "Oklch", "Node.js", "MyApp", ".NET", "GitHub"]
1216                .map(str::to_string)
1217                .to_vec(),
1218            false,
1219        );
1220        let content = "Use node.js and .net for myapp.\n\
1221                       See docs/myapp.md and docs/myapp(1).md.\n\
1222                       Oklch syntax is oklch(.5 .5 255); github(tm) is a reference.\n\
1223                       I like TOML in my favorite.toml file.\n\
1224                       Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1225        let expected = "Use Node.js and .NET for MyApp.\n\
1226                        See docs/MyApp.md and docs/MyApp(1).md.\n\
1227                        Oklch syntax is Oklch(.5 .5 255); GitHub(tm) is a reference.\n\
1228                        I like TOML in my favorite.toml file.\n\
1229                        Use `favorite.toml` and `oklch(.5 .5 255)`.\n";
1230        let ctx = create_context(content);
1231        assert_eq!(rule.check(&ctx).unwrap().len(), 7);
1232        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1233        assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1234    }
1235
1236    #[test]
1237    fn file_extensions_do_not_exempt_ellipsis_or_multiword_prose() {
1238        let rule = MD044ProperNames::new(vec!["TOML".to_string(), "My App".to_string()], false);
1239        let content = "Wait...toml or …toml; e.g. toml and file. toml.\n\
1240                       Sentence.my app is still prose.\n";
1241        let expected = "Wait...TOML or …TOML; e.g. TOML and file. TOML.\n\
1242                        Sentence.My App is still prose.\n";
1243        let ctx = create_context(content);
1244        assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1245        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1246    }
1247
1248    #[test]
1249    fn file_extension_syntax_applies_to_dotted_tokens_and_html_prose() {
1250        let rule = MD044ProperNames::new(["TOML", "Node.js", "GitHub"].map(str::to_string).to_vec(), false);
1251        // A single dot is a syntax cue, not proof of a filename. In particular,
1252        // missing a space after a sentence period can hide a capitalization error.
1253        let content = ".github api.node.js api.github.com i.e.toml\n\
1254                       node.js github and toml\n\
1255                       <!-- favorite.toml toml -->\n\
1256                       <div>favorite.toml toml</div>\n";
1257        let expected = ".github api.node.js api.github.com i.e.toml\n\
1258                        Node.js GitHub and TOML\n\
1259                        <!-- favorite.toml TOML -->\n\
1260                        <div>favorite.toml TOML</div>\n";
1261        let ctx = create_context(content);
1262        assert_eq!(rule.check(&ctx).unwrap().len(), 5);
1263        assert_eq!(rule.fix(&ctx).unwrap(), expected);
1264        assert!(rule.check(&create_context(expected)).unwrap().is_empty());
1265    }
1266
1267    #[test]
1268    fn file_extensions_in_markdown_preserve_suffixes_and_check_prose() {
1269        let content = "**favorite.toml** *favorite.toml* _favorite.toml_\n\
1270                       [favorite.toml](other.md) [toml](favorite.toml)\n\
1271                       ![favorite.toml](img.png) ![toml](img.toml)\n\
1272                       [[favorite.toml]] [[Page|favorite.toml]] [[Page|toml]]\n\
1273                       `favorite.toml toml`\n\
1274                       ```text\n\
1275                       favorite.toml toml\n\
1276                       ```\n";
1277        for code_blocks in [false, true] {
1278            let rule = MD044ProperNames::new(vec!["TOML".to_string()], code_blocks);
1279            let mut expected = content.replace("[toml]", "[TOML]").replace("|toml]]", "|TOML]]");
1280            if code_blocks {
1281                expected = expected.replace("favorite.toml toml", "favorite.toml TOML");
1282            }
1283            for flavor in [
1284                crate::config::MarkdownFlavor::Standard,
1285                crate::config::MarkdownFlavor::Obsidian,
1286            ] {
1287                let ctx = LintContext::new(content, flavor, None);
1288                assert_eq!(rule.check(&ctx).unwrap().len(), if code_blocks { 5 } else { 3 });
1289                assert_eq!(rule.fix(&ctx).unwrap(), expected);
1290            }
1291        }
1292    }
1293
1294    #[test]
1295    fn file_extensions_in_frontmatter_preserve_suffixes_and_check_prose() {
1296        let rule = MD044ProperNames::new(["TOML", "Node.js", "MyApp"].map(str::to_string).to_vec(), false);
1297        for content in [
1298            "---\ntoml: favorite.toml\ntitle: node.js guide to toml\nimage: myapp.png\npath: docs/myapp.md\n---\n",
1299            "+++\ntoml = \"favorite.toml\"\ntitle = \"node.js guide to toml\"\nimage = \"myapp.png\"\npath = \"docs/myapp.md\"\n+++\n",
1300        ] {
1301            let expected = content
1302                .replace("node.js guide to toml", "Node.js guide to TOML")
1303                .replace("myapp.png", "MyApp.png");
1304            let ctx = create_context(content);
1305            assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1306            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1307        }
1308    }
1309
1310    #[test]
1311    fn whole_words_preserves_compound_identifiers_but_checks_standalone_names() {
1312        let rule = whole_word_rule(&["ABC"]);
1313        let content = "123-abc-def\nfoo_abc_bar\nabc\n(abc) [abc]\n";
1314        let ctx = create_context(content);
1315
1316        let warnings = rule.check(&ctx).unwrap();
1317        assert_eq!(warnings.len(), 3);
1318        assert_eq!(
1319            warnings.iter().map(|warning| warning.line).collect::<Vec<_>>(),
1320            [3, 4, 4]
1321        );
1322        assert_eq!(rule.fix(&ctx).unwrap(), "123-abc-def\nfoo_abc_bar\nABC\n(ABC) [ABC]\n");
1323    }
1324
1325    #[test]
1326    fn whole_words_preserves_identifiers_inside_emphasis() {
1327        let rule = whole_word_rule(&["ABC"]);
1328        let content = "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1329                       _abc_def_ _foo_abc_\n\
1330                       _abc_ __abc__ ___abc___ foo-__abc__-bar\n";
1331        let ctx = create_context(content);
1332        let warnings = rule.check(&ctx).unwrap();
1333        assert_eq!(warnings.len(), 4);
1334        assert!(warnings.iter().all(|warning| warning.line == 3));
1335        let fixed = rule.fix(&ctx).unwrap();
1336        assert_eq!(
1337            fixed,
1338            "_123-abc-def_ _foo_abc_bar_ __foo__abc__bar__\n\
1339             _abc_def_ _foo_abc_\n\
1340             _ABC_ __ABC__ ___ABC___ foo-__ABC__-bar\n"
1341        );
1342        assert!(rule.check(&create_context(&fixed)).unwrap().is_empty());
1343        assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1344    }
1345
1346    #[test]
1347    fn whole_words_distinguishes_unicode_compounds_from_markdown_emphasis() {
1348        let rule = whole_word_rule(&["ABC"]);
1349        let content = "café_abc_menu 東京-abc-駅 foo__abc__bar _abc_ __abc__ foo-__abc__-bar\n";
1350        let ctx = create_context(content);
1351
1352        assert_eq!(rule.check(&ctx).unwrap().len(), 3);
1353        let fixed = rule.fix(&ctx).unwrap();
1354        assert_eq!(
1355            fixed,
1356            "café_abc_menu 東京-abc-駅 foo__abc__bar _ABC_ __ABC__ foo-__ABC__-bar\n"
1357        );
1358        assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1359    }
1360
1361    #[test]
1362    fn repeated_intraword_underscores_keep_legacy_behavior_without_whole_words() {
1363        let rule = MD044ProperNames::new(vec!["ABC".to_string()], false);
1364        let content = "foo__abc__bar\n";
1365        let ctx = create_context(content);
1366
1367        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1368        assert_eq!(rule.fix(&ctx).unwrap(), "foo__ABC__bar\n");
1369    }
1370
1371    #[test]
1372    fn whole_words_respects_code_and_link_exclusions_and_multiword_names() {
1373        let rule = whole_word_rule(&["ABC", "My App"]);
1374        let content = "`abc` [label](https://example.test/abc) my app x-my app-y\n";
1375        let ctx = create_context(content);
1376
1377        let warnings = rule.check(&ctx).unwrap();
1378        assert_eq!(warnings.len(), 1);
1379        assert_eq!(warnings[0].message, "Proper name 'my app' should be 'My App'");
1380        let fixed = rule.fix(&ctx).unwrap();
1381        assert_eq!(fixed, "`abc` [label](https://example.test/abc) My App x-my app-y\n");
1382        assert_eq!(rule.fix(&create_context(&fixed)).unwrap(), fixed);
1383    }
1384
1385    #[test]
1386    fn first_configured_spelling_wins_for_colliding_variants() {
1387        for (names, content, expected) in [
1388            (["Node.js", "Nodejs"], "nodejs", "Node.js"),
1389            (["Nodejs", "Node.js"], "nodejs", "Nodejs"),
1390            (["Café", "Cafe"], "cafe", "Café"),
1391            (["Cafe", "Café"], "cafe", "Cafe"),
1392            (["Node.js", "NODE.JS"], "node.js", "Node.js"),
1393        ] {
1394            let rule = MD044ProperNames::new(names.map(str::to_string).to_vec(), false);
1395            let ctx = create_context(content);
1396            let warnings = rule.check(&ctx).unwrap();
1397            assert_eq!(warnings.len(), 1);
1398            assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, expected);
1399            assert_eq!(rule.fix(&ctx).unwrap(), expected);
1400            assert_eq!(rule.clone().check(&ctx).unwrap(), warnings);
1401        }
1402    }
1403
1404    #[test]
1405    fn test_field_map_nested_lines_inherit_top_level_key() {
1406        let map = field_map_for("---\nseo:\n  canonical: docs/a.md\n  keywords:\n    - myapp\ntitle: x\n---\n");
1407        assert_eq!(map[2].as_deref(), Some("seo"));
1408        assert_eq!(map[4].as_deref(), Some("seo"));
1409        assert_eq!(map[5].as_deref(), Some("title"));
1410    }
1411
1412    #[test]
1413    fn test_field_map_block_scalar_bracket_does_not_swallow_next_key() {
1414        let map = field_map_for("---\ndescription: |\n  [myapp\ntitle: myapp\n---\n");
1415        assert_eq!(map[2].as_deref(), Some("description"));
1416        assert_eq!(
1417            map[3].as_deref(),
1418            Some("title"),
1419            "an indent-0 key always starts a new key"
1420        );
1421    }
1422
1423    #[test]
1424    fn test_field_map_quoted_key_with_colon() {
1425        let map = field_map_for("---\n\"og:title\": myapp\n---\n");
1426        assert_eq!(map[1].as_deref(), Some("og:title"));
1427    }
1428
1429    #[test]
1430    fn test_field_map_top_level_sequence_clears_attribution() {
1431        let map = field_map_for("---\n- myapp\n---\n");
1432        assert_eq!(map[1], None);
1433    }
1434
1435    #[test]
1436    fn test_field_map_toml_table_body_belongs_to_table_root() {
1437        let map = field_map_for("+++\n[seo]\ncanonical = \"docs/a.md\"\n\n[[authors]]\nname = \"myapp\"\n+++\n");
1438        assert_eq!(map[2].as_deref(), Some("seo"));
1439        assert_eq!(map[5].as_deref(), Some("authors"));
1440    }
1441
1442    #[test]
1443    fn test_field_map_toml_dotted_assignment_uses_root() {
1444        let map = field_map_for("+++\nseo.canonical = \"docs/a.md\"\n+++\n");
1445        assert_eq!(map[1].as_deref(), Some("seo"));
1446    }
1447
1448    #[test]
1449    fn test_field_map_toml_array_continuation_inherits() {
1450        let map = field_map_for("+++\nseo = [\n\"docs/guide/myapp\"\n]\n+++\n");
1451        assert_eq!(map[2].as_deref(), Some("seo"));
1452    }
1453
1454    #[test]
1455    fn test_field_map_indent_zero_flow_continuation_is_not_attributed_to_parent() {
1456        // Documented limitation: this is the safe direction. The value keeps
1457        // being checked, exactly as it is today.
1458        let map = field_map_for("---\nseo: [\n{name: myapp}\n]\n---\n");
1459        assert_eq!(map[2].as_deref(), Some("{name"));
1460    }
1461
1462    #[test]
1463    fn test_field_map_toml_nested_array_inherits_and_title_not_corrupted() {
1464        let map = field_map_for("+++\nmatrix = [\n  [1, 2],\n  [3, 4],\n]\ntitle = \"x\"\n+++\n");
1465        assert_eq!(map[2].as_deref(), Some("matrix"), "nested array line inherits matrix");
1466        assert_eq!(map[3].as_deref(), Some("matrix"), "nested array line inherits matrix");
1467        assert_eq!(
1468            map[5].as_deref(),
1469            Some("title"),
1470            "title must not inherit stale attribution from a closed nested array"
1471        );
1472    }
1473
1474    #[test]
1475    fn test_field_map_toml_nested_array_last_element_without_trailing_comma() {
1476        let map = field_map_for("+++\nmatrix = [\n  [1, 2],\n  [2]\n]\ntitle = \"x\"\n+++\n");
1477        assert_eq!(
1478            map[3].as_deref(),
1479            Some("matrix"),
1480            "last element without a trailing comma still inherits matrix"
1481        );
1482        assert_eq!(
1483            map[5].as_deref(),
1484            Some("title"),
1485            "title must not inherit stale attribution from a closed nested array"
1486        );
1487    }
1488
1489    #[test]
1490    fn test_field_map_toml_nested_array_then_real_table_header_non_regression_guard() {
1491        // Not a bug reproduction: `toml_table_header` runs unconditionally
1492        // on every non-continuation line, so a real `[table]` header always
1493        // resyncs `current`/`in_toml_table` regardless of what came before.
1494        // This passed before the array-depth fix and always will; it guards
1495        // against a future change accidentally gating the header check
1496        // itself behind array-depth state.
1497        let map = field_map_for("+++\nmatrix = [\n  [1, 2],\n  [3, 4],\n]\n\n[seo]\ncanonical = \"docs/a.md\"\n+++\n");
1498        assert_eq!(map[6].as_deref(), Some("seo"), "real table header after a closed array");
1499        assert_eq!(
1500            map[7].as_deref(),
1501            Some("seo"),
1502            "table body still attributes to the table"
1503        );
1504    }
1505
1506    #[test]
1507    fn test_field_map_toml_unclosed_array_resyncs_on_next_assignment() {
1508        // A forgotten closing bracket is a plausible authoring typo. Without
1509        // recovery, the stuck array depth would attribute `title` to
1510        // `matrix` forever, and excluding `matrix` would silently suppress
1511        // a real violation on `title`. The indent-0 assignment must resync
1512        // regardless of the unclosed depth.
1513        let map = field_map_for("+++\nmatrix = [\n  [1, 2],\ntitle = \"x\"\n+++\n");
1514        assert_eq!(
1515            map[3].as_deref(),
1516            Some("title"),
1517            "title must resync even though the array was never closed"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_field_map_toml_column_zero_array_elements_inherit_and_title_not_corrupted() {
1523        // TOML does not require indentation inside a multi-line array, so
1524        // `[1, 2],` at column 0 is a valid array element, not a table
1525        // header. `toml_table_header` must reject it: it starts with `[`
1526        // and contains a later `]`, but does not END with `]` (there is a
1527        // trailing `,`) and its inner content has an unquoted comma.
1528        let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[3, 4],\n]\ntitle = \"x\"\n+++\n");
1529        assert_eq!(
1530            map[2].as_deref(),
1531            Some("matrix"),
1532            "column-0 array element inherits matrix"
1533        );
1534        assert_eq!(
1535            map[3].as_deref(),
1536            Some("matrix"),
1537            "column-0 array element inherits matrix"
1538        );
1539        assert_eq!(
1540            map[5].as_deref(),
1541            Some("title"),
1542            "title must not inherit stale attribution from a misread array element"
1543        );
1544    }
1545
1546    #[test]
1547    fn test_field_map_toml_column_zero_array_last_element_without_trailing_comma() {
1548        let map = field_map_for("+++\nmatrix = [\n[1, 2],\n[2]\n]\ntitle = \"x\"\n+++\n");
1549        assert_eq!(
1550            map[3].as_deref(),
1551            Some("matrix"),
1552            "column-0 last element without a trailing comma still inherits matrix"
1553        );
1554        assert_eq!(
1555            map[5].as_deref(),
1556            Some("title"),
1557            "title must not inherit stale attribution from a misread array element"
1558        );
1559    }
1560
1561    #[test]
1562    fn test_correctly_capitalized_names() {
1563        let rule = MD044ProperNames::new(
1564            vec![
1565                "JavaScript".to_string(),
1566                "TypeScript".to_string(),
1567                "Node.js".to_string(),
1568            ],
1569            true,
1570        );
1571
1572        let content = "This document uses JavaScript, TypeScript, and Node.js correctly.";
1573        let ctx = create_context(content);
1574        let result = rule.check(&ctx).unwrap();
1575        assert!(result.is_empty(), "Should not flag correctly capitalized names");
1576    }
1577
1578    #[test]
1579    fn test_incorrectly_capitalized_names() {
1580        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
1581
1582        let content = "This document uses javascript and typescript incorrectly.";
1583        let ctx = create_context(content);
1584        let result = rule.check(&ctx).unwrap();
1585
1586        assert_eq!(result.len(), 2, "Should flag two incorrect capitalizations");
1587        assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1588        assert_eq!(result[0].line, 1);
1589        assert_eq!(result[0].column, 20);
1590        assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1591        assert_eq!(result[1].line, 1);
1592        assert_eq!(result[1].column, 35);
1593    }
1594
1595    #[test]
1596    fn test_names_at_beginning_of_sentences() {
1597        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "Python".to_string()], true);
1598
1599        let content = "javascript is a great language. python is also popular.";
1600        let ctx = create_context(content);
1601        let result = rule.check(&ctx).unwrap();
1602
1603        assert_eq!(result.len(), 2, "Should flag names at beginning of sentences");
1604        assert_eq!(result[0].line, 1);
1605        assert_eq!(result[0].column, 1);
1606        assert_eq!(result[1].line, 1);
1607        assert_eq!(result[1].column, 33);
1608    }
1609
1610    #[test]
1611    fn test_names_in_code_blocks_checked_by_default() {
1612        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1613
1614        let content = r#"Here is some text with JavaScript.
1615
1616```javascript
1617// This javascript should be checked
1618const lang = "javascript";
1619```
1620
1621But this javascript should be flagged."#;
1622
1623        let ctx = create_context(content);
1624        let result = rule.check(&ctx).unwrap();
1625
1626        assert_eq!(result.len(), 3, "Should flag javascript inside and outside code blocks");
1627        assert_eq!(result[0].line, 4);
1628        assert_eq!(result[1].line, 5);
1629        assert_eq!(result[2].line, 8);
1630    }
1631
1632    #[test]
1633    fn test_names_in_code_blocks_ignored_when_disabled() {
1634        let rule = MD044ProperNames::new(
1635            vec!["JavaScript".to_string()],
1636            false, // code_blocks = false means skip code blocks
1637        );
1638
1639        let content = r#"```
1640javascript in code block
1641```"#;
1642
1643        let ctx = create_context(content);
1644        let result = rule.check(&ctx).unwrap();
1645
1646        assert_eq!(
1647            result.len(),
1648            0,
1649            "Should not flag javascript in code blocks when code_blocks is false"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_names_in_inline_code_checked_by_default() {
1655        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1656
1657        let content = "This is `javascript` in inline code and javascript outside.";
1658        let ctx = create_context(content);
1659        let result = rule.check(&ctx).unwrap();
1660
1661        // When code_blocks=true, inline code should be checked
1662        assert_eq!(result.len(), 2, "Should flag javascript inside and outside inline code");
1663        assert_eq!(result[0].column, 10); // javascript in inline code
1664        assert_eq!(result[1].column, 41); // javascript outside
1665    }
1666
1667    #[test]
1668    fn test_multiple_names_in_same_line() {
1669        let rule = MD044ProperNames::new(
1670            vec!["JavaScript".to_string(), "TypeScript".to_string(), "React".to_string()],
1671            true,
1672        );
1673
1674        let content = "I use javascript, typescript, and react in my projects.";
1675        let ctx = create_context(content);
1676        let result = rule.check(&ctx).unwrap();
1677
1678        assert_eq!(result.len(), 3, "Should flag all three incorrect names");
1679        assert_eq!(result[0].message, "Proper name 'javascript' should be 'JavaScript'");
1680        assert_eq!(result[1].message, "Proper name 'typescript' should be 'TypeScript'");
1681        assert_eq!(result[2].message, "Proper name 'react' should be 'React'");
1682    }
1683
1684    #[test]
1685    fn test_case_sensitivity() {
1686        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1687
1688        let content = "JAVASCRIPT, Javascript, javascript, and JavaScript variations.";
1689        let ctx = create_context(content);
1690        let result = rule.check(&ctx).unwrap();
1691
1692        assert_eq!(result.len(), 3, "Should flag all incorrect case variations");
1693        // JavaScript (correct) should not be flagged
1694        assert!(result.iter().all(|w| w.message.contains("should be 'JavaScript'")));
1695    }
1696
1697    #[test]
1698    fn test_configuration_with_custom_name_list() {
1699        let config = MD044Config {
1700            names: vec!["GitHub".to_string(), "GitLab".to_string(), "DevOps".to_string()],
1701            code_blocks: true,
1702            ..Default::default()
1703        };
1704        let rule = MD044ProperNames::from_config_struct(config);
1705
1706        let content = "We use github, gitlab, and devops for our workflow.";
1707        let ctx = create_context(content);
1708        let result = rule.check(&ctx).unwrap();
1709
1710        assert_eq!(result.len(), 3, "Should flag all custom names");
1711        assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
1712        assert_eq!(result[1].message, "Proper name 'gitlab' should be 'GitLab'");
1713        assert_eq!(result[2].message, "Proper name 'devops' should be 'DevOps'");
1714    }
1715
1716    #[test]
1717    fn test_empty_configuration() {
1718        let rule = MD044ProperNames::new(vec![], true);
1719
1720        let content = "This has javascript and typescript but no configured names.";
1721        let ctx = create_context(content);
1722        let result = rule.check(&ctx).unwrap();
1723
1724        assert!(result.is_empty(), "Should not flag anything with empty configuration");
1725    }
1726
1727    #[test]
1728    fn test_names_with_special_characters() {
1729        let rule = MD044ProperNames::new(
1730            vec!["Node.js".to_string(), "ASP.NET".to_string(), "C++".to_string()],
1731            true,
1732        );
1733
1734        let content = "We use nodejs, asp.net, ASP.NET, and c++ in our stack.";
1735        let ctx = create_context(content);
1736        let result = rule.check(&ctx).unwrap();
1737
1738        // nodejs should match Node.js (dotless variation)
1739        // asp.net should be flagged (wrong case)
1740        // ASP.NET should not be flagged (correct)
1741        // c++ should be flagged
1742        assert_eq!(result.len(), 3, "Should handle special characters correctly");
1743
1744        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
1745        assert!(messages.contains(&"Proper name 'nodejs' should be 'Node.js'"));
1746        assert!(messages.contains(&"Proper name 'asp.net' should be 'ASP.NET'"));
1747        assert!(messages.contains(&"Proper name 'c++' should be 'C++'"));
1748    }
1749
1750    #[test]
1751    fn test_word_boundaries() {
1752        let rule = MD044ProperNames::new(vec!["Java".to_string(), "Script".to_string()], true);
1753
1754        let content = "JavaScript is not java or script, but Java and Script are separate.";
1755        let ctx = create_context(content);
1756        let result = rule.check(&ctx).unwrap();
1757
1758        // Should only flag lowercase "java" and "script" as separate words
1759        assert_eq!(result.len(), 2, "Should respect word boundaries");
1760        assert!(result.iter().any(|w| w.column == 19)); // "java" position
1761        assert!(result.iter().any(|w| w.column == 27)); // "script" position
1762    }
1763
1764    #[test]
1765    fn test_fix_method() {
1766        let rule = MD044ProperNames::new(
1767            vec![
1768                "JavaScript".to_string(),
1769                "TypeScript".to_string(),
1770                "Node.js".to_string(),
1771            ],
1772            true,
1773        );
1774
1775        let content = "I love javascript, typescript, and nodejs!";
1776        let ctx = create_context(content);
1777        let fixed = rule.fix(&ctx).unwrap();
1778
1779        assert_eq!(fixed, "I love JavaScript, TypeScript, and Node.js!");
1780    }
1781
1782    #[test]
1783    fn test_fix_multiple_occurrences() {
1784        let rule = MD044ProperNames::new(vec!["Python".to_string()], true);
1785
1786        let content = "python is great. I use python daily. PYTHON is powerful.";
1787        let ctx = create_context(content);
1788        let fixed = rule.fix(&ctx).unwrap();
1789
1790        assert_eq!(fixed, "Python is great. I use Python daily. Python is powerful.");
1791    }
1792
1793    #[test]
1794    fn test_fix_checks_code_blocks_by_default() {
1795        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
1796
1797        let content = r#"I love javascript.
1798
1799```
1800const lang = "javascript";
1801```
1802
1803More javascript here."#;
1804
1805        let ctx = create_context(content);
1806        let fixed = rule.fix(&ctx).unwrap();
1807
1808        let expected = r#"I love JavaScript.
1809
1810```
1811const lang = "JavaScript";
1812```
1813
1814More JavaScript here."#;
1815
1816        assert_eq!(fixed, expected);
1817    }
1818
1819    #[test]
1820    fn test_multiline_content() {
1821        let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
1822
1823        let content = r#"First line with rust.
1824Second line with python.
1825Third line with RUST and PYTHON."#;
1826
1827        let ctx = create_context(content);
1828        let result = rule.check(&ctx).unwrap();
1829
1830        assert_eq!(result.len(), 4, "Should flag all incorrect occurrences");
1831        assert_eq!(result[0].line, 1);
1832        assert_eq!(result[1].line, 2);
1833        assert_eq!(result[2].line, 3);
1834        assert_eq!(result[3].line, 3);
1835    }
1836
1837    #[test]
1838    fn test_default_config() {
1839        let config = MD044Config::default();
1840        assert!(config.names.is_empty());
1841        assert!(!config.code_blocks);
1842        assert!(config.html_elements);
1843        assert!(config.html_comments);
1844    }
1845
1846    #[test]
1847    fn test_default_config_checks_html_comments() {
1848        let config = MD044Config {
1849            names: vec!["JavaScript".to_string()],
1850            ..MD044Config::default()
1851        };
1852        let rule = MD044ProperNames::from_config_struct(config);
1853
1854        let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1855        let ctx = create_context(content);
1856        let result = rule.check(&ctx).unwrap();
1857
1858        assert_eq!(result.len(), 1, "Default config should check HTML comments");
1859        assert_eq!(result[0].line, 3);
1860    }
1861
1862    #[test]
1863    fn test_default_config_skips_code_blocks() {
1864        let config = MD044Config {
1865            names: vec!["JavaScript".to_string()],
1866            ..MD044Config::default()
1867        };
1868        let rule = MD044ProperNames::from_config_struct(config);
1869
1870        let content = "# Guide\n\n```\njavascript in code\n```\n";
1871        let ctx = create_context(content);
1872        let result = rule.check(&ctx).unwrap();
1873
1874        assert_eq!(result.len(), 0, "Default config should skip code blocks");
1875    }
1876
1877    #[test]
1878    fn test_standalone_html_comment_checked() {
1879        let config = MD044Config {
1880            names: vec!["Test".to_string()],
1881            ..MD044Config::default()
1882        };
1883        let rule = MD044ProperNames::from_config_struct(config);
1884
1885        let content = "# Heading\n\n<!-- this is a test example -->\n";
1886        let ctx = create_context(content);
1887        let result = rule.check(&ctx).unwrap();
1888
1889        assert_eq!(result.len(), 1, "Should flag proper name in standalone HTML comment");
1890        assert_eq!(result[0].line, 3);
1891    }
1892
1893    #[test]
1894    fn test_inline_config_comments_not_flagged() {
1895        let config = MD044Config {
1896            names: vec!["RUMDL".to_string()],
1897            ..MD044Config::default()
1898        };
1899        let rule = MD044ProperNames::from_config_struct(config);
1900
1901        // Lines 1, 3, 4, 6 are inline config comments — should not be flagged.
1902        // Lines 2, 5 contain "rumdl" in regular text — flagged by rule.check(),
1903        // but would be suppressed by the linting engine's inline config filtering.
1904        let content = "<!-- rumdl-disable MD044 -->\nSome rumdl text here.\n<!-- rumdl-enable MD044 -->\n<!-- markdownlint-disable -->\nMore rumdl text.\n<!-- markdownlint-enable -->\n";
1905        let ctx = create_context(content);
1906        let result = rule.check(&ctx).unwrap();
1907
1908        assert_eq!(result.len(), 2, "Should only flag body lines, not config comments");
1909        assert_eq!(result[0].line, 2);
1910        assert_eq!(result[1].line, 5);
1911    }
1912
1913    #[test]
1914    fn test_compact_inline_config_comments_not_flagged() {
1915        let config = MD044Config {
1916            names: vec!["RUMDL".to_string(), "Markdownlint".to_string()],
1917            ..MD044Config::default()
1918        };
1919        let rule = MD044ProperNames::from_config_struct(config);
1920
1921        // Compact directive lines are skipped like the spaced ones; the two body
1922        // lines are flagged, and so is a comment that is not a directive.
1923        let content = "<!--rumdl-disable MD044-->\nSome rumdl text.\n<!--markdownlint-enable MD044 -->\n<!--\trumdl-capture-->\nMore rumdl text.\n<!-- note markdownlint -->\n";
1924        let ctx = create_context(content);
1925        let result = rule.check(&ctx).unwrap();
1926        let lines: Vec<usize> = result.iter().map(|w| w.line).collect();
1927        assert_eq!(lines, vec![2, 5, 6], "{result:?}");
1928    }
1929
1930    #[test]
1931    fn test_html_comment_skipped_when_disabled() {
1932        let config = MD044Config {
1933            names: vec!["Test".to_string()],
1934            code_blocks: true,
1935            html_comments: false,
1936            ..Default::default()
1937        };
1938        let rule = MD044ProperNames::from_config_struct(config);
1939
1940        let content = "# Heading\n\n<!-- this is a test example -->\n\nRegular test here.\n";
1941        let ctx = create_context(content);
1942        let result = rule.check(&ctx).unwrap();
1943
1944        assert_eq!(
1945            result.len(),
1946            1,
1947            "Should only flag 'test' outside HTML comment when html_comments=false"
1948        );
1949        assert_eq!(result[0].line, 5);
1950    }
1951
1952    #[test]
1953    fn test_fix_corrects_html_comment_content() {
1954        let config = MD044Config {
1955            names: vec!["JavaScript".to_string()],
1956            ..MD044Config::default()
1957        };
1958        let rule = MD044ProperNames::from_config_struct(config);
1959
1960        let content = "# Guide\n\n<!-- javascript mentioned here -->\n";
1961        let ctx = create_context(content);
1962        let fixed = rule.fix(&ctx).unwrap();
1963
1964        assert_eq!(fixed, "# Guide\n\n<!-- JavaScript mentioned here -->\n");
1965    }
1966
1967    #[test]
1968    fn test_fix_does_not_modify_inline_config_comments() {
1969        let config = MD044Config {
1970            names: vec!["RUMDL".to_string()],
1971            ..MD044Config::default()
1972        };
1973        let rule = MD044ProperNames::from_config_struct(config);
1974
1975        let content = "<!-- rumdl-disable -->\nSome rumdl text.\n<!-- rumdl-enable -->\n";
1976        let ctx = create_context(content);
1977        let fixed = rule.fix(&ctx).unwrap();
1978
1979        // Config comments should be untouched
1980        assert!(fixed.contains("<!-- rumdl-disable -->"));
1981        assert!(fixed.contains("<!-- rumdl-enable -->"));
1982        // Body text inside disable block should NOT be fixed (rule is disabled)
1983        assert!(
1984            fixed.contains("Some rumdl text."),
1985            "Line inside rumdl-disable block should not be modified by fix()"
1986        );
1987    }
1988
1989    #[test]
1990    fn test_fix_respects_inline_disable_partial() {
1991        let config = MD044Config {
1992            names: vec!["RUMDL".to_string()],
1993            ..MD044Config::default()
1994        };
1995        let rule = MD044ProperNames::from_config_struct(config);
1996
1997        let content =
1998            "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
1999        let ctx = create_context(content);
2000        let fixed = rule.fix(&ctx).unwrap();
2001
2002        // Line inside disable block should be preserved
2003        assert!(
2004            fixed.contains("Some rumdl text.\n<!-- rumdl-enable"),
2005            "Line inside disable block should not be modified"
2006        );
2007        // Line outside disable block should be fixed
2008        assert!(
2009            fixed.contains("Some RUMDL text outside."),
2010            "Line outside disable block should be fixed"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_performance_with_many_names() {
2016        let mut names = vec![];
2017        for i in 0..50 {
2018            names.push(format!("ProperName{i}"));
2019        }
2020
2021        let rule = MD044ProperNames::new(names, true);
2022
2023        let content = "This has propername0, propername25, and propername49 incorrectly.";
2024        let ctx = create_context(content);
2025        let result = rule.check(&ctx).unwrap();
2026
2027        assert_eq!(result.len(), 3, "Should handle many configured names efficiently");
2028    }
2029
2030    #[test]
2031    fn test_large_name_count_performance() {
2032        // Verify MD044 can handle large numbers of names without regex limitations
2033        // This test confirms that fancy-regex handles large patterns well
2034        let names = (0..1000).map(|i| format!("ProperName{i}")).collect::<Vec<_>>();
2035
2036        let rule = MD044ProperNames::new(names, true);
2037
2038        // The combined pattern should be created successfully
2039        assert!(rule.combined_pattern.is_some());
2040
2041        // Should be able to check content without errors
2042        let content = "This has propername0 and propername999 in it.";
2043        let ctx = create_context(content);
2044        let result = rule.check(&ctx).unwrap();
2045
2046        // Should detect both incorrect names
2047        assert_eq!(result.len(), 2, "Should handle 1000 names without issues");
2048    }
2049
2050    #[test]
2051    fn test_cache_behavior() {
2052        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2053
2054        let content = "Using javascript here.";
2055        let ctx = create_context(content);
2056
2057        // First check
2058        let result1 = rule.check(&ctx).unwrap();
2059        assert_eq!(result1.len(), 1);
2060
2061        // Second check should use cache
2062        let result2 = rule.check(&ctx).unwrap();
2063        assert_eq!(result2.len(), 1);
2064
2065        // Results should be identical
2066        assert_eq!(result1[0].line, result2[0].line);
2067        assert_eq!(result1[0].column, result2[0].column);
2068    }
2069
2070    #[test]
2071    fn test_html_comments_not_checked_when_disabled() {
2072        let config = MD044Config {
2073            names: vec!["JavaScript".to_string()],
2074            code_blocks: true,    // Check code blocks
2075            html_comments: false, // Don't check HTML comments
2076            ..Default::default()
2077        };
2078        let rule = MD044ProperNames::from_config_struct(config);
2079
2080        let content = r#"Regular javascript here.
2081<!-- This javascript in HTML comment should be ignored -->
2082More javascript outside."#;
2083
2084        let ctx = create_context(content);
2085        let result = rule.check(&ctx).unwrap();
2086
2087        assert_eq!(result.len(), 2, "Should only flag javascript outside HTML comments");
2088        assert_eq!(result[0].line, 1);
2089        assert_eq!(result[1].line, 3);
2090    }
2091
2092    #[test]
2093    fn test_html_comments_checked_when_enabled() {
2094        let config = MD044Config {
2095            names: vec!["JavaScript".to_string()],
2096            code_blocks: true, // Check code blocks
2097            ..Default::default()
2098        };
2099        let rule = MD044ProperNames::from_config_struct(config);
2100
2101        let content = r#"Regular javascript here.
2102<!-- This javascript in HTML comment should be checked -->
2103More javascript outside."#;
2104
2105        let ctx = create_context(content);
2106        let result = rule.check(&ctx).unwrap();
2107
2108        assert_eq!(
2109            result.len(),
2110            3,
2111            "Should flag all javascript occurrences including in HTML comments"
2112        );
2113    }
2114
2115    #[test]
2116    fn test_indented_html_comment_escapes_via_link_and_backticks() {
2117        // Regression for #755: MD044 checks inside HTML comments by default, but
2118        // links and inline code inside a comment should escape the rule. That
2119        // protection depends on the line being recognised as an HTML comment,
2120        // which must hold whether or not the comment is indented.
2121        let config = MD044Config {
2122            names: vec!["Test".to_string()],
2123            ..Default::default()
2124        };
2125        let rule = MD044ProperNames::from_config_struct(config);
2126
2127        let content = "<!-- see the [relevant page](test.md). -->\n<!-- see `test.md` -->\n  <!-- see the [relevant page](test.md). -->\n  <!-- see `test.md` -->\n";
2128
2129        let ctx = create_context(content);
2130        let result = rule.check(&ctx).unwrap();
2131
2132        assert!(
2133            result.is_empty(),
2134            "'test' inside a link URL or backticks must be ignored in both column-0 and indented comments, got: {result:?}"
2135        );
2136    }
2137
2138    #[test]
2139    fn test_indented_html_comment_still_checks_bare_prose() {
2140        // The indent fix must not suppress genuine violations: bare prose inside an
2141        // indented comment is still checked (only links/backticks escape).
2142        let config = MD044Config {
2143            names: vec!["Test".to_string()],
2144            ..Default::default()
2145        };
2146        let rule = MD044ProperNames::from_config_struct(config);
2147
2148        let content = "  <!-- this is a test comment -->\n";
2149
2150        let ctx = create_context(content);
2151        let result = rule.check(&ctx).unwrap();
2152
2153        assert_eq!(
2154            result.len(),
2155            1,
2156            "bare 'test' in an indented comment is still a violation"
2157        );
2158        assert_eq!(result[0].line, 1);
2159    }
2160
2161    #[test]
2162    fn test_multiline_html_comments() {
2163        let config = MD044Config {
2164            names: vec!["Python".to_string(), "JavaScript".to_string()],
2165            code_blocks: true,    // Check code blocks
2166            html_comments: false, // Don't check HTML comments
2167            ..Default::default()
2168        };
2169        let rule = MD044ProperNames::from_config_struct(config);
2170
2171        let content = r#"Regular python here.
2172<!--
2173This is a multiline comment
2174with javascript and python
2175that should be ignored
2176-->
2177More javascript outside."#;
2178
2179        let ctx = create_context(content);
2180        let result = rule.check(&ctx).unwrap();
2181
2182        assert_eq!(result.len(), 2, "Should only flag names outside HTML comments");
2183        assert_eq!(result[0].line, 1); // python
2184        assert_eq!(result[1].line, 7); // javascript
2185    }
2186
2187    #[test]
2188    fn test_fix_preserves_html_comments_when_disabled() {
2189        let config = MD044Config {
2190            names: vec!["JavaScript".to_string()],
2191            code_blocks: true,    // Check code blocks
2192            html_comments: false, // Don't check HTML comments
2193            ..Default::default()
2194        };
2195        let rule = MD044ProperNames::from_config_struct(config);
2196
2197        let content = r#"javascript here.
2198<!-- javascript in comment -->
2199More javascript."#;
2200
2201        let ctx = create_context(content);
2202        let fixed = rule.fix(&ctx).unwrap();
2203
2204        let expected = r#"JavaScript here.
2205<!-- javascript in comment -->
2206More JavaScript."#;
2207
2208        assert_eq!(
2209            fixed, expected,
2210            "Should not fix names inside HTML comments when disabled"
2211        );
2212    }
2213
2214    #[test]
2215    fn test_proper_names_in_link_text_are_flagged() {
2216        let rule = MD044ProperNames::new(
2217            vec!["JavaScript".to_string(), "Node.js".to_string(), "Python".to_string()],
2218            true,
2219        );
2220
2221        let content = r#"Check this [javascript documentation](https://javascript.info) for info.
2222
2223Visit [node.js homepage](https://nodejs.org) and [python tutorial](https://python.org).
2224
2225Real javascript should be flagged.
2226
2227Also see the [typescript guide][ts-ref] for more.
2228
2229Real python should be flagged too.
2230
2231[ts-ref]: https://typescript.org/handbook"#;
2232
2233        let ctx = create_context(content);
2234        let result = rule.check(&ctx).unwrap();
2235
2236        // Link text should be checked, URLs should not be checked
2237        // Line 1: [javascript documentation] - "javascript" should be flagged
2238        // Line 3: [node.js homepage] - "node.js" should be flagged (matches "Node.js")
2239        // Line 3: [python tutorial] - "python" should be flagged
2240        // Line 5: standalone javascript
2241        // Line 9: standalone python
2242        assert_eq!(result.len(), 5, "Expected 5 warnings: 3 in link text + 2 standalone");
2243
2244        // Verify line numbers for link text warnings
2245        let line_1_warnings: Vec<_> = result.iter().filter(|w| w.line == 1).collect();
2246        assert_eq!(line_1_warnings.len(), 1);
2247        assert!(
2248            line_1_warnings[0]
2249                .message
2250                .contains("'javascript' should be 'JavaScript'")
2251        );
2252
2253        let line_3_warnings: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2254        assert_eq!(line_3_warnings.len(), 2); // node.js and python
2255
2256        // Standalone warnings
2257        assert!(result.iter().any(|w| w.line == 5 && w.message.contains("'javascript'")));
2258        assert!(result.iter().any(|w| w.line == 9 && w.message.contains("'python'")));
2259    }
2260
2261    #[test]
2262    fn test_link_urls_not_flagged() {
2263        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2264
2265        // URL contains "javascript" but should NOT be flagged
2266        let content = r#"[Link Text](https://javascript.info/guide)"#;
2267
2268        let ctx = create_context(content);
2269        let result = rule.check(&ctx).unwrap();
2270
2271        // URL should not be checked
2272        assert!(result.is_empty(), "URLs should not be checked for proper names");
2273    }
2274
2275    #[test]
2276    fn test_bare_urls_not_flagged() {
2277        let rule = MD044ProperNames::new(vec!["Foo".to_string(), "JavaScript".to_string()], true);
2278
2279        // Bare URLs are not links in ctx.links(), but a proper-name "fix"
2280        // inside a domain or case-sensitive path would break the link.
2281        let content =
2282            "https://foo.com\n\nSee https://javascript.info/foo/guide for details.\n\nMail foo@foo.com about it.\n";
2283
2284        let ctx = create_context(content);
2285        let result = rule.check(&ctx).unwrap();
2286
2287        assert!(
2288            result.is_empty(),
2289            "Bare URLs and emails should not be checked for proper names: {result:?}"
2290        );
2291    }
2292
2293    #[test]
2294    fn test_prose_around_bare_url_still_flagged() {
2295        let rule = MD044ProperNames::new(vec!["Foo".to_string()], true);
2296
2297        // The word before and after the URL must still be flagged; only the
2298        // URL bytes themselves are exempt.
2299        let content = "Use foo at https://foo.com because foo is great.\n";
2300
2301        let ctx = create_context(content);
2302        let result = rule.check(&ctx).unwrap();
2303
2304        assert_eq!(
2305            result.len(),
2306            2,
2307            "Prose occurrences around a bare URL must still be flagged: {result:?}"
2308        );
2309        assert!(result.iter().all(|w| w.message.contains("'foo' should be 'Foo'")));
2310    }
2311
2312    #[test]
2313    fn test_proper_names_in_image_alt_text_are_flagged() {
2314        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2315
2316        let content = r#"Here is a ![javascript logo](javascript.png "javascript icon") image.
2317
2318Real javascript should be flagged."#;
2319
2320        let ctx = create_context(content);
2321        let result = rule.check(&ctx).unwrap();
2322
2323        // Image alt text should be checked, URL and title should not be checked
2324        // Line 1: ![javascript logo] - "javascript" should be flagged
2325        // Line 3: standalone javascript
2326        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in alt text + 1 standalone");
2327        assert!(result[0].message.contains("'javascript' should be 'JavaScript'"));
2328        assert!(result[0].line == 1); // "![javascript logo]"
2329        assert!(result[1].message.contains("'javascript' should be 'JavaScript'"));
2330        assert!(result[1].line == 3); // "Real javascript should be flagged."
2331    }
2332
2333    #[test]
2334    fn test_image_urls_not_flagged() {
2335        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2336
2337        // URL contains "javascript" but should NOT be flagged
2338        let content = r#"![Logo](https://javascript.info/logo.png)"#;
2339
2340        let ctx = create_context(content);
2341        let result = rule.check(&ctx).unwrap();
2342
2343        // Image URL should not be checked
2344        assert!(result.is_empty(), "Image URLs should not be checked for proper names");
2345    }
2346
2347    #[test]
2348    fn test_reference_link_text_flagged_but_definition_not() {
2349        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2350
2351        let content = r#"Check the [javascript guide][js-ref] for details.
2352
2353Real javascript should be flagged.
2354
2355[js-ref]: https://javascript.info/typescript/guide"#;
2356
2357        let ctx = create_context(content);
2358        let result = rule.check(&ctx).unwrap();
2359
2360        // Link text should be checked, reference definitions should not
2361        // Line 1: [javascript guide] - should be flagged
2362        // Line 3: standalone javascript - should be flagged
2363        // Line 5: reference definition - should NOT be flagged
2364        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in link text + 1 standalone");
2365        assert!(result.iter().any(|w| w.line == 1 && w.message.contains("'javascript'")));
2366        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2367    }
2368
2369    #[test]
2370    fn test_reference_definitions_not_flagged() {
2371        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2372
2373        // Reference definition should NOT be flagged
2374        let content = r#"[js-ref]: https://javascript.info/guide"#;
2375
2376        let ctx = create_context(content);
2377        let result = rule.check(&ctx).unwrap();
2378
2379        // Reference definition URLs should not be checked
2380        assert!(result.is_empty(), "Reference definitions should not be checked");
2381    }
2382
2383    #[test]
2384    fn test_wikilinks_text_is_flagged() {
2385        let rule = MD044ProperNames::new(vec!["JavaScript".to_string()], true);
2386
2387        // WikiLinks [[destination]] should have their text checked
2388        let content = r#"[[javascript]]
2389
2390Regular javascript here.
2391
2392[[JavaScript|display text]]"#;
2393
2394        let ctx = create_context(content);
2395        let result = rule.check(&ctx).unwrap();
2396
2397        // Line 1: [[javascript]] - should be flagged (WikiLink text)
2398        // Line 3: standalone javascript - should be flagged
2399        // Line 5: [[JavaScript|display text]] - correct capitalization, no flag
2400        assert_eq!(result.len(), 2, "Expected 2 warnings: 1 in WikiLink + 1 standalone");
2401        assert!(
2402            result
2403                .iter()
2404                .any(|w| w.line == 1 && w.column == 3 && w.message.contains("'javascript'"))
2405        );
2406        assert!(result.iter().any(|w| w.line == 3 && w.message.contains("'javascript'")));
2407    }
2408
2409    #[test]
2410    fn test_url_link_text_not_flagged() {
2411        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2412
2413        // Link text that is itself a URL should not be flagged
2414        let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2415
2416[http://github.com/org/repo](http://github.com/org/repo)
2417
2418[www.github.com/org/repo](https://www.github.com/org/repo)"#;
2419
2420        let ctx = create_context(content);
2421        let result = rule.check(&ctx).unwrap();
2422
2423        assert!(
2424            result.is_empty(),
2425            "URL-like link text should not be flagged, got: {result:?}"
2426        );
2427    }
2428
2429    #[test]
2430    fn test_url_link_text_with_leading_space_not_flagged() {
2431        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2432
2433        // Leading/trailing whitespace in link text should be trimmed before URL check
2434        let content = r#"[ https://github.com/org/repo](https://github.com/org/repo)"#;
2435
2436        let ctx = create_context(content);
2437        let result = rule.check(&ctx).unwrap();
2438
2439        assert!(
2440            result.is_empty(),
2441            "URL-like link text with leading space should not be flagged, got: {result:?}"
2442        );
2443    }
2444
2445    #[test]
2446    fn test_url_link_text_uppercase_scheme_not_flagged() {
2447        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2448
2449        let content = r#"[HTTPS://GITHUB.COM/org/repo](https://github.com/org/repo)"#;
2450
2451        let ctx = create_context(content);
2452        let result = rule.check(&ctx).unwrap();
2453
2454        assert!(
2455            result.is_empty(),
2456            "URL-like link text with uppercase scheme should not be flagged, got: {result:?}"
2457        );
2458    }
2459
2460    #[test]
2461    fn test_non_url_link_text_still_flagged() {
2462        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2463
2464        // Only prose descriptions in link text should be flagged.
2465        // Bare-domain, protocol-relative, and scheme-prefixed link texts that
2466        // match the destination URL are all URLs and must not be corrected.
2467        let content = r#"[github.com/org/repo](https://github.com/org/repo)
2468
2469[Visit github](https://github.com/org/repo)
2470
2471[//github.com/org/repo](//github.com/org/repo)
2472
2473[ftp://github.com/org/repo](ftp://github.com/org/repo)"#;
2474
2475        let ctx = create_context(content);
2476        let result = rule.check(&ctx).unwrap();
2477
2478        // Line 1: bare-domain text matches destination — not flagged
2479        // Line 3: prose description — flagged
2480        // Line 5: protocol-relative URL text — not flagged
2481        // Line 7: ftp:// URL text matches destination — not flagged
2482        assert_eq!(
2483            result.len(),
2484            1,
2485            "Only prose link text should be flagged, got: {result:?}"
2486        );
2487        assert!(
2488            result.iter().any(|w| w.line == 3),
2489            "Expected 'Visit github' on line 3 to be flagged"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_url_link_text_fix_not_applied() {
2495        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2496
2497        let content = "[https://github.com/org/repo](https://github.com/org/repo)\n";
2498
2499        let ctx = create_context(content);
2500        let result = rule.fix(&ctx).unwrap();
2501
2502        assert_eq!(result, content, "Fix should not modify URL-like link text");
2503    }
2504
2505    #[test]
2506    fn test_mixed_url_and_regular_link_text() {
2507        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
2508
2509        // Mix of URL link text (should skip) and regular text (should flag)
2510        let content = r#"[https://github.com/org/repo](https://github.com/org/repo)
2511
2512Visit [github documentation](https://github.com/docs) for details.
2513
2514[www.github.com/pricing](https://www.github.com/pricing)"#;
2515
2516        let ctx = create_context(content);
2517        let result = rule.check(&ctx).unwrap();
2518
2519        // Only line 3 should be flagged ("github documentation" is not a URL)
2520        assert_eq!(
2521            result.len(),
2522            1,
2523            "Only non-URL link text should be flagged, got: {result:?}"
2524        );
2525        assert_eq!(result[0].line, 3);
2526    }
2527
2528    #[test]
2529    fn test_html_attribute_values_not_flagged() {
2530        // Matches inside HTML tag attributes (between `<` and `>`) are not flagged.
2531        // Attribute values are not prose — they hold URLs, class names, data values, etc.
2532        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2533        let content = "# Heading\n\ntest\n\n<img src=\"www.example.test/test_image.png\">\n";
2534        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2535        let result = rule.check(&ctx).unwrap();
2536
2537        // Nothing on line 5 should be flagged — everything is inside the `<img ...>` tag
2538        let line5_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2539        assert!(
2540            line5_violations.is_empty(),
2541            "Should not flag anything inside HTML tag attributes: {line5_violations:?}"
2542        );
2543
2544        // Plain text on line 3 is still flagged
2545        let line3_violations: Vec<_> = result.iter().filter(|w| w.line == 3).collect();
2546        assert_eq!(line3_violations.len(), 1, "Plain 'test' on line 3 should be flagged");
2547    }
2548
2549    #[test]
2550    fn test_html_text_content_still_flagged() {
2551        // Text between HTML tags (not inside `<...>`) is still checked.
2552        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2553        let content = "# Heading\n\n<a href=\"https://example.test/page\">test link</a>\n";
2554        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555        let result = rule.check(&ctx).unwrap();
2556
2557        // "example.test" in the href attribute → not flagged (inside `<...>`)
2558        // "test link" in the anchor text → flagged (between `>` and `<`)
2559        assert_eq!(
2560            result.len(),
2561            1,
2562            "Should flag only 'test' in anchor text, not in href: {result:?}"
2563        );
2564        assert_eq!(result[0].column, 37, "Should flag col 37 ('test link' in anchor text)");
2565    }
2566
2567    #[test]
2568    fn test_html_attribute_various_not_flagged() {
2569        // All attribute types are ignored: src, href, alt, class, data-*, title, etc.
2570        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2571        let content = concat!(
2572            "# Heading\n\n",
2573            "<img src=\"test.png\" alt=\"test image\">\n",
2574            "<span class=\"test-class\" data-test=\"value\">test content</span>\n",
2575        );
2576        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2577        let result = rule.check(&ctx).unwrap();
2578
2579        // Only "test content" (between tags on line 4) should be flagged
2580        assert_eq!(
2581            result.len(),
2582            1,
2583            "Should flag only 'test content' between tags: {result:?}"
2584        );
2585        assert_eq!(result[0].line, 4);
2586    }
2587
2588    #[test]
2589    fn test_plain_text_underscore_boundary_unchanged() {
2590        // Plain text (outside HTML tags) still uses original word boundary semantics where
2591        // underscore is a boundary character, matching markdownlint's behavior via AST splitting.
2592        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2593        let content = "# Heading\n\ntest_image is here and just_test ends here\n";
2594        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2595        let result = rule.check(&ctx).unwrap();
2596
2597        // Both "test_image" (test at start) and "just_test" (test at end) are flagged
2598        // because in plain text, "_" is a word boundary
2599        assert_eq!(
2600            result.len(),
2601            2,
2602            "Should flag 'test' in both 'test_image' and 'just_test': {result:?}"
2603        );
2604        let cols: Vec<usize> = result.iter().map(|w| w.column).collect();
2605        assert!(cols.contains(&1), "Should flag col 1 (test_image): {cols:?}");
2606        assert!(cols.contains(&29), "Should flag col 29 (just_test): {cols:?}");
2607    }
2608
2609    #[test]
2610    fn test_frontmatter_yaml_keys_not_flagged() {
2611        // YAML keys in frontmatter should NOT be checked for proper name violations.
2612        // Only values should be checked.
2613        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2614
2615        let content = "---\ntitle: Heading\ntest: Some Test value\n---\n\nTest\n";
2616        let ctx = create_context(content);
2617        let result = rule.check(&ctx).unwrap();
2618
2619        // "test" in the YAML key (line 3) should NOT be flagged
2620        // "Test" in the YAML value (line 3) is correct capitalization, no flag
2621        // "Test" in body (line 6) is correct capitalization, no flag
2622        assert!(
2623            result.is_empty(),
2624            "Should not flag YAML keys or correctly capitalized values: {result:?}"
2625        );
2626    }
2627
2628    #[test]
2629    fn test_frontmatter_yaml_values_flagged() {
2630        // Incorrectly capitalized names in YAML values should be flagged.
2631        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2632
2633        let content = "---\ntitle: Heading\nkey: a test value\n---\n\nTest\n";
2634        let ctx = create_context(content);
2635        let result = rule.check(&ctx).unwrap();
2636
2637        // "test" in the YAML value (line 3) SHOULD be flagged
2638        assert_eq!(result.len(), 1, "Should flag 'test' in YAML value: {result:?}");
2639        assert_eq!(result[0].line, 3);
2640        assert_eq!(result[0].column, 8); // "key: a " = 7 chars, then "test" at column 8
2641    }
2642
2643    #[test]
2644    fn test_frontmatter_key_matches_name_not_flagged() {
2645        // A YAML key that happens to match a configured name should NOT be flagged.
2646        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2647
2648        let content = "---\ntest: other value\n---\n\nBody text\n";
2649        let ctx = create_context(content);
2650        let result = rule.check(&ctx).unwrap();
2651
2652        assert!(
2653            result.is_empty(),
2654            "Should not flag YAML key that matches configured name: {result:?}"
2655        );
2656    }
2657
2658    #[test]
2659    fn test_frontmatter_empty_value_not_flagged() {
2660        // YAML key with no value should be skipped entirely.
2661        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2662
2663        let content = "---\ntest:\ntest: \n---\n\nBody text\n";
2664        let ctx = create_context(content);
2665        let result = rule.check(&ctx).unwrap();
2666
2667        assert!(
2668            result.is_empty(),
2669            "Should not flag YAML keys with empty values: {result:?}"
2670        );
2671    }
2672
2673    #[test]
2674    fn test_frontmatter_nested_yaml_key_not_flagged() {
2675        // Nested/indented YAML keys should also be skipped.
2676        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2677
2678        let content = "---\nparent:\n  test: nested value\n---\n\nBody text\n";
2679        let ctx = create_context(content);
2680        let result = rule.check(&ctx).unwrap();
2681
2682        // "test" as a nested key should NOT be flagged
2683        assert!(result.is_empty(), "Should not flag nested YAML keys: {result:?}");
2684    }
2685
2686    #[test]
2687    fn test_frontmatter_list_items_checked() {
2688        // YAML list items are values and should be checked for proper names.
2689        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2690
2691        let content = "---\ntags:\n  - test\n  - other\n---\n\nBody text\n";
2692        let ctx = create_context(content);
2693        let result = rule.check(&ctx).unwrap();
2694
2695        // "test" as a list item value SHOULD be flagged
2696        assert_eq!(result.len(), 1, "Should flag 'test' in YAML list item: {result:?}");
2697        assert_eq!(result[0].line, 3);
2698    }
2699
2700    #[test]
2701    fn test_frontmatter_value_with_multiple_colons() {
2702        // For "key: value: more", key is before first colon.
2703        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2704
2705        let content = "---\ntest: description: a test thing\n---\n\nBody text\n";
2706        let ctx = create_context(content);
2707        let result = rule.check(&ctx).unwrap();
2708
2709        // "test" as key should NOT be flagged
2710        // "test" in value portion ("description: a test thing") SHOULD be flagged
2711        assert_eq!(
2712            result.len(),
2713            1,
2714            "Should flag 'test' in value after first colon: {result:?}"
2715        );
2716        assert_eq!(result[0].line, 2);
2717        assert!(result[0].column > 6, "Violation column should be in value portion");
2718    }
2719
2720    #[test]
2721    fn test_frontmatter_does_not_affect_body() {
2722        // Body text after frontmatter should still be fully checked.
2723        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2724
2725        let content = "---\ntitle: Heading\n---\n\ntest should be flagged here\n";
2726        let ctx = create_context(content);
2727        let result = rule.check(&ctx).unwrap();
2728
2729        assert_eq!(result.len(), 1, "Should flag 'test' in body text: {result:?}");
2730        assert_eq!(result[0].line, 5);
2731    }
2732
2733    #[test]
2734    fn test_frontmatter_fix_corrects_values_preserves_keys() {
2735        // Fix should correct YAML values but preserve keys.
2736        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2737
2738        let content = "---\ntest: a test value\n---\n\ntest here\n";
2739        let ctx = create_context(content);
2740        let fixed = rule.fix(&ctx).unwrap();
2741
2742        // Key "test" should remain lowercase; value "test" should become "Test"
2743        assert_eq!(fixed, "---\ntest: a Test value\n---\n\nTest here\n");
2744    }
2745
2746    #[test]
2747    fn test_frontmatter_multiword_value_flagged() {
2748        // Multiple proper names in a single YAML value should all be flagged.
2749        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2750
2751        let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2752        let ctx = create_context(content);
2753        let result = rule.check(&ctx).unwrap();
2754
2755        assert_eq!(result.len(), 2, "Should flag both names in YAML value: {result:?}");
2756        assert!(result.iter().all(|w| w.line == 2));
2757    }
2758
2759    #[test]
2760    fn test_frontmatter_yaml_comments_not_checked() {
2761        // YAML comments inside frontmatter should be skipped entirely.
2762        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2763
2764        let content = "---\n# test comment\ntitle: Heading\n---\n\nBody text\n";
2765        let ctx = create_context(content);
2766        let result = rule.check(&ctx).unwrap();
2767
2768        assert!(result.is_empty(), "Should not flag names in YAML comments: {result:?}");
2769    }
2770
2771    #[test]
2772    fn test_frontmatter_delimiters_not_checked() {
2773        // Frontmatter delimiter lines (--- or +++) should never be checked.
2774        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2775
2776        let content = "---\ntitle: Heading\n---\n\ntest here\n";
2777        let ctx = create_context(content);
2778        let result = rule.check(&ctx).unwrap();
2779
2780        // Only the body "test" on line 5 should be flagged
2781        assert_eq!(result.len(), 1, "Should only flag body text: {result:?}");
2782        assert_eq!(result[0].line, 5);
2783    }
2784
2785    #[test]
2786    fn test_frontmatter_continuation_lines_checked() {
2787        // Continuation lines (indented, no colon) are value content and should be checked.
2788        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2789
2790        let content = "---\ndescription: >\n  a test value\n  continued here\n---\n\nBody\n";
2791        let ctx = create_context(content);
2792        let result = rule.check(&ctx).unwrap();
2793
2794        // "test" on the continuation line should be flagged
2795        assert_eq!(result.len(), 1, "Should flag 'test' in continuation line: {result:?}");
2796        assert_eq!(result[0].line, 3);
2797    }
2798
2799    #[test]
2800    fn test_frontmatter_quoted_values_checked() {
2801        // Quoted YAML values should have their content checked (inside the quotes).
2802        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2803
2804        let content = "---\ntitle: \"a test title\"\n---\n\nBody\n";
2805        let ctx = create_context(content);
2806        let result = rule.check(&ctx).unwrap();
2807
2808        assert_eq!(result.len(), 1, "Should flag 'test' in quoted YAML value: {result:?}");
2809        assert_eq!(result[0].line, 2);
2810    }
2811
2812    #[test]
2813    fn test_frontmatter_single_quoted_values_checked() {
2814        // Single-quoted YAML values should have their content checked.
2815        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2816
2817        let content = "---\ntitle: 'a test title'\n---\n\nBody\n";
2818        let ctx = create_context(content);
2819        let result = rule.check(&ctx).unwrap();
2820
2821        assert_eq!(
2822            result.len(),
2823            1,
2824            "Should flag 'test' in single-quoted YAML value: {result:?}"
2825        );
2826        assert_eq!(result[0].line, 2);
2827    }
2828
2829    #[test]
2830    fn test_frontmatter_fix_multiword_values() {
2831        // Fix should correct all proper names in frontmatter values.
2832        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
2833
2834        let content = "---\ndescription: Learn javascript and typescript\n---\n\nBody\n";
2835        let ctx = create_context(content);
2836        let fixed = rule.fix(&ctx).unwrap();
2837
2838        assert_eq!(
2839            fixed,
2840            "---\ndescription: Learn JavaScript and TypeScript\n---\n\nBody\n"
2841        );
2842    }
2843
2844    #[test]
2845    fn test_frontmatter_fix_preserves_yaml_structure() {
2846        // Fix should preserve YAML structure while correcting values.
2847        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2848
2849        let content = "---\ntags:\n  - test\n  - other\ntitle: a test doc\n---\n\ntest body\n";
2850        let ctx = create_context(content);
2851        let fixed = rule.fix(&ctx).unwrap();
2852
2853        assert_eq!(
2854            fixed,
2855            "---\ntags:\n  - Test\n  - other\ntitle: a Test doc\n---\n\nTest body\n"
2856        );
2857    }
2858
2859    #[test]
2860    fn test_frontmatter_toml_delimiters_not_checked() {
2861        // TOML frontmatter with +++ delimiters should also be handled.
2862        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2863
2864        let content = "+++\ntitle = \"a test title\"\n+++\n\ntest body\n";
2865        let ctx = create_context(content);
2866        let result = rule.check(&ctx).unwrap();
2867
2868        // "title" as TOML key should NOT be flagged
2869        // "test" in TOML quoted value SHOULD be flagged (line 2)
2870        // "test" in body SHOULD be flagged (line 5)
2871        assert_eq!(result.len(), 2, "Should flag TOML value and body: {result:?}");
2872        let fm_violations: Vec<_> = result.iter().filter(|w| w.line == 2).collect();
2873        assert_eq!(fm_violations.len(), 1, "Should flag 'test' in TOML value: {result:?}");
2874        let body_violations: Vec<_> = result.iter().filter(|w| w.line == 5).collect();
2875        assert_eq!(body_violations.len(), 1, "Should flag body 'test': {result:?}");
2876    }
2877
2878    #[test]
2879    fn test_frontmatter_toml_key_not_flagged() {
2880        // TOML keys should NOT be flagged, only values.
2881        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2882
2883        let content = "+++\ntest = \"other value\"\n+++\n\nBody text\n";
2884        let ctx = create_context(content);
2885        let result = rule.check(&ctx).unwrap();
2886
2887        assert!(
2888            result.is_empty(),
2889            "Should not flag TOML key that matches configured name: {result:?}"
2890        );
2891    }
2892
2893    #[test]
2894    fn test_frontmatter_toml_fix_preserves_keys() {
2895        // Fix should correct TOML values but preserve keys.
2896        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2897
2898        let content = "+++\ntest = \"a test value\"\n+++\n\ntest here\n";
2899        let ctx = create_context(content);
2900        let fixed = rule.fix(&ctx).unwrap();
2901
2902        // Key "test" should remain lowercase; value "test" should become "Test"
2903        assert_eq!(fixed, "+++\ntest = \"a Test value\"\n+++\n\nTest here\n");
2904    }
2905
2906    #[test]
2907    fn test_frontmatter_list_item_mapping_key_not_flagged() {
2908        // In "- test: nested value", "test" is a YAML key within a list-item mapping.
2909        // The key should NOT be flagged; only the value should be checked.
2910        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2911
2912        let content = "---\nitems:\n  - test: nested value\n---\n\nBody text\n";
2913        let ctx = create_context(content);
2914        let result = rule.check(&ctx).unwrap();
2915
2916        assert!(
2917            result.is_empty(),
2918            "Should not flag YAML key in list-item mapping: {result:?}"
2919        );
2920    }
2921
2922    #[test]
2923    fn test_frontmatter_list_item_mapping_value_flagged() {
2924        // In "- key: test value", the value portion should be checked.
2925        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2926
2927        let content = "---\nitems:\n  - key: a test value\n---\n\nBody text\n";
2928        let ctx = create_context(content);
2929        let result = rule.check(&ctx).unwrap();
2930
2931        assert_eq!(
2932            result.len(),
2933            1,
2934            "Should flag 'test' in list-item mapping value: {result:?}"
2935        );
2936        assert_eq!(result[0].line, 3);
2937    }
2938
2939    #[test]
2940    fn test_frontmatter_bare_list_item_still_flagged() {
2941        // Bare list items without a colon (e.g., "- test") are values and should be flagged.
2942        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2943
2944        let content = "---\ntags:\n  - test\n  - other\n---\n\nBody text\n";
2945        let ctx = create_context(content);
2946        let result = rule.check(&ctx).unwrap();
2947
2948        assert_eq!(result.len(), 1, "Should flag 'test' in bare list item: {result:?}");
2949        assert_eq!(result[0].line, 3);
2950    }
2951
2952    #[test]
2953    fn test_frontmatter_flow_mapping_not_flagged() {
2954        // Flow mappings like {test: value} contain YAML keys that should not be flagged.
2955        // The entire flow construct should be skipped.
2956        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2957
2958        let content = "---\nflow_map: {test: value, other: test}\n---\n\nBody text\n";
2959        let ctx = create_context(content);
2960        let result = rule.check(&ctx).unwrap();
2961
2962        assert!(
2963            result.is_empty(),
2964            "Should not flag names inside flow mappings: {result:?}"
2965        );
2966    }
2967
2968    #[test]
2969    fn test_frontmatter_flow_sequence_not_flagged() {
2970        // Flow sequences like [test, other] should also be skipped.
2971        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2972
2973        let content = "---\nitems: [test, other, test]\n---\n\nBody text\n";
2974        let ctx = create_context(content);
2975        let result = rule.check(&ctx).unwrap();
2976
2977        assert!(
2978            result.is_empty(),
2979            "Should not flag names inside flow sequences: {result:?}"
2980        );
2981    }
2982
2983    #[test]
2984    fn test_frontmatter_list_item_mapping_fix_preserves_key() {
2985        // Fix should correct values in list-item mappings but preserve keys.
2986        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
2987
2988        let content = "---\nitems:\n  - test: a test value\n---\n\ntest here\n";
2989        let ctx = create_context(content);
2990        let fixed = rule.fix(&ctx).unwrap();
2991
2992        // "test" as list-item key should remain lowercase;
2993        // "test" in value portion should become "Test"
2994        assert_eq!(fixed, "---\nitems:\n  - test: a Test value\n---\n\nTest here\n");
2995    }
2996
2997    #[test]
2998    fn test_frontmatter_backtick_code_not_flagged() {
2999        // Names inside backticks in frontmatter should NOT be flagged when code_blocks=false.
3000        let config = MD044Config {
3001            names: vec!["GoodApplication".to_string()],
3002            code_blocks: false,
3003            ..MD044Config::default()
3004        };
3005        let rule = MD044ProperNames::from_config_struct(config);
3006
3007        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3008        let ctx = create_context(content);
3009        let result = rule.check(&ctx).unwrap();
3010
3011        // Neither the frontmatter nor the body backtick-wrapped name should be flagged
3012        assert!(
3013            result.is_empty(),
3014            "Should not flag names inside backticks in frontmatter or body: {result:?}"
3015        );
3016    }
3017
3018    #[test]
3019    fn test_frontmatter_unquoted_backtick_code_not_flagged() {
3020        // Exact case from issue #513: unquoted YAML frontmatter with backticks
3021        let config = MD044Config {
3022            names: vec!["GoodApplication".to_string()],
3023            code_blocks: false,
3024            ..MD044Config::default()
3025        };
3026        let rule = MD044ProperNames::from_config_struct(config);
3027
3028        let content = "---\ntitle: `goodapplication` CLI\n---\n\nIntroductory `goodapplication` CLI text.\n";
3029        let ctx = create_context(content);
3030        let result = rule.check(&ctx).unwrap();
3031
3032        assert!(
3033            result.is_empty(),
3034            "Should not flag names inside backticks in unquoted YAML frontmatter: {result:?}"
3035        );
3036    }
3037
3038    #[test]
3039    fn test_frontmatter_bare_name_still_flagged_with_backtick_nearby() {
3040        // Names outside backticks in frontmatter should still be flagged.
3041        let config = MD044Config {
3042            names: vec!["GoodApplication".to_string()],
3043            code_blocks: false,
3044            ..MD044Config::default()
3045        };
3046        let rule = MD044ProperNames::from_config_struct(config);
3047
3048        let content = "---\ntitle: goodapplication `goodapplication` CLI\n---\n\nBody\n";
3049        let ctx = create_context(content);
3050        let result = rule.check(&ctx).unwrap();
3051
3052        // Only the bare "goodapplication" (before backticks) should be flagged
3053        assert_eq!(
3054            result.len(),
3055            1,
3056            "Should flag bare name but not backtick-wrapped name: {result:?}"
3057        );
3058        assert_eq!(result[0].line, 2);
3059        assert_eq!(result[0].column, 8); // "title: " = 7 chars, name at column 8
3060    }
3061
3062    #[test]
3063    fn test_frontmatter_backtick_code_with_code_blocks_true() {
3064        // When code_blocks=true, names inside backticks ARE checked.
3065        let config = MD044Config {
3066            names: vec!["GoodApplication".to_string()],
3067            code_blocks: true,
3068            ..MD044Config::default()
3069        };
3070        let rule = MD044ProperNames::from_config_struct(config);
3071
3072        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nBody\n";
3073        let ctx = create_context(content);
3074        let result = rule.check(&ctx).unwrap();
3075
3076        // With code_blocks=true, backtick-wrapped name SHOULD be flagged
3077        assert_eq!(
3078            result.len(),
3079            1,
3080            "Should flag backtick-wrapped name when code_blocks=true: {result:?}"
3081        );
3082        assert_eq!(result[0].line, 2);
3083    }
3084
3085    #[test]
3086    fn test_frontmatter_fix_preserves_backtick_code() {
3087        // Fix should NOT change names inside backticks in frontmatter.
3088        let config = MD044Config {
3089            names: vec!["GoodApplication".to_string()],
3090            code_blocks: false,
3091            ..MD044Config::default()
3092        };
3093        let rule = MD044ProperNames::from_config_struct(config);
3094
3095        let content = "---\ntitle: \"`goodapplication` CLI\"\n---\n\nIntroductory `goodapplication` CLI text.\n";
3096        let ctx = create_context(content);
3097        let fixed = rule.fix(&ctx).unwrap();
3098
3099        // Neither backtick-wrapped occurrence should be changed
3100        assert_eq!(
3101            fixed, content,
3102            "Fix should not modify names inside backticks in frontmatter"
3103        );
3104    }
3105
3106    fn rule_ignoring(names: &[&str], ignore: &[&str]) -> MD044ProperNames {
3107        MD044ProperNames::from_config_struct(MD044Config {
3108            names: names.iter().map(ToString::to_string).collect(),
3109            ignore_frontmatter_fields: Some(ignore.iter().map(ToString::to_string).collect()),
3110            ..Default::default()
3111        })
3112    }
3113
3114    #[test]
3115    fn test_ignore_frontmatter_field_suppresses_only_that_field() {
3116        let content = "---\ntitle: Heading for myapp\nslug: myapp-guide\n---\n";
3117        let rule = rule_ignoring(&["MyApp"], &["slug"]);
3118        let result = rule.check(&create_context(content)).unwrap();
3119        assert_eq!(result.len(), 1, "only title is flagged: {result:?}");
3120        assert_eq!(result[0].line, 2);
3121    }
3122
3123    #[test]
3124    fn test_ignore_frontmatter_field_is_case_insensitive() {
3125        let content = "---\nSlug: myapp-guide\n---\n";
3126        let rule = rule_ignoring(&["MyApp"], &["SLUG"]);
3127        assert!(rule.check(&create_context(content)).unwrap().is_empty());
3128    }
3129
3130    #[test]
3131    fn test_ignore_frontmatter_field_covers_nested_subtree() {
3132        let content = "---\nseo:\n  canonical: myapp\n  keywords:\n    - myapp\n---\n";
3133        let rule = rule_ignoring(&["MyApp"], &["seo"]);
3134        assert!(rule.check(&create_context(content)).unwrap().is_empty());
3135    }
3136
3137    #[test]
3138    fn test_ignore_frontmatter_field_does_not_affect_body() {
3139        let content = "---\nslug: myapp\n---\n\nBody mentions myapp.\n";
3140        let rule = rule_ignoring(&["MyApp"], &["slug"]);
3141        let result = rule.check(&create_context(content)).unwrap();
3142        assert_eq!(result.len(), 1);
3143        assert_eq!(result[0].line, 5);
3144    }
3145
3146    #[test]
3147    fn test_ignore_frontmatter_field_toml_table() {
3148        let content = "+++\n[seo]\ncanonical = \"myapp\"\n+++\n";
3149        let rule = rule_ignoring(&["MyApp"], &["seo"]);
3150        assert!(rule.check(&create_context(content)).unwrap().is_empty());
3151    }
3152
3153    // --- Angle-bracket URL tests (issue #457) ---
3154
3155    #[test]
3156    fn test_angle_bracket_url_in_html_comment_not_flagged() {
3157        // Angle-bracket URLs inside HTML comments should be skipped
3158        let config = MD044Config {
3159            names: vec!["Test".to_string()],
3160            ..MD044Config::default()
3161        };
3162        let rule = MD044ProperNames::from_config_struct(config);
3163
3164        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";
3165        let ctx = create_context(content);
3166        let result = rule.check(&ctx).unwrap();
3167
3168        // Line 7: "Test" in comment prose before bare URL -- already correct capitalization
3169        // Line 7: "test" in bare URL (not in angle brackets) -- but "test" is in URL domain, not prose.
3170        //   However, .example.test has "test" at a word boundary (after '.'), so it IS flagged.
3171        // Line 8: "Test" in comment prose -- correct capitalization, not flagged
3172        // Line 8: "test" in <https://www.example.test> -- inside angle-bracket URL, NOT flagged
3173
3174        // The key assertion: line 8's angle-bracket URL should NOT produce a warning
3175        let line8_warnings: Vec<_> = result.iter().filter(|w| w.line == 8).collect();
3176        assert!(
3177            line8_warnings.is_empty(),
3178            "Should not flag names inside angle-bracket URLs in HTML comments: {line8_warnings:?}"
3179        );
3180    }
3181
3182    #[test]
3183    fn test_bare_url_in_html_comment_still_flagged() {
3184        // Bare URLs (not in angle brackets) inside HTML comments should still be checked
3185        let config = MD044Config {
3186            names: vec!["Test".to_string()],
3187            ..MD044Config::default()
3188        };
3189        let rule = MD044ProperNames::from_config_struct(config);
3190
3191        let content = "<!-- This is a test https://www.example.test -->\n";
3192        let ctx = create_context(content);
3193        let result = rule.check(&ctx).unwrap();
3194
3195        // "test" appears as prose text before URL and also in the bare URL domain
3196        // At minimum, the prose "test" should be flagged
3197        assert!(
3198            !result.is_empty(),
3199            "Should flag 'test' in prose text of HTML comment with bare URL"
3200        );
3201    }
3202
3203    #[test]
3204    fn test_angle_bracket_url_in_regular_markdown_not_flagged() {
3205        // Angle-bracket URLs in regular markdown are already handled by the link parser,
3206        // but the angle-bracket check provides a safety net
3207        let rule = MD044ProperNames::new(vec!["Test".to_string()], true);
3208
3209        let content = "<https://www.example.test>\n";
3210        let ctx = create_context(content);
3211        let result = rule.check(&ctx).unwrap();
3212
3213        assert!(
3214            result.is_empty(),
3215            "Should not flag names inside angle-bracket URLs in regular markdown: {result:?}"
3216        );
3217    }
3218
3219    #[test]
3220    fn test_multiple_angle_bracket_urls_in_one_comment() {
3221        let config = MD044Config {
3222            names: vec!["Test".to_string()],
3223            ..MD044Config::default()
3224        };
3225        let rule = MD044ProperNames::from_config_struct(config);
3226
3227        let content = "<!-- See <https://test.example.com> and <https://www.example.test> for details -->\n";
3228        let ctx = create_context(content);
3229        let result = rule.check(&ctx).unwrap();
3230
3231        // Both URLs are inside angle brackets, so "test" inside them should NOT be flagged
3232        assert!(
3233            result.is_empty(),
3234            "Should not flag names inside multiple angle-bracket URLs: {result:?}"
3235        );
3236    }
3237
3238    #[test]
3239    fn test_angle_bracket_non_url_still_flagged() {
3240        // <Test> is NOT a URL (no scheme), so is_in_angle_bracket_url does NOT protect it.
3241        // Whether it gets flagged depends on HTML tag detection, not on our URL check.
3242        assert!(
3243            !MD044ProperNames::is_in_angle_bracket_url("<test> which is not a URL.", 1),
3244            "is_in_angle_bracket_url should return false for non-URL angle brackets"
3245        );
3246    }
3247
3248    #[test]
3249    fn test_angle_bracket_mailto_url_not_flagged() {
3250        let config = MD044Config {
3251            names: vec!["Test".to_string()],
3252            ..MD044Config::default()
3253        };
3254        let rule = MD044ProperNames::from_config_struct(config);
3255
3256        let content = "<!-- Contact <mailto:test@example.com> for help -->\n";
3257        let ctx = create_context(content);
3258        let result = rule.check(&ctx).unwrap();
3259
3260        assert!(
3261            result.is_empty(),
3262            "Should not flag names inside angle-bracket mailto URLs: {result:?}"
3263        );
3264    }
3265
3266    #[test]
3267    fn test_angle_bracket_ftp_url_not_flagged() {
3268        let config = MD044Config {
3269            names: vec!["Test".to_string()],
3270            ..MD044Config::default()
3271        };
3272        let rule = MD044ProperNames::from_config_struct(config);
3273
3274        let content = "<!-- Download from <ftp://test.example.com/file> -->\n";
3275        let ctx = create_context(content);
3276        let result = rule.check(&ctx).unwrap();
3277
3278        assert!(
3279            result.is_empty(),
3280            "Should not flag names inside angle-bracket FTP URLs: {result:?}"
3281        );
3282    }
3283
3284    #[test]
3285    fn test_angle_bracket_url_fix_preserves_url() {
3286        // Fix should not modify text inside angle-bracket URLs
3287        let config = MD044Config {
3288            names: vec!["Test".to_string()],
3289            ..MD044Config::default()
3290        };
3291        let rule = MD044ProperNames::from_config_struct(config);
3292
3293        let content = "<!-- test text <https://www.example.test> -->\n";
3294        let ctx = create_context(content);
3295        let fixed = rule.fix(&ctx).unwrap();
3296
3297        // "test" in prose should be fixed, URL should be preserved
3298        assert!(
3299            fixed.contains("<https://www.example.test>"),
3300            "Fix should preserve angle-bracket URLs: {fixed}"
3301        );
3302        assert!(
3303            fixed.contains("Test text"),
3304            "Fix should correct prose 'test' to 'Test': {fixed}"
3305        );
3306    }
3307
3308    #[test]
3309    fn test_fallback_delimiter_index_matches_direct_scans() {
3310        let alphabet = [b'a', b'\\', b'[', b']', b'(', b')'];
3311        for mut value in 0..alphabet.len().pow(6) {
3312            let mut bytes = [0; 6];
3313            for byte in &mut bytes {
3314                *byte = alphabet[value % alphabet.len()];
3315                value /= alphabet.len();
3316            }
3317            for (open, close) in [(b'[', b']'), (b'(', b')')] {
3318                let mut lookup = FallbackDelimiters {
3319                    scanned_bytes: bytes.len() * 2,
3320                    ..Default::default()
3321                };
3322                for from in (1..=bytes.len()).chain((1..=bytes.len()).rev()) {
3323                    if bytes[from - 1] == open {
3324                        assert_eq!(
3325                            lookup.find(&bytes, from, open, close),
3326                            FallbackDelimiters::scan(&bytes, from, open, close),
3327                            "{bytes:?}, {from}, {open}"
3328                        );
3329                    }
3330                }
3331            }
3332        }
3333    }
3334
3335    #[test]
3336    fn test_fallback_ranges_match_scalar_queries() {
3337        fn scalar_links(line: &str, pos: usize) -> bool {
3338            let bytes = line.as_bytes();
3339            let len = bytes.len();
3340            let mut i = 0;
3341
3342            while i < len {
3343                // Look for unescaped '[' (handle double-escaped \\[ as unescaped)
3344                if bytes[i] == b'[' && (i == 0 || bytes[i - 1] != b'\\' || (i >= 2 && bytes[i - 2] == b'\\')) {
3345                    // Find matching ']' handling nested brackets
3346                    let mut depth: u32 = 1;
3347                    let mut j = i + 1;
3348                    while j < len && depth > 0 {
3349                        match bytes[j] {
3350                            b'\\' => {
3351                                j += 1; // skip escaped char
3352                            }
3353                            b'[' => depth += 1,
3354                            b']' => depth -= 1,
3355                            _ => {}
3356                        }
3357                        j += 1;
3358                    }
3359
3360                    // j is now one past the ']'
3361                    if depth == 0 && j < len {
3362                        if bytes[j] == b'(' {
3363                            // Inline link: [text](url)
3364                            let url_start = j;
3365                            let mut paren_depth: u32 = 1;
3366                            let mut k = j + 1;
3367                            while k < len && paren_depth > 0 {
3368                                match bytes[k] {
3369                                    b'\\' => {
3370                                        k += 1; // skip escaped char
3371                                    }
3372                                    b'(' => paren_depth += 1,
3373                                    b')' => paren_depth -= 1,
3374                                    _ => {}
3375                                }
3376                                k += 1;
3377                            }
3378
3379                            if paren_depth == 0 {
3380                                if pos > url_start && pos < k {
3381                                    return true;
3382                                }
3383                                i = k;
3384                                continue;
3385                            }
3386                        } else if bytes[j] == b'[' {
3387                            // Reference link: [text][ref]
3388                            let ref_start = j;
3389                            let mut ref_depth: u32 = 1;
3390                            let mut k = j + 1;
3391                            while k < len && ref_depth > 0 {
3392                                match bytes[k] {
3393                                    b'\\' => {
3394                                        k += 1;
3395                                    }
3396                                    b'[' => ref_depth += 1,
3397                                    b']' => ref_depth -= 1,
3398                                    _ => {}
3399                                }
3400                                k += 1;
3401                            }
3402
3403                            if ref_depth == 0 {
3404                                if pos > ref_start && pos < k {
3405                                    return true;
3406                                }
3407                                i = k;
3408                                continue;
3409                            }
3410                        }
3411                    }
3412                }
3413                i += 1;
3414            }
3415            false
3416        }
3417
3418        fn scalar_code(line: &str, pos: usize) -> bool {
3419            let bytes = line.as_bytes();
3420            let len = bytes.len();
3421            let mut i = 0;
3422            while i < len {
3423                if bytes[i] == b'`' {
3424                    // Count the opening backtick sequence length
3425                    let open_start = i;
3426                    while i < len && bytes[i] == b'`' {
3427                        i += 1;
3428                    }
3429                    let tick_len = i - open_start;
3430
3431                    // Scan forward for a closing sequence of exactly tick_len backticks
3432                    while i < len {
3433                        if bytes[i] == b'`' {
3434                            let close_start = i;
3435                            while i < len && bytes[i] == b'`' {
3436                                i += 1;
3437                            }
3438                            if i - close_start == tick_len {
3439                                // Matched pair found; the code span content is between
3440                                // the end of the opening backticks and the start of the
3441                                // closing backticks (exclusive of the backticks themselves).
3442                                let content_start = open_start + tick_len;
3443                                let content_end = close_start;
3444                                if pos >= content_start && pos < content_end {
3445                                    return true;
3446                                }
3447                                // Continue scanning after this pair
3448                                break;
3449                            }
3450                            // Not the right length; keep scanning
3451                        } else {
3452                            i += 1;
3453                        }
3454                    }
3455                } else {
3456                    i += 1;
3457                }
3458            }
3459            false
3460        }
3461
3462        let tokens = [
3463            "javascript",
3464            "`",
3465            "``",
3466            "```",
3467            "`javascript`",
3468            "``javascript``",
3469            "`javascript``",
3470            "\\`",
3471            "[javascript](url)",
3472            "[text][javascript]",
3473            "[[x]](url)",
3474            "[a [b] c](x(y)z)",
3475            "[a](x\\)y)",
3476            "[a][x\\]y]",
3477            "[a](x y)",
3478            "[a](x",
3479            "[a][x",
3480            "[[",
3481            "[",
3482            "]",
3483            "(",
3484            ")",
3485            "\\[",
3486            "\\\\[",
3487            "\\\\\\[",
3488            "\\\\",
3489            "é日本🙂",
3490            "<!--",
3491            "-->",
3492        ];
3493        for first in tokens {
3494            for second in tokens {
3495                let text = format!("{first}{second}");
3496                let urls = MD044ProperNames::markdown_link_url_ranges(&text);
3497                let code = MD044ProperNames::backtick_code_ranges(&text);
3498                for ranges in [&urls, &code] {
3499                    assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3500                }
3501                for byte in 0..=text.len() + 1 {
3502                    assert_eq!(
3503                        crate::inline_config::offset_in_ranges(&urls, byte),
3504                        scalar_links(&text, byte),
3505                        "links {text:?} at {byte}: {urls:?}"
3506                    );
3507                    assert_eq!(
3508                        crate::inline_config::offset_in_ranges(&code, byte),
3509                        scalar_code(&text, byte),
3510                        "code {text:?} at {byte}: {code:?}"
3511                    );
3512                }
3513            }
3514        }
3515    }
3516
3517    #[test]
3518    fn test_fallback_ranges_preserve_code_and_link_fixes() {
3519        let body =
3520            "javascript `javascript` [javascript](javascript) [javascript][javascript] <custom:javascript> CAFÉ ";
3521        let fixed =
3522            "JavaScript `javascript` [JavaScript](javascript) [JavaScript][javascript] <custom:javascript> Café ";
3523        for (prefix, suffix) in [
3524            ("<!--\n", "\n-->"),
3525            ("<div>\n", "\n</div>"),
3526            ("---\ntitle: ", "\n---\n"),
3527        ] {
3528            for ending in ["\n", "\r\n"] {
3529                let content = format!("{prefix}{}{suffix}", body.repeat(32)).replace('\n', ending);
3530                let expected = format!("{prefix}{}{suffix}", fixed.repeat(32)).replace('\n', ending);
3531                let ctx = create_context(&content);
3532                let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3533                let warnings = rule.check(&ctx).unwrap();
3534                assert_eq!(warnings.len(), 128, "{prefix:?} {ending:?}");
3535                assert_eq!(rule.check(&ctx).unwrap(), warnings);
3536                assert_eq!(rule.fix(&ctx).unwrap(), expected);
3537            }
3538        }
3539    }
3540
3541    #[test]
3542    fn test_wikilink_url_ranges_match_scalar_queries() {
3543        fn scalar(ctx: &crate::lint_context::LintContext, byte_pos: usize) -> bool {
3544            use pulldown_cmark::LinkType;
3545            let content = ctx.content.as_bytes();
3546
3547            for link in ctx.links_starting_before_or_at(byte_pos) {
3548                if !matches!(link.link_type, LinkType::WikiLink { .. }) {
3549                    continue;
3550                }
3551                let wiki_end = link.byte_end;
3552                // The WikiLink must end before byte_pos and be immediately followed by '('.
3553                if wiki_end >= byte_pos || wiki_end >= content.len() || content[wiki_end] != b'(' {
3554                    continue;
3555                }
3556                // Scan to the matching ')' tracking nested parens and backslash escapes.
3557                // Per CommonMark, an unquoted inline link destination cannot contain
3558                // spaces, tabs, or newlines. If we encounter one, this is parenthesised
3559                // prose rather than a URL, and pulldown-cmark will not parse it as a link.
3560                let mut depth: u32 = 1;
3561                let mut k = wiki_end + 1;
3562                let mut valid_destination = true;
3563                while k < content.len() && depth > 0 {
3564                    match content[k] {
3565                        b'\\' => {
3566                            k += 1; // skip escaped character
3567                        }
3568                        b'(' => depth += 1,
3569                        b')' => depth -= 1,
3570                        b' ' | b'\t' | b'\n' | b'\r' => {
3571                            valid_destination = false;
3572                            break;
3573                        }
3574                        _ => {}
3575                    }
3576                    k += 1;
3577                }
3578                // byte_pos is inside the URL if it falls between '(' and the matching ')'
3579                // and the destination is valid (no unescaped whitespace).
3580                if valid_destination && depth == 0 && byte_pos > wiki_end && byte_pos < k {
3581                    return true;
3582                }
3583            }
3584            false
3585        }
3586
3587        use crate::config::MarkdownFlavor;
3588        let tokens = [
3589            "[[target]](javascript)",
3590            "[[target]](a(b)c)",
3591            "[[target]](a\\)b)",
3592            "[[target]](a\\ b)",
3593            "[[target]](a b)",
3594            "[[target]](a\tb)",
3595            "[[target]](a\nb)",
3596            "[[target]](a\rb)",
3597            "[[target]](a",
3598            "[[target]](a[[inner]](b)c)",
3599            "[[target]](a[[inner]](b)",
3600            "\\[[target]](path)",
3601            "`[[target]](path)`",
3602            "![x](path)",
3603            "é🙂 ",
3604            "[[x]]",
3605            "\\)",
3606            "([[target]](x))",
3607        ];
3608        for flavor in [
3609            MarkdownFlavor::Standard,
3610            MarkdownFlavor::Obsidian,
3611            MarkdownFlavor::MDX,
3612            MarkdownFlavor::Quarto,
3613        ] {
3614            for first in tokens {
3615                for second in tokens {
3616                    let content = format!("{first}{second}");
3617                    let ctx = LintContext::new(&content, flavor, None);
3618                    let ranges = MD044ProperNames::wikilink_url_ranges(&ctx);
3619                    assert!(ranges.windows(2).all(|pair| pair[0].end <= pair[1].start));
3620                    for byte in 0..=content.len() + 1 {
3621                        assert_eq!(
3622                            crate::inline_config::offset_in_ranges(&ranges, byte),
3623                            scalar(&ctx, byte),
3624                            "{flavor:?} {content:?} at {byte}: {ranges:?}"
3625                        );
3626                    }
3627                }
3628            }
3629        }
3630    }
3631
3632    #[test]
3633    fn test_wikilink_destinations_preserve_prose_fixes() {
3634        use crate::config::MarkdownFlavor;
3635        for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::Obsidian] {
3636            let content = "javascript [[target]](javascript) javascript [[other]](a(javascript)b) CAFÉ";
3637            let ctx = LintContext::new(content, flavor, None);
3638            let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3639            assert_eq!(rule.check(&ctx).unwrap().len(), 3);
3640            assert_eq!(
3641                rule.fix(&ctx).unwrap(),
3642                "JavaScript [[target]](javascript) JavaScript [[other]](a(javascript)b) Café"
3643            );
3644        }
3645    }
3646
3647    #[test]
3648    fn test_repeated_name_columns_and_fixes_with_unicode() {
3649        for ending in ["\n", "\r\n"] {
3650            let line = "é日本🙂 javascript CAFÉ JavaScript <custom:javascript> ".repeat(64);
3651            let content = format!("{line}{ending}{ending}{line}");
3652            let ctx = create_context(&content);
3653            let rule = MD044ProperNames::new(vec!["JavaScript".into(), "Café".into()], false);
3654            let warnings = rule.check(&ctx).unwrap();
3655            let mut expected = Vec::new();
3656            for (line_idx, line_info) in ctx.lines.iter().enumerate() {
3657                let text = line_info.content(ctx.content);
3658                for (byte, _) in text.match_indices("javascript CAFÉ") {
3659                    for (offset, found, replacement) in [(0, "javascript", "JavaScript"), (11, "CAFÉ", "Café")] {
3660                        let start = byte + offset;
3661                        let column = text[..start].chars().count() + 1;
3662                        expected.push((
3663                            line_idx + 1,
3664                            column,
3665                            column + found.chars().count(),
3666                            line_info.byte_offset + start..line_info.byte_offset + start + found.len(),
3667                            replacement,
3668                        ));
3669                    }
3670                }
3671            }
3672            assert_eq!(warnings.len(), 256);
3673            assert_eq!(warnings.len(), expected.len());
3674            for (warning, (line, column, end_column, range, replacement)) in warnings.iter().zip(expected) {
3675                assert_eq!(
3676                    (warning.line, warning.column, warning.end_line, warning.end_column),
3677                    (line, column, line, end_column)
3678                );
3679                assert_eq!(warning.fix, Some(Fix::new(range, replacement.to_owned())));
3680            }
3681            assert_eq!(rule.check(&ctx).unwrap(), warnings, "cached diagnostics");
3682            assert_eq!(rule.clone().check(&ctx).unwrap(), warnings, "shared cache");
3683            assert_eq!(
3684                rule.fix(&ctx).unwrap(),
3685                content.replace("javascript CAFÉ", "JavaScript Café")
3686            );
3687        }
3688    }
3689
3690    #[test]
3691    fn test_angle_url_ranges_preserve_boundaries() {
3692        let line = "<bad space> <x:one> <y:two\tthree> <z:unclosed <good:last> tail";
3693        let ranges = MD044ProperNames::angle_bracket_url_ranges(line);
3694        let expected = ["<x:one>", "<y:two\tthree>", "<good:last>"];
3695        assert_eq!(ranges.len(), expected.len());
3696        for (range, text) in ranges.iter().zip(expected) {
3697            assert_eq!(&line[range.clone()], text);
3698            assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.start));
3699            assert!(MD044ProperNames::is_in_angle_bracket_url(line, range.end - 1));
3700            assert!(!MD044ProperNames::is_in_angle_bracket_url(line, range.end));
3701        }
3702    }
3703
3704    #[test]
3705    fn test_is_in_angle_bracket_url_helper() {
3706        // Direct tests of the helper function
3707        let line = "text <https://example.test> more text";
3708
3709        // Inside the URL
3710        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 5)); // '<'
3711        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 6)); // 'h'
3712        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 15)); // middle of URL
3713        assert!(MD044ProperNames::is_in_angle_bracket_url(line, 26)); // '>'
3714
3715        // Outside the URL
3716        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 0)); // 't' at start
3717        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 4)); // space before '<'
3718        assert!(!MD044ProperNames::is_in_angle_bracket_url(line, 27)); // space after '>'
3719
3720        // Non-URL angle brackets
3721        assert!(!MD044ProperNames::is_in_angle_bracket_url("<notaurl>", 1));
3722
3723        // mailto scheme
3724        assert!(MD044ProperNames::is_in_angle_bracket_url(
3725            "<mailto:test@example.com>",
3726            10
3727        ));
3728
3729        // ftp scheme
3730        assert!(MD044ProperNames::is_in_angle_bracket_url(
3731            "<ftp://test.example.com>",
3732            10
3733        ));
3734    }
3735
3736    #[test]
3737    fn test_is_in_angle_bracket_url_uppercase_scheme() {
3738        // RFC 3986: URI schemes are case-insensitive
3739        assert!(MD044ProperNames::is_in_angle_bracket_url(
3740            "<HTTPS://test.example.com>",
3741            10
3742        ));
3743        assert!(MD044ProperNames::is_in_angle_bracket_url(
3744            "<Http://test.example.com>",
3745            10
3746        ));
3747    }
3748
3749    #[test]
3750    fn test_is_in_angle_bracket_url_uncommon_schemes() {
3751        // ssh scheme
3752        assert!(MD044ProperNames::is_in_angle_bracket_url(
3753            "<ssh://test@example.com>",
3754            10
3755        ));
3756        // file scheme
3757        assert!(MD044ProperNames::is_in_angle_bracket_url("<file:///test/path>", 10));
3758        // data scheme (no authority, just colon)
3759        assert!(MD044ProperNames::is_in_angle_bracket_url("<data:text/plain;test>", 10));
3760    }
3761
3762    #[test]
3763    fn test_is_in_angle_bracket_url_unclosed() {
3764        // Unclosed angle bracket should NOT match
3765        assert!(!MD044ProperNames::is_in_angle_bracket_url(
3766            "<https://test.example.com",
3767            10
3768        ));
3769    }
3770
3771    #[test]
3772    fn test_vale_inline_config_comments_not_flagged() {
3773        let config = MD044Config {
3774            names: vec!["Vale".to_string(), "JavaScript".to_string()],
3775            ..MD044Config::default()
3776        };
3777        let rule = MD044ProperNames::from_config_struct(config);
3778
3779        let content = "\
3780<!-- vale off -->
3781Some javascript text here.
3782<!-- vale on -->
3783<!-- vale Style.Rule = NO -->
3784More javascript text.
3785<!-- vale Style.Rule = YES -->
3786<!-- vale JavaScript.Grammar = NO -->
3787";
3788        let ctx = create_context(content);
3789        let result = rule.check(&ctx).unwrap();
3790
3791        // Only the body text lines (2, 5) should be flagged for "javascript"
3792        assert_eq!(result.len(), 2, "Should only flag body lines, not Vale config comments");
3793        assert_eq!(result[0].line, 2);
3794        assert_eq!(result[1].line, 5);
3795    }
3796
3797    #[test]
3798    fn test_remark_lint_inline_config_comments_not_flagged() {
3799        let config = MD044Config {
3800            names: vec!["JavaScript".to_string()],
3801            ..MD044Config::default()
3802        };
3803        let rule = MD044ProperNames::from_config_struct(config);
3804
3805        let content = "\
3806<!-- lint disable remark-lint-some-rule -->
3807Some javascript text here.
3808<!-- lint enable remark-lint-some-rule -->
3809<!-- lint ignore remark-lint-some-rule -->
3810More javascript text.
3811";
3812        let ctx = create_context(content);
3813        let result = rule.check(&ctx).unwrap();
3814
3815        assert_eq!(
3816            result.len(),
3817            2,
3818            "Should only flag body lines, not remark-lint config comments"
3819        );
3820        assert_eq!(result[0].line, 2);
3821        assert_eq!(result[1].line, 5);
3822    }
3823
3824    #[test]
3825    fn test_fix_does_not_modify_vale_remark_lint_comments() {
3826        let config = MD044Config {
3827            names: vec!["JavaScript".to_string(), "Vale".to_string()],
3828            ..MD044Config::default()
3829        };
3830        let rule = MD044ProperNames::from_config_struct(config);
3831
3832        let content = "\
3833<!-- vale off -->
3834Some javascript text.
3835<!-- vale on -->
3836<!-- lint disable remark-lint-some-rule -->
3837More javascript text.
3838<!-- lint enable remark-lint-some-rule -->
3839";
3840        let ctx = create_context(content);
3841        let fixed = rule.fix(&ctx).unwrap();
3842
3843        // Config directive lines must be preserved unchanged
3844        assert!(fixed.contains("<!-- vale off -->"));
3845        assert!(fixed.contains("<!-- vale on -->"));
3846        assert!(fixed.contains("<!-- lint disable remark-lint-some-rule -->"));
3847        assert!(fixed.contains("<!-- lint enable remark-lint-some-rule -->"));
3848        // Body text should be fixed
3849        assert!(fixed.contains("Some JavaScript text."));
3850        assert!(fixed.contains("More JavaScript text."));
3851    }
3852
3853    #[test]
3854    fn test_mixed_tool_directives_all_skipped() {
3855        let config = MD044Config {
3856            names: vec!["JavaScript".to_string(), "Vale".to_string()],
3857            ..MD044Config::default()
3858        };
3859        let rule = MD044ProperNames::from_config_struct(config);
3860
3861        let content = "\
3862<!-- rumdl-disable MD044 -->
3863Some javascript text.
3864<!-- markdownlint-disable -->
3865More javascript text.
3866<!-- vale off -->
3867Even more javascript text.
3868<!-- lint disable some-rule -->
3869Final javascript text.
3870<!-- rumdl-enable MD044 -->
3871<!-- markdownlint-enable -->
3872<!-- vale on -->
3873<!-- lint enable some-rule -->
3874";
3875        let ctx = create_context(content);
3876        let result = rule.check(&ctx).unwrap();
3877
3878        // Only body text lines should be flagged (lines 2, 4, 6, 8)
3879        assert_eq!(
3880            result.len(),
3881            4,
3882            "Should only flag body lines, not any tool directive comments"
3883        );
3884        assert_eq!(result[0].line, 2);
3885        assert_eq!(result[1].line, 4);
3886        assert_eq!(result[2].line, 6);
3887        assert_eq!(result[3].line, 8);
3888    }
3889
3890    #[test]
3891    fn test_vale_remark_lint_edge_cases_not_matched() {
3892        let config = MD044Config {
3893            names: vec!["JavaScript".to_string(), "Vale".to_string()],
3894            ..MD044Config::default()
3895        };
3896        let rule = MD044ProperNames::from_config_struct(config);
3897
3898        // These are regular HTML comments, NOT tool directives:
3899        // - "<!-- vale -->" is not a valid Vale directive (no action keyword)
3900        // - "<!-- vale is a tool -->" starts with "vale" but is prose, not a directive
3901        // - "<!-- valedictorian javascript -->" does not start with "<!-- vale "
3902        // - "<!-- linting javascript tips -->" does not start with "<!-- lint "
3903        // - "<!-- vale javascript -->" starts with "vale" but has no action keyword
3904        // - "<!-- lint your javascript code -->" starts with "lint" but has no action keyword
3905        let content = "\
3906<!-- vale -->
3907<!-- vale is a tool for writing -->
3908<!-- valedictorian javascript -->
3909<!-- linting javascript tips -->
3910<!-- vale javascript -->
3911<!-- lint your javascript code -->
3912";
3913        let ctx = create_context(content);
3914        let result = rule.check(&ctx).unwrap();
3915
3916        // Line 1: "<!-- vale -->" contains "vale" (wrong case for "Vale") -> flagged
3917        // Line 2: "<!-- vale is a tool for writing -->" contains "vale" -> flagged
3918        // Line 3: "<!-- valedictorian javascript -->" contains "javascript" -> flagged
3919        // Line 4: "<!-- linting javascript tips -->" contains "javascript" -> flagged
3920        // Line 5: "<!-- vale javascript -->" contains "vale" and "javascript" -> flagged for both
3921        // Line 6: "<!-- lint your javascript code -->" contains "javascript" -> flagged
3922        assert_eq!(
3923            result.len(),
3924            7,
3925            "Should flag proper names in non-directive HTML comments: got {result:?}"
3926        );
3927        assert_eq!(result[0].line, 1); // "vale" in <!-- vale -->
3928        assert_eq!(result[1].line, 2); // "vale" in <!-- vale is a tool -->
3929        assert_eq!(result[2].line, 3); // "javascript" in <!-- valedictorian javascript -->
3930        assert_eq!(result[3].line, 4); // "javascript" in <!-- linting javascript tips -->
3931        assert_eq!(result[4].line, 5); // "vale" in <!-- vale javascript -->
3932        assert_eq!(result[5].line, 5); // "javascript" in <!-- vale javascript -->
3933        assert_eq!(result[6].line, 6); // "javascript" in <!-- lint your javascript code -->
3934    }
3935
3936    #[test]
3937    fn test_vale_style_directives_skipped() {
3938        let config = MD044Config {
3939            names: vec!["JavaScript".to_string(), "Vale".to_string()],
3940            ..MD044Config::default()
3941        };
3942        let rule = MD044ProperNames::from_config_struct(config);
3943
3944        // These ARE valid Vale directives and should be skipped:
3945        let content = "\
3946<!-- vale style = MyStyle -->
3947<!-- vale styles = Style1, Style2 -->
3948<!-- vale MyRule.Name = YES -->
3949<!-- vale MyRule.Name = NO -->
3950Some javascript text.
3951";
3952        let ctx = create_context(content);
3953        let result = rule.check(&ctx).unwrap();
3954
3955        // Only line 5 (body text) should be flagged
3956        assert_eq!(
3957            result.len(),
3958            1,
3959            "Should only flag body lines, not Vale style/rule directives: got {result:?}"
3960        );
3961        assert_eq!(result[0].line, 5);
3962    }
3963
3964    // --- is_in_backtick_code_in_line unit tests ---
3965
3966    #[test]
3967    fn test_backtick_code_single_backticks() {
3968        let line = "hello `world` bye";
3969        // 'w' is at index 7, inside the backtick span (content between backticks at 6 and 12)
3970        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 7));
3971        // 'h' at index 0 is outside
3972        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3973        // 'b' at index 14 is outside
3974        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 14));
3975    }
3976
3977    #[test]
3978    fn test_backtick_code_double_backticks() {
3979        let line = "a ``code`` b";
3980        // 'c' is at index 4, inside ``...``
3981        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
3982        // 'a' at index 0 is outside
3983        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
3984        // 'b' at index 11 is outside
3985        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 11));
3986    }
3987
3988    #[test]
3989    fn test_backtick_code_unclosed() {
3990        let line = "a `code b";
3991        // No closing backtick, so nothing is a code span
3992        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
3993    }
3994
3995    #[test]
3996    fn test_backtick_code_mismatched_count() {
3997        // Single backtick opening, double backtick is not a match
3998        let line = "a `code`` b";
3999        // The single ` at index 2 doesn't match `` at index 7-8
4000        // So 'c' at index 3 is NOT in a code span
4001        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 3));
4002    }
4003
4004    #[test]
4005    fn test_backtick_code_multiple_spans() {
4006        let line = "`first` and `second`";
4007        // 'f' at index 1 (inside first span)
4008        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
4009        // 'a' at index 8 (between spans)
4010        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 8));
4011        // 's' at index 13 (inside second span)
4012        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 13));
4013    }
4014
4015    #[test]
4016    fn test_backtick_code_on_backtick_boundary() {
4017        let line = "`code`";
4018        // Position 0 is the opening backtick itself, not inside the span
4019        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 0));
4020        // Position 5 is the closing backtick, not inside the span
4021        assert!(!MD044ProperNames::is_in_backtick_code_in_line(line, 5));
4022        // Position 1-4 are inside the span
4023        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 1));
4024        assert!(MD044ProperNames::is_in_backtick_code_in_line(line, 4));
4025    }
4026
4027    // Double-bracket WikiLink + URL: [[text]](url)
4028    // pulldown-cmark parses [[text]] as a WikiLink but leaves the (url)
4029    // as plain text, so ctx.links() does not cover the URL portion.
4030    // MD044 must fall back to markdown_link_url_ranges for all lines.
4031
4032    #[test]
4033    fn test_double_bracket_link_url_not_flagged() {
4034        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4035        // Exact reproduction from issue #564
4036        let content = "[[rumdl]](https://github.com/rvben/rumdl)";
4037        let ctx = create_context(content);
4038        let result = rule.check(&ctx).unwrap();
4039        assert!(
4040            result.is_empty(),
4041            "URL inside [[text]](url) must not be flagged, got: {result:?}"
4042        );
4043    }
4044
4045    #[test]
4046    fn test_double_bracket_link_url_not_fixed() {
4047        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4048        let content = "[[rumdl]](https://github.com/rvben/rumdl)\n";
4049        let ctx = create_context(content);
4050        let fixed = rule.fix(&ctx).unwrap();
4051        assert_eq!(
4052            fixed, content,
4053            "fix() must leave the URL inside [[text]](url) unchanged"
4054        );
4055    }
4056
4057    #[test]
4058    fn test_double_bracket_link_text_still_flagged() {
4059        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4060        // The link text portion [[github]](url) should still be checked.
4061        let content = "[[github]](https://example.com)";
4062        let ctx = create_context(content);
4063        let result = rule.check(&ctx).unwrap();
4064        assert_eq!(
4065            result.len(),
4066            1,
4067            "Incorrect name in [[text]] link text should still be flagged, got: {result:?}"
4068        );
4069        assert_eq!(result[0].message, "Proper name 'github' should be 'GitHub'");
4070    }
4071
4072    #[test]
4073    fn test_double_bracket_link_mixed_line() {
4074        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4075        // URL must be skipped, standalone text must be flagged.
4076        let content = "See [[rumdl]](https://github.com/rvben/rumdl) and github for more.";
4077        let ctx = create_context(content);
4078        let result = rule.check(&ctx).unwrap();
4079        assert_eq!(
4080            result.len(),
4081            1,
4082            "Only the standalone 'github' after the link should be flagged, got: {result:?}"
4083        );
4084        assert!(result[0].message.contains("'github'"));
4085        // "See " (4) + "[[rumdl]](https://github.com/rvben/rumdl)" (42) + " and " (4) = column 51
4086        assert_eq!(
4087            result[0].column, 51,
4088            "Flagged column should be the trailing 'github', not the one in the URL"
4089        );
4090    }
4091
4092    #[test]
4093    fn test_regular_link_url_still_not_flagged() {
4094        // Confirm existing [text](url) behavior is unaffected by the fix.
4095        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4096        let content = "[rumdl](https://github.com/rvben/rumdl)";
4097        let ctx = create_context(content);
4098        let result = rule.check(&ctx).unwrap();
4099        assert!(
4100            result.is_empty(),
4101            "URL inside regular [text](url) must still not be flagged, got: {result:?}"
4102        );
4103    }
4104
4105    #[test]
4106    fn test_link_like_text_in_code_span_still_flagged_when_code_blocks_enabled() {
4107        // When code-blocks = true the user explicitly opts into checking code spans.
4108        // A code span containing link-like text (`[foo](https://github.com)`) must
4109        // NOT be silently suppressed by markdown_link_url_ranges: the content is
4110        // literal characters, not a real Markdown link.
4111        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], true);
4112        let content = "`[foo](https://github.com/org/repo)`";
4113        let ctx = create_context(content);
4114        let result = rule.check(&ctx).unwrap();
4115        assert_eq!(
4116            result.len(),
4117            1,
4118            "Proper name inside a code span must be flagged when code-blocks=true, got: {result:?}"
4119        );
4120        assert!(result[0].message.contains("'github'"));
4121    }
4122
4123    #[test]
4124    fn test_malformed_link_not_treated_as_url() {
4125        // [text](url with spaces) is NOT a valid Markdown link; pulldown-cmark
4126        // does not parse it, so the name inside must still be flagged.
4127        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4128        let content = "See [rumdl](github repo) for details.";
4129        let ctx = create_context(content);
4130        let result = rule.check(&ctx).unwrap();
4131        assert_eq!(
4132            result.len(),
4133            1,
4134            "Name inside malformed [text](url with spaces) must still be flagged, got: {result:?}"
4135        );
4136        assert!(result[0].message.contains("'github'"));
4137    }
4138
4139    #[test]
4140    fn test_wikilink_followed_by_prose_parens_still_flagged() {
4141        // [[note]](github repo) — WikiLink followed by parenthesised prose, NOT
4142        // a valid link URL (space in destination). pulldown-cmark does not parse
4143        // it as a link, so the name inside must still be flagged.
4144        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4145        let content = "[[note]](github repo)";
4146        let ctx = create_context(content);
4147        let result = rule.check(&ctx).unwrap();
4148        assert_eq!(
4149            result.len(),
4150            1,
4151            "Name inside [[wikilink]](prose with spaces) must still be flagged, got: {result:?}"
4152        );
4153        assert!(result[0].message.contains("'github'"));
4154    }
4155
4156    /// Roundtrip safety: fix() output must produce zero warnings on re-check.
4157    #[test]
4158    fn test_roundtrip_fix_then_check_basic() {
4159        let rule = MD044ProperNames::new(
4160            vec![
4161                "JavaScript".to_string(),
4162                "TypeScript".to_string(),
4163                "Node.js".to_string(),
4164            ],
4165            true,
4166        );
4167        let content = "I love javascript, typescript, and nodejs!";
4168        let ctx = create_context(content);
4169        let fixed = rule.fix(&ctx).unwrap();
4170        let ctx2 = create_context(&fixed);
4171        let warnings = rule.check(&ctx2).unwrap();
4172        assert!(
4173            warnings.is_empty(),
4174            "Re-check after fix should produce zero warnings, got: {warnings:?}"
4175        );
4176    }
4177
4178    /// Roundtrip safety: fix() output must produce zero warnings for multiline content.
4179    #[test]
4180    fn test_roundtrip_fix_then_check_multiline() {
4181        let rule = MD044ProperNames::new(vec!["Rust".to_string(), "Python".to_string()], true);
4182        let content = "First line with rust.\nSecond line with python.\nThird line with RUST and PYTHON.\n";
4183        let ctx = create_context(content);
4184        let fixed = rule.fix(&ctx).unwrap();
4185        let ctx2 = create_context(&fixed);
4186        let warnings = rule.check(&ctx2).unwrap();
4187        assert!(
4188            warnings.is_empty(),
4189            "Re-check after fix should produce zero warnings, got: {warnings:?}"
4190        );
4191    }
4192
4193    /// Roundtrip safety: fix() with inline config disable blocks.
4194    #[test]
4195    fn test_roundtrip_fix_then_check_inline_config() {
4196        let config = MD044Config {
4197            names: vec!["RUMDL".to_string()],
4198            ..MD044Config::default()
4199        };
4200        let rule = MD044ProperNames::from_config_struct(config);
4201        let content =
4202            "<!-- rumdl-disable MD044 -->\nSome rumdl text.\n<!-- rumdl-enable MD044 -->\n\nSome rumdl text outside.\n";
4203        let ctx = create_context(content);
4204        let fixed = rule.fix(&ctx).unwrap();
4205        // The disabled block should be preserved, the outside text fixed
4206        assert!(
4207            fixed.contains("Some rumdl text.\n"),
4208            "Disabled block text should be preserved"
4209        );
4210        assert!(
4211            fixed.contains("Some RUMDL text outside."),
4212            "Outside text should be fixed"
4213        );
4214    }
4215
4216    /// Roundtrip safety: fix() with HTML comment content.
4217    #[test]
4218    fn test_roundtrip_fix_then_check_html_comments() {
4219        let config = MD044Config {
4220            names: vec!["JavaScript".to_string()],
4221            ..MD044Config::default()
4222        };
4223        let rule = MD044ProperNames::from_config_struct(config);
4224        let content = "# Guide\n\n<!-- javascript mentioned here -->\n\njavascript outside\n";
4225        let ctx = create_context(content);
4226        let fixed = rule.fix(&ctx).unwrap();
4227        let ctx2 = create_context(&fixed);
4228        let warnings = rule.check(&ctx2).unwrap();
4229        assert!(
4230            warnings.is_empty(),
4231            "Re-check after fix should produce zero warnings, got: {warnings:?}"
4232        );
4233    }
4234
4235    /// Roundtrip safety: fix() preserves content when no violations exist.
4236    #[test]
4237    fn test_roundtrip_no_op_when_correct() {
4238        let rule = MD044ProperNames::new(vec!["JavaScript".to_string(), "TypeScript".to_string()], true);
4239        let content = "This uses JavaScript and TypeScript correctly.\n";
4240        let ctx = create_context(content);
4241        let fixed = rule.fix(&ctx).unwrap();
4242        assert_eq!(fixed, content, "Fix should be a no-op when content is already correct");
4243    }
4244
4245    // --- Bare-domain link text: display text is the destination URL with scheme stripped ---
4246
4247    #[test]
4248    fn test_bare_domain_link_text_not_flagged() {
4249        // `[ravencentric.github.io](https://ravencentric.github.io)` — the display text
4250        // is the URL with the scheme stripped; "github" here is a domain label, not a
4251        // reference to "GitHub" the product, and must not be corrected.
4252        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4253        let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4254        let ctx = create_context(content);
4255        let result = rule.check(&ctx).unwrap();
4256        assert!(
4257            result.is_empty(),
4258            "Should not flag 'github' in a bare-domain link text that matches the link URL: {result:?}"
4259        );
4260    }
4261
4262    #[test]
4263    fn test_bare_domain_link_text_not_fixed() {
4264        // fix() must not rewrite the link text when it is the bare URL hostname.
4265        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4266        let content = "My site is [ravencentric.github.io](https://ravencentric.github.io).\n";
4267        let ctx = create_context(content);
4268        let fixed = rule.fix(&ctx).unwrap();
4269        assert_eq!(
4270            fixed, content,
4271            "fix() must not alter bare-domain link text that matches the destination URL"
4272        );
4273    }
4274
4275    #[test]
4276    fn test_bare_domain_link_text_with_path_not_flagged() {
4277        // Display text is the hostname only; destination has a path.
4278        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4279        let content = "Visit [ravencentric.github.io](https://ravencentric.github.io/projects).\n";
4280        let ctx = create_context(content);
4281        let result = rule.check(&ctx).unwrap();
4282        assert!(
4283            result.is_empty(),
4284            "Should not flag 'github' when bare-domain text is the hostname of its destination URL: {result:?}"
4285        );
4286    }
4287
4288    #[test]
4289    fn test_bare_domain_link_text_full_path_not_flagged() {
4290        // Display text is the full URL-without-scheme including a path.
4291        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4292        let content = "See [ravencentric.github.io/blog](https://ravencentric.github.io/blog).\n";
4293        let ctx = create_context(content);
4294        let result = rule.check(&ctx).unwrap();
4295        assert!(
4296            result.is_empty(),
4297            "Should not flag 'github' when link text is the full URL path without scheme: {result:?}"
4298        );
4299    }
4300
4301    #[test]
4302    fn test_github_product_name_in_link_text_still_flagged() {
4303        // `[github pages](https://pages.github.com)` — the display text is a human
4304        // description, not a bare domain; "github" should still be corrected to "GitHub".
4305        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4306        let content = "Hosted on [github pages](https://pages.github.com).\n";
4307        let ctx = create_context(content);
4308        let result = rule.check(&ctx).unwrap();
4309        assert!(
4310            !result.is_empty(),
4311            "Should still flag 'github' in descriptive link text that does not match the destination URL"
4312        );
4313    }
4314
4315    #[test]
4316    fn test_protocol_relative_bare_domain_link_text_not_flagged() {
4317        // Protocol-relative URL `[github.io](//github.io)`.
4318        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4319        let content = "See [github.io](//github.io).\n";
4320        let ctx = create_context(content);
4321        let result = rule.check(&ctx).unwrap();
4322        assert!(
4323            result.is_empty(),
4324            "Should not flag 'github' in bare-domain text matching a protocol-relative destination: {result:?}"
4325        );
4326    }
4327
4328    #[test]
4329    fn test_dotted_wikilink_target_still_flagged() {
4330        // `[[node.js]]` is a WikiLink whose page name contains a dot.
4331        // The dot guard alone does not protect it because text == url == "node.js".
4332        // The is_in_link WikiLink guard must prevent bare-domain suppression,
4333        // so the improper capitalization is still caught.
4334        let rule = MD044ProperNames::new(vec!["Node.js".to_string()], false);
4335        let content = "See [[node.js]] for details.\n";
4336        let ctx = create_context(content);
4337        let result = rule.check(&ctx).unwrap();
4338        assert!(
4339            !result.is_empty(),
4340            "Should flag 'node.js' in a dotted WikiLink target: {result:?}"
4341        );
4342    }
4343
4344    #[test]
4345    fn test_bare_domain_link_text_case_insensitive_url() {
4346        // URL with uppercase scheme `[github.io](HTTPS://github.io)` — the scheme is
4347        // case-insensitive, so the display text should still be recognised as a bare domain.
4348        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4349        let content = "See [github.io](HTTPS://github.io).\n";
4350        let ctx = create_context(content);
4351        let result = rule.check(&ctx).unwrap();
4352        assert!(
4353            result.is_empty(),
4354            "Should not flag bare-domain text when destination URL has an uppercase scheme: {result:?}"
4355        );
4356    }
4357
4358    #[test]
4359    fn test_frontmatter_value_span_strips_trailing_comment() {
4360        let line = "link: docs/guide/myapp # canonical path";
4361        let (s, e) = frontmatter_values::value_span(line).unwrap();
4362        assert_eq!(&line[s..e], "docs/guide/myapp");
4363    }
4364
4365    #[test]
4366    fn test_frontmatter_value_span_quoted_keeps_hash_and_spaces() {
4367        let line = "link: 'docs/My App/a#b'";
4368        let (s, e) = frontmatter_values::value_span(line).unwrap();
4369        assert_eq!(&line[s..e], "docs/My App/a#b");
4370    }
4371
4372    #[test]
4373    fn test_frontmatter_value_span_plain_value() {
4374        let line = "title: Heading for myapp";
4375        let (s, e) = frontmatter_values::value_span(line).unwrap();
4376        assert_eq!(&line[s..e], "Heading for myapp");
4377    }
4378
4379    #[test]
4380    fn test_frontmatter_value_span_none_for_key_only() {
4381        assert!(frontmatter_values::value_span("seo:").is_none());
4382        assert!(frontmatter_values::value_span("---").is_none());
4383    }
4384
4385    #[test]
4386    fn test_frontmatter_value_span_quoted_strips_trailing_comment() {
4387        let line = "link: 'docs/guide' # canonical path";
4388        let (s, e) = frontmatter_values::value_span(line).unwrap();
4389        assert_eq!(&line[s..e], "docs/guide");
4390    }
4391
4392    #[test]
4393    fn test_frontmatter_value_span_empty_quoted_value_is_none() {
4394        assert!(frontmatter_values::value_span("key: ''").is_none());
4395    }
4396
4397    #[test]
4398    fn test_frontmatter_value_span_unterminated_quote_strips_leading_quote() {
4399        let line = "link: 'docs/a";
4400        let (s, e) = frontmatter_values::value_span(line).unwrap();
4401        assert_eq!(&line[s..e], "docs/a");
4402    }
4403
4404    /// Byte offset of `needle` in `line`, for locating the match under test.
4405    fn at(line: &str, needle: &str) -> usize {
4406        line.find(needle).expect("needle present")
4407    }
4408
4409    #[test]
4410    fn test_path_like_exempts_single_token_frontmatter_paths() {
4411        for line in [
4412            "link: this/is/a/link/to/myapp.md",
4413            "link: docs/myapp.md",
4414            "link: /abs/path/myapp.md",
4415            "link: ./myapp.md",
4416            "link: ../shared/myapp.md",
4417        ] {
4418            let span = frontmatter_values::value_span(line).unwrap();
4419            let pos = at(line, "myapp");
4420            assert!(
4421                MD044ProperNames::is_in_path_like_token(line, pos, span),
4422                "should treat as a path: {line}"
4423            );
4424        }
4425    }
4426
4427    #[test]
4428    fn test_path_like_does_not_exempt_slash_conjunction_prose() {
4429        // Extra words around the slash-separated token mean it is not the
4430        // sole frontmatter value, so the 3+ segment path signal must not fire.
4431        let line = "description: We support github/gitlab/bitbucket imports.";
4432        let span = frontmatter_values::value_span(line).unwrap();
4433        assert!(
4434            !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4435            "slash-separated prose is not a path"
4436        );
4437
4438        let line = "description: The javascript/typescript ecosystem is large.";
4439        let span = frontmatter_values::value_span(line).unwrap();
4440        assert!(!MD044ProperNames::is_in_path_like_token(
4441            line,
4442            at(line, "javascript"),
4443            span
4444        ));
4445    }
4446
4447    #[test]
4448    fn test_path_like_requires_a_slash_so_dotted_names_survive() {
4449        let line = "title: Use nodejs and myapp.md today.";
4450        let span = frontmatter_values::value_span(line).unwrap();
4451        assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4452    }
4453
4454    #[test]
4455    fn test_path_like_no_slash_frontmatter_value_still_flagged() {
4456        // A frontmatter value with no slash at all is never a path signal,
4457        // regardless of it being the sole value; the mandatory slash is what
4458        // protects dotted proper names like `Node.js` without over-exempting
4459        // plain slugs.
4460        let line = "slug: myapp-guide";
4461        let span = frontmatter_values::value_span(line).unwrap();
4462        assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4463    }
4464
4465    #[test]
4466    fn test_path_like_returns_false_outside_value_span() {
4467        // A match outside the frontmatter value span (e.g. in the key) is
4468        // rejected immediately, before any token-bound scanning happens.
4469        let line = "myapp: docs/guide/myapp";
4470        let span = frontmatter_values::value_span(line).unwrap();
4471        let key_pos = 0;
4472        assert!(!MD044ProperNames::is_in_path_like_token(line, key_pos, span));
4473    }
4474
4475    #[test]
4476    fn test_path_like_three_segments_only_as_sole_frontmatter_value() {
4477        let line = "link: docs/guide/myapp";
4478        let span = frontmatter_values::value_span(line).unwrap();
4479        assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4480
4481        let line = "description: We support github/gitlab/bitbucket now";
4482        let span = frontmatter_values::value_span(line).unwrap();
4483        assert!(
4484            !MD044ProperNames::is_in_path_like_token(line, at(line, "github"), span),
4485            "multi-token value gets body treatment"
4486        );
4487    }
4488
4489    #[test]
4490    fn test_path_like_quoted_value_with_spaces() {
4491        // The collapsed token has a real extension on its last segment, so
4492        // signal (b) exempts it regardless of the multi-word collapse.
4493        let line = "link: 'docs/My App/myapp.md'";
4494        let span = frontmatter_values::value_span(line).unwrap();
4495        assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4496    }
4497
4498    #[test]
4499    fn test_path_like_quoted_value_with_spaces_no_extension_not_exempt() {
4500        // Same shape as above but without an extension: the collapsed token
4501        // only has the 3+ segment signal available, which is deliberately
4502        // restricted to single-token values (see `is_multi_word_collapse` in
4503        // `is_in_path_like_token`). An extensionless path containing a
4504        // literal space is rare enough that this is an accepted narrowing,
4505        // not an oversight.
4506        let line = "link: 'docs/My App/myapp'";
4507        let span = frontmatter_values::value_span(line).unwrap();
4508        assert!(!MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4509    }
4510
4511    #[test]
4512    fn test_path_like_trailing_comment_is_still_sole_value() {
4513        let line = "link: docs/guide/myapp # canonical path";
4514        let span = frontmatter_values::value_span(line).unwrap();
4515        assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4516    }
4517
4518    #[test]
4519    fn test_path_like_trailing_punctuation_trimmed() {
4520        let line = "link: docs/myapp.md, then leave.";
4521        let span = frontmatter_values::value_span(line).unwrap();
4522        assert!(MD044ProperNames::is_in_path_like_token(line, at(line, "myapp"), span));
4523    }
4524
4525    #[test]
4526    fn test_trim_token_bounds_reaches_fixpoint_after_punctuation_exposes_wrapper() {
4527        let line = r#"See "docs/myapp.md", then leave."#;
4528        let raw_start = at(line, "\"docs");
4529        let raw_end = raw_start + r#""docs/myapp.md","#.len();
4530        assert_eq!(&line[raw_start..raw_end], r#""docs/myapp.md","#);
4531        let (start, end) = frontmatter_values::trim_token_bounds(line, raw_start, raw_end);
4532        assert_eq!(&line[start..end], "docs/myapp.md");
4533    }
4534
4535    #[test]
4536    fn test_trim_token_bounds_reaches_fixpoint_with_multiple_trailing_wrappers() {
4537        let line = r#"("docs/myapp.md")."#;
4538        let (start, end) = frontmatter_values::trim_token_bounds(line, 0, line.len());
4539        assert_eq!(&line[start..end], "docs/myapp.md");
4540    }
4541
4542    #[test]
4543    fn test_frontmatter_link_path_not_flagged() {
4544        let content = "---\ntitle: Heading for MyApp\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4545        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4546        let ctx = create_context(content);
4547        let result = rule.check(&ctx).unwrap();
4548        assert!(
4549            result.is_empty(),
4550            "path in a frontmatter value must not be flagged: {result:?}"
4551        );
4552    }
4553
4554    #[test]
4555    fn test_fix_does_not_corrupt_frontmatter_link_path() {
4556        let content = "---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\nBody.\n";
4557        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4558        let ctx = create_context(content);
4559        assert_eq!(rule.fix(&ctx).unwrap(), content, "fix must not rewrite a path");
4560    }
4561
4562    // The next few tests document the deliberate frontmatter-only scope: a
4563    // body occurrence that sits inside text shaped like a file path (a
4564    // parenthesised disambiguator, a bracketed dynamic segment, a Next.js
4565    // catch-all route) is corrected exactly like any other prose occurrence,
4566    // and only that single word changes. Earlier attempts at exempting
4567    // body-prose paths corrupted these exact shapes (splitting mid-token on
4568    // wrapper characters, or on `[[`/`]]`/`](` sequences); asserting the
4569    // plain single-word correction here guards against that class of bug
4570    // reappearing without silently reintroducing the abandoned exemption.
4571
4572    #[test]
4573    fn test_body_prose_parenthesized_disambiguator_is_case_corrected() {
4574        let content = "See docs/myapp(1).md here.\n";
4575        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4576        let ctx = create_context(content);
4577        let result = rule.check(&ctx).unwrap();
4578        assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4579        assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp(1).md here.\n");
4580    }
4581
4582    #[test]
4583    fn test_body_prose_bracketed_dynamic_segment_is_case_corrected() {
4584        let content = "See docs/[myapp].md here.\n";
4585        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4586        let ctx = create_context(content);
4587        let result = rule.check(&ctx).unwrap();
4588        assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4589        assert_eq!(rule.fix(&ctx).unwrap(), "See docs/[MyApp].md here.\n");
4590    }
4591
4592    #[test]
4593    fn test_body_prose_nextjs_catch_all_segment_is_case_corrected() {
4594        // `[[...myapp]]` is a Next.js optional catch-all route segment, not
4595        // WikiLink syntax; nothing in the parser treats it specially here.
4596        let content = "pages/[[...myapp]].tsx are catch-all routes.\n";
4597        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4598        let ctx = create_context(content);
4599        let result = rule.check(&ctx).unwrap();
4600        assert_eq!(result.len(), 1, "body occurrence is flagged: {result:?}");
4601        assert_eq!(
4602            rule.fix(&ctx).unwrap(),
4603            "pages/[[...MyApp]].tsx are catch-all routes.\n"
4604        );
4605    }
4606
4607    #[test]
4608    fn test_two_adjacent_whitespace_free_links_both_flagged() {
4609        // Two links with no whitespace between them, `[myapp](url)[github](url)`.
4610        // A body-prose tokenizer that split on markdown syntax previously let
4611        // the first link's boundary swallow the second, losing its flag.
4612        let content = "[myapp](https://a.com)[github](https://b.com)\n";
4613        let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitHub".to_string()], false);
4614        let ctx = create_context(content);
4615        let result = rule.check(&ctx).unwrap();
4616        assert_eq!(result.len(), 2, "both link texts must be flagged: {result:?}");
4617        assert!(result.iter().any(|w| w.message.contains("'myapp'")));
4618        assert!(result.iter().any(|w| w.message.contains("'github'")));
4619    }
4620
4621    #[test]
4622    fn test_fix_does_not_corrupt_frontmatter_path_with_route_group_named_after_proper_name() {
4623        // Next.js-style route group: the parenthesised directory segment
4624        // itself is the proper name, e.g. `src/(myapp)/page.tsx`.
4625        let content = "---\nlink: src/(myapp)/page.tsx\n---\n\nBody.\n";
4626        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4627        let ctx = create_context(content);
4628        assert_eq!(
4629            rule.fix(&ctx).unwrap(),
4630            content,
4631            "fix must not rewrite a frontmatter path whose route-group directory name is the proper name"
4632        );
4633    }
4634
4635    #[test]
4636    fn test_quoted_frontmatter_value_slash_conjunction_prose_still_flagged() {
4637        // A quoted value is not automatically a single path token just
4638        // because it is quoted: "We" carries no slash, so per-word
4639        // tokenization applies and the slash-separated list is judged as
4640        // prose, not the sole value.
4641        let content = "---\ndescription: \"We support github/gitlab/bitbucket now\"\n---\n\nBody.\n";
4642        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4643        let ctx = create_context(content);
4644        let result = rule.check(&ctx).unwrap();
4645        assert_eq!(
4646            result.len(),
4647            1,
4648            "quoted prose value must still flag 'github': {result:?}"
4649        );
4650    }
4651
4652    #[test]
4653    fn test_quoted_frontmatter_value_single_slash_word_with_unrelated_dot_still_flagged() {
4654        // One slash-bearing word plus an unrelated later dot (in "1.0" or
4655        // "e.g.") must not make the whole quoted sentence exempt as a path.
4656        let content = "---\ndescription: \"We use myapp/gitlab and version 1.0 e.g. weekly\"\n---\n\nBody.\n";
4657        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4658        let ctx = create_context(content);
4659        let result = rule.check(&ctx).unwrap();
4660        assert_eq!(
4661            result.len(),
4662            1,
4663            "quoted prose value must still flag 'myapp': {result:?}"
4664        );
4665    }
4666
4667    #[test]
4668    fn test_quoted_toml_frontmatter_value_slash_conjunction_prose_still_flagged() {
4669        // TOML string values are essentially always quoted, so this class of
4670        // bug affects TOML frontmatter systematically.
4671        let content = "+++\ndescription = \"We support github/gitlab/bitbucket now\"\n+++\n\nBody.\n";
4672        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4673        let ctx = create_context(content);
4674        let result = rule.check(&ctx).unwrap();
4675        assert_eq!(
4676            result.len(),
4677            1,
4678            "TOML quoted prose value must still flag 'github': {result:?}"
4679        );
4680    }
4681
4682    #[test]
4683    fn test_path_like_collapsed_multiword_no_extension_not_exempt() {
4684        // Every word in these quoted values carries a slash, so they collapse
4685        // to one token per `is_single_quoted_path`. None of the collapsed
4686        // tokens starts with a path prefix or ends in an extension, so the
4687        // 3+ segment sole-value signal must not exempt them either: it is
4688        // restricted to genuinely single-token values, not tokens formed by
4689        // collapsing several whitespace-separated words together.
4690        for line in [
4691            r#"description: "myapp/gitlab github/bitbucket""#,
4692            r#"description: "and/or this/that myapp/gitlab""#,
4693            r#"description: "he/him she/her myapp/gitlab""#,
4694        ] {
4695            let span = frontmatter_values::value_span(line).unwrap();
4696            for needle in ["myapp", "gitlab"] {
4697                if let Some(byte_pos) = line.find(needle) {
4698                    assert!(
4699                        !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4700                        "collapsed multi-word value must not exempt '{needle}': {line}"
4701                    );
4702                }
4703            }
4704        }
4705    }
4706
4707    #[test]
4708    fn test_path_like_collapsed_multiword_no_extension_not_exempt_toml() {
4709        let line = r#"description = "myapp/gitlab github/bitbucket""#;
4710        let span = frontmatter_values::value_span(line).unwrap();
4711        for needle in ["myapp", "gitlab", "github", "bitbucket"] {
4712            let byte_pos = at(line, needle);
4713            assert!(
4714                !MD044ProperNames::is_in_path_like_token(line, byte_pos, span),
4715                "collapsed multi-word TOML value must not exempt '{needle}'"
4716            );
4717        }
4718    }
4719
4720    #[test]
4721    fn test_frontmatter_collapsed_multiword_names_all_flagged_yaml() {
4722        let content = "---\ndescription: \"myapp/gitlab github/bitbucket\"\n---\n\nBody.\n";
4723        let rule = MD044ProperNames::new(
4724            vec![
4725                "MyApp".to_string(),
4726                "GitLab".to_string(),
4727                "GitHub".to_string(),
4728                "Bitbucket".to_string(),
4729            ],
4730            false,
4731        );
4732        let ctx = create_context(content);
4733        let result = rule.check(&ctx).unwrap();
4734        assert_eq!(
4735            result.len(),
4736            4,
4737            "all four names in the collapsed multi-word value must be flagged: {result:?}"
4738        );
4739    }
4740
4741    #[test]
4742    fn test_frontmatter_collapsed_multiword_names_all_flagged_toml() {
4743        let content = "+++\ndescription = \"myapp/gitlab github/bitbucket\"\n+++\n\nBody.\n";
4744        let rule = MD044ProperNames::new(
4745            vec![
4746                "MyApp".to_string(),
4747                "GitLab".to_string(),
4748                "GitHub".to_string(),
4749                "Bitbucket".to_string(),
4750            ],
4751            false,
4752        );
4753        let ctx = create_context(content);
4754        let result = rule.check(&ctx).unwrap();
4755        assert_eq!(
4756            result.len(),
4757            4,
4758            "all four names in the collapsed multi-word TOML value must be flagged: {result:?}"
4759        );
4760    }
4761
4762    #[test]
4763    fn test_frontmatter_collapsed_multiword_conjunction_pairs_flagged() {
4764        let content = "---\ndescription: \"and/or this/that myapp/gitlab\"\n---\n\nBody.\n";
4765        let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4766        let ctx = create_context(content);
4767        let result = rule.check(&ctx).unwrap();
4768        assert_eq!(
4769            result.len(),
4770            2,
4771            "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4772        );
4773    }
4774
4775    #[test]
4776    fn test_frontmatter_collapsed_multiword_pronoun_pairs_flagged() {
4777        let content = "---\ndescription: \"he/him she/her myapp/gitlab\"\n---\n\nBody.\n";
4778        let rule = MD044ProperNames::new(vec!["MyApp".to_string(), "GitLab".to_string()], false);
4779        let ctx = create_context(content);
4780        let result = rule.check(&ctx).unwrap();
4781        assert_eq!(
4782            result.len(),
4783            2,
4784            "myapp and gitlab must both be flagged despite the surrounding slash pairs: {result:?}"
4785        );
4786    }
4787
4788    /// The path exemption is scoped to frontmatter only, by design: a body
4789    /// occurrence sitting inside what looks like a file path is still
4790    /// flagged and fixed like any other prose occurrence. This is a
4791    /// deliberate limit (see `is_in_path_like_token`), not an oversight, so a
4792    /// future reader does not "fix" it back into hand-rolled body tokenizing.
4793    #[test]
4794    fn test_body_prose_path_is_flagged_frontmatter_only_scope() {
4795        let content = "See docs/myapp.md for details about myapp.\n";
4796        let rule = MD044ProperNames::new(vec!["MyApp".to_string()], false);
4797        let ctx = create_context(content);
4798        let result = rule.check(&ctx).unwrap();
4799        assert_eq!(
4800            result.len(),
4801            2,
4802            "both the path occurrence and the prose occurrence are flagged in body text: {result:?}"
4803        );
4804        assert_eq!(rule.fix(&ctx).unwrap(), "See docs/MyApp.md for details about MyApp.\n");
4805    }
4806
4807    #[test]
4808    fn test_slash_conjunction_prose_still_flagged() {
4809        let content = "We support github/gitlab imports.\n";
4810        let rule = MD044ProperNames::new(vec!["GitHub".to_string()], false);
4811        let ctx = create_context(content);
4812        assert_eq!(rule.check(&ctx).unwrap().len(), 1);
4813    }
4814}