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