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