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