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