Skip to main content

rumdl_lib/rules/
md044_proper_names.rs

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