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