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)]
116 fn record(
117 &self,
118 text: &str,
119 custom_id: Option<&str>,
120 level: u8,
121 line_num: usize,
122 content: &str,
123 anchor_style: AnchorStyle,
124 seen: &mut HashMap<String, usize>,
125 warnings: &mut Vec<LintWarning>,
126 ) {
127 if !self.config.levels.contains(&level) {
128 return;
129 }
130
131 let anchor = self.effective_anchor(text, custom_id, anchor_style);
132 if anchor.is_empty() {
133 return;
134 }
135
136 if let Some(&first_line) = seen.get(&anchor) {
137 let (start_line, start_col, end_line, end_col) =
138 calculate_match_range(line_num, content, content.find(text).unwrap_or(0), text.len());
139 warnings.push(LintWarning {
140 rule_name: Some(self.name().to_string()),
141 severity: Severity::Warning,
142 line: start_line,
143 column: start_col,
144 end_line,
145 end_column: end_col,
146 message: format!(
147 "Heading anchor '{anchor}' collides with the heading at line {first_line}; \
148 fragment links and any derived page identifier resolve only to the first occurrence"
149 ),
150 fix: None,
151 });
152 } else {
153 seen.insert(anchor, line_num);
154 }
155 }
156}
157
158impl Rule for MD080HeadingAnchorCollision {
159 fn name(&self) -> &'static str {
160 "MD080"
161 }
162
163 fn description(&self) -> &'static str {
164 "Heading anchors must be unique"
165 }
166
167 fn check(&self, ctx: &LintContext) -> LintResult {
168 let mut warnings = Vec::new();
169 let mut seen: HashMap<String, usize> = HashMap::new();
171 let anchor_style = self.anchor_style(ctx);
172
173 for parsed in ctx.headings() {
174 let heading = parsed.heading;
175 if !heading.is_valid || heading.text.is_empty() {
176 continue;
177 }
178 self.record(
179 &heading.text,
180 heading.custom_id.as_deref(),
181 heading.level,
182 parsed.line_num,
183 parsed.line_info.content(ctx.content),
184 anchor_style,
185 &mut seen,
186 &mut warnings,
187 );
188 }
189
190 Ok(warnings)
191 }
192
193 fn fix_capability(&self) -> FixCapability {
194 FixCapability::Unfixable
198 }
199
200 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
201 Err(LintError::FixFailed("MD080 has no auto-fix".to_string()))
202 }
203
204 fn category(&self) -> RuleCategory {
205 RuleCategory::Heading
206 }
207
208 fn as_any(&self) -> &dyn std::any::Any {
209 self
210 }
211
212 crate::impl_rule_config_sections!(MD080Config);
213
214 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
215 where
216 Self: Sized,
217 {
218 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD080Config>(config);
219
220 let explicit_style_present = config
225 .rules
226 .get("MD080")
227 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
228 if !explicit_style_present {
229 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
230 }
231
232 Box::new(MD080HeadingAnchorCollision {
233 config: rule_config,
234 anchor_style_pinned: explicit_style_present,
235 })
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::config::MarkdownFlavor;
243
244 fn check(content: &str) -> Vec<LintWarning> {
245 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
246 MD080HeadingAnchorCollision::new().check(&ctx).unwrap()
247 }
248
249 fn check_with(config: MD080Config, content: &str) -> Vec<LintWarning> {
250 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
251 MD080HeadingAnchorCollision::from_config_struct(config)
252 .check(&ctx)
253 .unwrap()
254 }
255
256 const ANCHOR_STYLE_PROBE: &str = "# Test--Double\n\n## Test Double\n";
260
261 fn collisions(rule: &dyn Rule, flavor: MarkdownFlavor) -> usize {
262 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
263 rule.check(&ctx).unwrap().len()
264 }
265
266 #[test]
270 fn test_unpinned_anchor_style_follows_the_file_flavor() {
271 let rule_from_global = |flavor| {
272 let mut config = crate::config::Config::default();
273 config.global.flavor = flavor;
274 MD080HeadingAnchorCollision::from_config(&config)
275 };
276
277 let standard_global = rule_from_global(MarkdownFlavor::Standard);
280 assert_eq!(
281 collisions(standard_global.as_ref(), MarkdownFlavor::Standard),
282 0,
283 "GitHub anchors keep the doubled hyphen, so there is no collision"
284 );
285 assert_eq!(
288 collisions(standard_global.as_ref(), MarkdownFlavor::MkDocs),
289 1,
290 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
291 );
292
293 let mkdocs_global = rule_from_global(MarkdownFlavor::MkDocs);
295 assert_eq!(collisions(mkdocs_global.as_ref(), MarkdownFlavor::MkDocs), 1);
296 assert_eq!(
297 collisions(mkdocs_global.as_ref(), MarkdownFlavor::Standard),
298 0,
299 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
300 );
301 }
302
303 #[test]
306 fn test_pinned_anchor_style_ignores_the_file_flavor() {
307 let mut config = crate::config::Config::default();
308 config.global.flavor = MarkdownFlavor::MkDocs;
309 let mut rule_config = crate::config::RuleConfig::default();
310 rule_config
311 .values
312 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
313 config.rules.insert("MD080".to_string(), rule_config);
314 let rule = MD080HeadingAnchorCollision::from_config(&config);
315
316 for flavor in [
317 MarkdownFlavor::Standard,
318 MarkdownFlavor::MkDocs,
319 MarkdownFlavor::Kramdown,
320 ] {
321 assert_eq!(
322 collisions(rule.as_ref(), flavor),
323 0,
324 "pinned github anchors must survive a {flavor:?} file"
325 );
326 }
327 }
328
329 #[test]
332 fn test_directly_constructed_rule_keeps_its_anchor_style() {
333 let rule = MD080HeadingAnchorCollision::from_config_struct(MD080Config {
334 anchor_style: AnchorStyle::PythonMarkdown,
335 ..Default::default()
336 });
337 assert_eq!(
338 collisions(&rule, MarkdownFlavor::Standard),
339 1,
340 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
341 );
342 }
343
344 #[test]
345 fn flags_distinct_text_same_github_slug() {
346 let w = check("# Setup & Run\n\n# Setup Run\n");
349 assert_eq!(w.len(), 1, "got: {w:?}");
350 assert!(w[0].message.contains("collides with the heading at line 1"));
351 assert_eq!(w[0].line, 3);
352 }
353
354 #[test]
355 fn flags_punctuation_only_difference() {
356 let w = check("# C++\n\n## C\n");
358 assert_eq!(w.len(), 1, "got: {w:?}");
359 }
360
361 #[test]
362 fn flags_same_text_across_levels() {
363 let w = check("# Intro\n\nbody\n\n## Intro\n");
366 assert_eq!(w.len(), 1, "distinct-level slug collision must flag: {w:?}");
367 assert_eq!(w[0].line, 5);
368 }
369
370 #[test]
371 fn no_warning_when_slugs_differ() {
372 assert!(check("# Alpha\n\n## Beta\n\n### Gamma\n").is_empty());
373 }
374
375 #[test]
376 fn flags_three_way_collision_once_per_extra() {
377 let w = check("# Dup\n\n## Dup\n\n### Dup\n");
378 assert_eq!(w.len(), 2, "first defines, each later collides: {w:?}");
379 assert_eq!(w[0].line, 3);
380 assert_eq!(w[1].line, 5);
381 }
382
383 #[test]
384 fn flags_colliding_custom_ids() {
385 let w = check("# Alpha {#dup}\n\n## Beta {#dup}\n");
386 assert_eq!(w.len(), 1, "got: {w:?}");
387 assert!(w[0].message.contains("'dup'"));
388 }
389
390 #[test]
391 fn custom_id_disambiguates_same_text() {
392 let w = check("# Repeat {#first}\n\n## Repeat {#second}\n");
394 assert!(w.is_empty(), "explicit ids disambiguate: {w:?}");
395 }
396
397 #[test]
398 fn ignores_headings_in_code_fences() {
399 let w = check("# Title\n\n```\n# Title\n```\n");
400 assert!(w.is_empty(), "fenced `# Title` is not a heading: {w:?}");
401 }
402
403 #[test]
404 fn ignores_front_matter() {
405 let w = check("---\ntitle: Title\n---\n\n# Title\n\n## Title\n");
406 assert_eq!(w.len(), 1, "got: {w:?}");
408 assert_eq!(w[0].line, 7);
409 }
410
411 #[test]
412 fn levels_filter_restricts_scope() {
413 let cfg = MD080Config {
415 anchor_style: AnchorStyle::GitHub,
416 levels: vec![1, 2],
417 };
418 let w = check_with(cfg, "# Page\n\n### Dup\n\n### Dup\n");
419 assert!(w.is_empty(), "H3 collisions excluded by levels=[1,2]: {w:?}");
420 }
421
422 #[test]
423 fn anchor_style_changes_collision_outcome() {
424 let content = "# a_b\n\n## ab\n";
427 assert!(
428 check_with(
429 MD080Config {
430 anchor_style: AnchorStyle::GitHub,
431 levels: default_levels()
432 },
433 content
434 )
435 .is_empty(),
436 "GitHub keeps the underscore, slugs stay distinct"
437 );
438 assert_eq!(
439 check_with(
440 MD080Config {
441 anchor_style: AnchorStyle::Kramdown,
442 levels: default_levels()
443 },
444 content
445 )
446 .len(),
447 1,
448 "Kramdown removes `_`, so both headings slug to `ab`"
449 );
450 }
451
452 #[test]
453 fn flags_setext_heading_collision() {
454 let w = check("Intro\n=====\n\nbody\n\n## Intro\n");
457 assert_eq!(w.len(), 1, "setext + atx slug collision must flag: {w:?}");
458 assert_eq!(w[0].line, 6);
459 }
460
461 #[test]
462 fn custom_id_case_is_significant() {
463 let w = check("# Alpha {#API}\n\n## Beta {#api}\n");
466 assert!(w.is_empty(), "custom ids differing only in case are distinct: {w:?}");
467 }
468
469 #[test]
470 fn flags_blockquote_heading_collision() {
471 let w = check("> ## Intro\n\n## Intro\n");
474 assert_eq!(w.len(), 1, "blockquote heading slug collision must flag: {w:?}");
475 assert_eq!(w[0].line, 3);
476 }
477
478 #[test]
479 fn blockquote_in_html_block_is_not_a_heading() {
480 let w = check("<div>\n> ## Intro\n</div>\n\n## Intro\n");
483 assert!(w.is_empty(), "raw HTML must not create a heading collision: {w:?}");
484 }
485
486 #[test]
487 fn no_auto_fix_offered() {
488 let w = check("# Dup\n\n## Dup\n");
489 assert!(w[0].fix.is_none());
490 let ctx = LintContext::new("# Dup\n\n## Dup\n", MarkdownFlavor::Standard, None);
491 assert!(MD080HeadingAnchorCollision::new().fix(&ctx).is_err());
492 }
493
494 #[test]
495 fn empty_document_is_clean() {
496 assert!(check("").is_empty());
497 assert!(check("Just prose, no headings.\n").is_empty());
498 }
499}