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