1use crate::lint_context::LintContext;
21use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
22use crate::rule_config_serde::RuleConfig;
23use crate::utils::anchor_styles::AnchorStyle;
24use crate::utils::range_utils::calculate_match_range;
25use serde::{Deserialize, Serialize};
26use std::collections::HashMap;
27
28fn default_levels() -> Vec<u8> {
29 vec![1, 2, 3, 4, 5, 6]
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(rename_all = "kebab-case")]
35pub struct MD080Config {
36 #[serde(default, alias = "anchor_style")]
38 pub anchor_style: AnchorStyle,
39
40 #[serde(default = "default_levels")]
44 pub levels: Vec<u8>,
45}
46
47impl Default for MD080Config {
48 fn default() -> Self {
49 Self {
50 anchor_style: AnchorStyle::default(),
51 levels: default_levels(),
52 }
53 }
54}
55
56impl RuleConfig for MD080Config {
57 const RULE_NAME: &'static str = "MD080";
58}
59
60#[derive(Debug, Clone)]
61pub struct MD080HeadingAnchorCollision {
62 config: MD080Config,
63 anchor_style_pinned: bool,
67}
68
69impl Default for MD080HeadingAnchorCollision {
70 fn default() -> Self {
71 Self::from_config_struct(MD080Config::default())
72 }
73}
74
75impl MD080HeadingAnchorCollision {
76 pub fn new() -> Self {
77 Self::default()
78 }
79
80 pub fn from_config_struct(config: MD080Config) -> Self {
81 Self {
82 config,
83 anchor_style_pinned: true,
84 }
85 }
86
87 fn anchor_style(&self, ctx: &LintContext) -> AnchorStyle {
93 if self.anchor_style_pinned {
94 self.config.anchor_style
95 } else {
96 AnchorStyle::for_flavor(ctx.flavor)
97 }
98 }
99
100 fn effective_anchor(&self, text: &str, custom_id: Option<&str>, anchor_style: AnchorStyle) -> String {
106 match custom_id {
107 Some(id) => id.to_string(),
108 None => anchor_style.generate_fragment(text),
109 }
110 }
111
112 #[allow(clippy::too_many_arguments)]
117 fn record(
118 &self,
119 slug_text: &str,
120 text: &str,
121 custom_id: Option<&str>,
122 level: u8,
123 line_num: usize,
124 content: &str,
125 anchor_style: AnchorStyle,
126 seen: &mut HashMap<String, usize>,
127 warnings: &mut Vec<LintWarning>,
128 ) {
129 if !self.config.levels.contains(&level) {
130 return;
131 }
132
133 let anchor = self.effective_anchor(slug_text, custom_id, anchor_style);
134 if anchor.is_empty() {
135 return;
136 }
137
138 if let Some(&first_line) = seen.get(&anchor) {
139 let (start_line, start_col, end_line, end_col) =
140 calculate_match_range(line_num, content, content.find(text).unwrap_or(0), text.len());
141 warnings.push(LintWarning {
142 rule_name: Some(self.name().to_string()),
143 severity: Severity::Warning,
144 line: start_line,
145 column: start_col,
146 end_line,
147 end_column: end_col,
148 message: format!(
149 "Heading anchor '{anchor}' collides with the heading at line {first_line}; \
150 fragment links and any derived page identifier resolve only to the first occurrence"
151 ),
152 fix: None,
153 });
154 } else {
155 seen.insert(anchor, line_num);
156 }
157 }
158}
159
160impl Rule for MD080HeadingAnchorCollision {
161 fn name(&self) -> &'static str {
162 "MD080"
163 }
164
165 fn description(&self) -> &'static str {
166 "Heading anchors must be unique"
167 }
168
169 fn check(&self, ctx: &LintContext) -> LintResult {
170 let mut warnings = Vec::new();
171 let mut seen: HashMap<String, usize> = HashMap::new();
173 let anchor_style = self.anchor_style(ctx);
174
175 for parsed in ctx.headings() {
176 let heading = parsed.heading;
177 if !heading.is_valid || heading.text.is_empty() {
178 continue;
179 }
180 self.record(
181 &heading.slug_text,
182 &heading.text,
183 heading.custom_id.as_deref(),
184 heading.level,
185 parsed.line_num,
186 parsed.line_info.content(ctx.content),
187 anchor_style,
188 &mut seen,
189 &mut warnings,
190 );
191 }
192
193 Ok(warnings)
194 }
195
196 fn fix_capability(&self) -> FixCapability {
197 FixCapability::Unfixable
201 }
202
203 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
204 Err(LintError::FixFailed("MD080 has no auto-fix".to_string()))
205 }
206
207 fn category(&self) -> RuleCategory {
208 RuleCategory::Heading
209 }
210
211 fn as_any(&self) -> &dyn std::any::Any {
212 self
213 }
214
215 crate::impl_rule_config_sections!(MD080Config);
216
217 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
218 where
219 Self: Sized,
220 {
221 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD080Config>(config);
222
223 let explicit_style_present = config
228 .rules
229 .get("MD080")
230 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
231 if !explicit_style_present {
232 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
233 }
234
235 Box::new(MD080HeadingAnchorCollision {
236 config: rule_config,
237 anchor_style_pinned: explicit_style_present,
238 })
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::config::MarkdownFlavor;
246
247 fn check(content: &str) -> Vec<LintWarning> {
248 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
249 MD080HeadingAnchorCollision::new().check(&ctx).unwrap()
250 }
251
252 fn check_with(config: MD080Config, content: &str) -> Vec<LintWarning> {
253 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
254 MD080HeadingAnchorCollision::from_config_struct(config)
255 .check(&ctx)
256 .unwrap()
257 }
258
259 const ANCHOR_STYLE_PROBE: &str = "# Test--Double\n\n## Test Double\n";
263
264 fn collisions(rule: &dyn Rule, flavor: MarkdownFlavor) -> usize {
265 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
266 rule.check(&ctx).unwrap().len()
267 }
268
269 #[test]
273 fn test_unpinned_anchor_style_follows_the_file_flavor() {
274 let rule_from_global = |flavor| {
275 let mut config = crate::config::Config::default();
276 config.global.flavor = flavor;
277 MD080HeadingAnchorCollision::from_config(&config)
278 };
279
280 let standard_global = rule_from_global(MarkdownFlavor::Standard);
283 assert_eq!(
284 collisions(standard_global.as_ref(), MarkdownFlavor::Standard),
285 0,
286 "GitHub anchors keep the doubled hyphen, so there is no collision"
287 );
288 assert_eq!(
291 collisions(standard_global.as_ref(), MarkdownFlavor::MkDocs),
292 1,
293 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
294 );
295
296 let mkdocs_global = rule_from_global(MarkdownFlavor::MkDocs);
298 assert_eq!(collisions(mkdocs_global.as_ref(), MarkdownFlavor::MkDocs), 1);
299 assert_eq!(
300 collisions(mkdocs_global.as_ref(), MarkdownFlavor::Standard),
301 0,
302 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
303 );
304 }
305
306 #[test]
309 fn test_pinned_anchor_style_ignores_the_file_flavor() {
310 let mut config = crate::config::Config::default();
311 config.global.flavor = MarkdownFlavor::MkDocs;
312 let mut rule_config = crate::config::RuleConfig::default();
313 rule_config
314 .values
315 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
316 config.rules.insert("MD080".to_string(), rule_config);
317 let rule = MD080HeadingAnchorCollision::from_config(&config);
318
319 for flavor in [
320 MarkdownFlavor::Standard,
321 MarkdownFlavor::MkDocs,
322 MarkdownFlavor::Kramdown,
323 ] {
324 assert_eq!(
325 collisions(rule.as_ref(), flavor),
326 0,
327 "pinned github anchors must survive a {flavor:?} file"
328 );
329 }
330 }
331
332 #[test]
335 fn test_directly_constructed_rule_keeps_its_anchor_style() {
336 let rule = MD080HeadingAnchorCollision::from_config_struct(MD080Config {
337 anchor_style: AnchorStyle::PythonMarkdown,
338 ..Default::default()
339 });
340 assert_eq!(
341 collisions(&rule, MarkdownFlavor::Standard),
342 1,
343 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
344 );
345 }
346
347 #[test]
348 fn flags_distinct_text_same_github_slug() {
349 let w = check("# Setup & Run\n\n# Setup Run\n");
352 assert_eq!(w.len(), 1, "got: {w:?}");
353 assert!(w[0].message.contains("collides with the heading at line 1"));
354 assert_eq!(w[0].line, 3);
355 }
356
357 #[test]
358 fn flags_punctuation_only_difference() {
359 let w = check("# C++\n\n## C\n");
361 assert_eq!(w.len(), 1, "got: {w:?}");
362 }
363
364 #[test]
365 fn flags_same_text_across_levels() {
366 let w = check("# Intro\n\nbody\n\n## Intro\n");
369 assert_eq!(w.len(), 1, "distinct-level slug collision must flag: {w:?}");
370 assert_eq!(w[0].line, 5);
371 }
372
373 #[test]
374 fn no_warning_when_slugs_differ() {
375 assert!(check("# Alpha\n\n## Beta\n\n### Gamma\n").is_empty());
376 }
377
378 #[test]
379 fn flags_three_way_collision_once_per_extra() {
380 let w = check("# Dup\n\n## Dup\n\n### Dup\n");
381 assert_eq!(w.len(), 2, "first defines, each later collides: {w:?}");
382 assert_eq!(w[0].line, 3);
383 assert_eq!(w[1].line, 5);
384 }
385
386 #[test]
387 fn flags_colliding_custom_ids() {
388 let w = check("# Alpha {#dup}\n\n## Beta {#dup}\n");
389 assert_eq!(w.len(), 1, "got: {w:?}");
390 assert!(w[0].message.contains("'dup'"));
391 }
392
393 #[test]
394 fn custom_id_disambiguates_same_text() {
395 let w = check("# Repeat {#first}\n\n## Repeat {#second}\n");
397 assert!(w.is_empty(), "explicit ids disambiguate: {w:?}");
398 }
399
400 #[test]
401 fn ignores_headings_in_code_fences() {
402 let w = check("# Title\n\n```\n# Title\n```\n");
403 assert!(w.is_empty(), "fenced `# Title` is not a heading: {w:?}");
404 }
405
406 #[test]
407 fn ignores_front_matter() {
408 let w = check("---\ntitle: Title\n---\n\n# Title\n\n## Title\n");
409 assert_eq!(w.len(), 1, "got: {w:?}");
411 assert_eq!(w[0].line, 7);
412 }
413
414 #[test]
415 fn levels_filter_restricts_scope() {
416 let cfg = MD080Config {
418 anchor_style: AnchorStyle::GitHub,
419 levels: vec![1, 2],
420 };
421 let w = check_with(cfg, "# Page\n\n### Dup\n\n### Dup\n");
422 assert!(w.is_empty(), "H3 collisions excluded by levels=[1,2]: {w:?}");
423 }
424
425 #[test]
426 fn anchor_style_changes_collision_outcome() {
427 let content = "# a_b\n\n## ab\n";
430 assert!(
431 check_with(
432 MD080Config {
433 anchor_style: AnchorStyle::GitHub,
434 levels: default_levels()
435 },
436 content
437 )
438 .is_empty(),
439 "GitHub keeps the underscore, slugs stay distinct"
440 );
441 assert_eq!(
442 check_with(
443 MD080Config {
444 anchor_style: AnchorStyle::Kramdown,
445 levels: default_levels()
446 },
447 content
448 )
449 .len(),
450 1,
451 "Kramdown removes `_`, so both headings slug to `ab`"
452 );
453 }
454
455 #[test]
456 fn flags_setext_heading_collision() {
457 let w = check("Intro\n=====\n\nbody\n\n## Intro\n");
460 assert_eq!(w.len(), 1, "setext + atx slug collision must flag: {w:?}");
461 assert_eq!(w[0].line, 6);
462 }
463
464 #[test]
465 fn custom_id_case_is_significant() {
466 let w = check("# Alpha {#API}\n\n## Beta {#api}\n");
469 assert!(w.is_empty(), "custom ids differing only in case are distinct: {w:?}");
470 }
471
472 #[test]
473 fn flags_blockquote_heading_collision() {
474 let w = check("> ## Intro\n\n## Intro\n");
477 assert_eq!(w.len(), 1, "blockquote heading slug collision must flag: {w:?}");
478 assert_eq!(w[0].line, 3);
479 }
480
481 #[test]
482 fn blockquote_in_html_block_is_not_a_heading() {
483 let w = check("<div>\n> ## Intro\n</div>\n\n## Intro\n");
486 assert!(w.is_empty(), "raw HTML must not create a heading collision: {w:?}");
487 }
488
489 #[test]
490 fn no_auto_fix_offered() {
491 let w = check("# Dup\n\n## Dup\n");
492 assert!(w[0].fix.is_none());
493 let ctx = LintContext::new("# Dup\n\n## Dup\n", MarkdownFlavor::Standard, None);
494 assert!(MD080HeadingAnchorCollision::new().fix(&ctx).is_err());
495 }
496
497 #[test]
498 fn empty_document_is_clean() {
499 assert!(check("").is_empty());
500 assert!(check("Just prose, no headings.\n").is_empty());
501 }
502}