Skip to main content

rumdl_lib/rules/
md044_proper_names.rs

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