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