Skip to main content

rumdl_lib/rules/
md044_proper_names.rs

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