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