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 (idx, line_info) in ctx.lines.iter().enumerate() {
174 if line_info.in_front_matter || line_info.in_code_block {
175 continue;
176 }
177 let line_num = idx + 1;
178 let content = line_info.content(ctx.content);
179
180 if let Some(heading) = &line_info.heading {
182 if heading.is_valid && !heading.text.is_empty() {
183 self.record(
184 &heading.text,
185 heading.custom_id.as_deref(),
186 heading.level,
187 line_num,
188 content,
189 anchor_style,
190 &mut seen,
191 &mut warnings,
192 );
193 }
194 continue;
195 }
196
197 if let Some(bq) = &line_info.blockquote
201 && let Some((clean_text, custom_id)) =
202 crate::utils::header_id_utils::parse_blockquote_atx_heading(&bq.content)
203 && !clean_text.is_empty()
204 {
205 let level = bq
206 .content
207 .trim_start()
208 .bytes()
209 .take_while(|&b| b == b'#')
210 .count()
211 .clamp(1, 6) as u8;
212 self.record(
213 &clean_text,
214 custom_id.as_deref(),
215 level,
216 line_num,
217 content,
218 anchor_style,
219 &mut seen,
220 &mut warnings,
221 );
222 }
223 }
224
225 Ok(warnings)
226 }
227
228 fn fix_capability(&self) -> FixCapability {
229 FixCapability::Unfixable
233 }
234
235 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
236 Err(LintError::FixFailed("MD080 has no auto-fix".to_string()))
237 }
238
239 fn category(&self) -> RuleCategory {
240 RuleCategory::Heading
241 }
242
243 fn as_any(&self) -> &dyn std::any::Any {
244 self
245 }
246
247 crate::impl_rule_config_sections!(MD080Config);
248
249 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
250 where
251 Self: Sized,
252 {
253 let mut rule_config = crate::rule_config_serde::load_rule_config::<MD080Config>(config);
254
255 let explicit_style_present = config
260 .rules
261 .get("MD080")
262 .is_some_and(|rc| rc.values.contains_key("anchor-style") || rc.values.contains_key("anchor_style"));
263 if !explicit_style_present {
264 rule_config.anchor_style = AnchorStyle::for_flavor(config.global.flavor);
265 }
266
267 Box::new(MD080HeadingAnchorCollision {
268 config: rule_config,
269 anchor_style_pinned: explicit_style_present,
270 })
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::config::MarkdownFlavor;
278
279 fn check(content: &str) -> Vec<LintWarning> {
280 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
281 MD080HeadingAnchorCollision::new().check(&ctx).unwrap()
282 }
283
284 fn check_with(config: MD080Config, content: &str) -> Vec<LintWarning> {
285 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
286 MD080HeadingAnchorCollision::from_config_struct(config)
287 .check(&ctx)
288 .unwrap()
289 }
290
291 const ANCHOR_STYLE_PROBE: &str = "# Test--Double\n\n## Test Double\n";
295
296 fn collisions(rule: &dyn Rule, flavor: MarkdownFlavor) -> usize {
297 let ctx = LintContext::new(ANCHOR_STYLE_PROBE, flavor, None);
298 rule.check(&ctx).unwrap().len()
299 }
300
301 #[test]
305 fn test_unpinned_anchor_style_follows_the_file_flavor() {
306 let rule_from_global = |flavor| {
307 let mut config = crate::config::Config::default();
308 config.global.flavor = flavor;
309 MD080HeadingAnchorCollision::from_config(&config)
310 };
311
312 let standard_global = rule_from_global(MarkdownFlavor::Standard);
315 assert_eq!(
316 collisions(standard_global.as_ref(), MarkdownFlavor::Standard),
317 0,
318 "GitHub anchors keep the doubled hyphen, so there is no collision"
319 );
320 assert_eq!(
323 collisions(standard_global.as_ref(), MarkdownFlavor::MkDocs),
324 1,
325 "a mkdocs file must be checked against Python-Markdown anchors even under a standard global flavor"
326 );
327
328 let mkdocs_global = rule_from_global(MarkdownFlavor::MkDocs);
330 assert_eq!(collisions(mkdocs_global.as_ref(), MarkdownFlavor::MkDocs), 1);
331 assert_eq!(
332 collisions(mkdocs_global.as_ref(), MarkdownFlavor::Standard),
333 0,
334 "a standard file must be checked against GitHub anchors even under a mkdocs global flavor"
335 );
336 }
337
338 #[test]
341 fn test_pinned_anchor_style_ignores_the_file_flavor() {
342 let mut config = crate::config::Config::default();
343 config.global.flavor = MarkdownFlavor::MkDocs;
344 let mut rule_config = crate::config::RuleConfig::default();
345 rule_config
346 .values
347 .insert("anchor-style".to_string(), toml::Value::String("github".to_string()));
348 config.rules.insert("MD080".to_string(), rule_config);
349 let rule = MD080HeadingAnchorCollision::from_config(&config);
350
351 for flavor in [
352 MarkdownFlavor::Standard,
353 MarkdownFlavor::MkDocs,
354 MarkdownFlavor::Kramdown,
355 ] {
356 assert_eq!(
357 collisions(rule.as_ref(), flavor),
358 0,
359 "pinned github anchors must survive a {flavor:?} file"
360 );
361 }
362 }
363
364 #[test]
367 fn test_directly_constructed_rule_keeps_its_anchor_style() {
368 let rule = MD080HeadingAnchorCollision::from_config_struct(MD080Config {
369 anchor_style: AnchorStyle::PythonMarkdown,
370 ..Default::default()
371 });
372 assert_eq!(
373 collisions(&rule, MarkdownFlavor::Standard),
374 1,
375 "an explicitly constructed Python-Markdown rule must not follow the file flavor"
376 );
377 }
378
379 #[test]
380 fn flags_distinct_text_same_github_slug() {
381 let w = check("# Setup & Run\n\n# Setup Run\n");
384 assert_eq!(w.len(), 1, "got: {w:?}");
385 assert!(w[0].message.contains("collides with the heading at line 1"));
386 assert_eq!(w[0].line, 3);
387 }
388
389 #[test]
390 fn flags_punctuation_only_difference() {
391 let w = check("# C++\n\n## C\n");
393 assert_eq!(w.len(), 1, "got: {w:?}");
394 }
395
396 #[test]
397 fn flags_same_text_across_levels() {
398 let w = check("# Intro\n\nbody\n\n## Intro\n");
401 assert_eq!(w.len(), 1, "distinct-level slug collision must flag: {w:?}");
402 assert_eq!(w[0].line, 5);
403 }
404
405 #[test]
406 fn no_warning_when_slugs_differ() {
407 assert!(check("# Alpha\n\n## Beta\n\n### Gamma\n").is_empty());
408 }
409
410 #[test]
411 fn flags_three_way_collision_once_per_extra() {
412 let w = check("# Dup\n\n## Dup\n\n### Dup\n");
413 assert_eq!(w.len(), 2, "first defines, each later collides: {w:?}");
414 assert_eq!(w[0].line, 3);
415 assert_eq!(w[1].line, 5);
416 }
417
418 #[test]
419 fn flags_colliding_custom_ids() {
420 let w = check("# Alpha {#dup}\n\n## Beta {#dup}\n");
421 assert_eq!(w.len(), 1, "got: {w:?}");
422 assert!(w[0].message.contains("'dup'"));
423 }
424
425 #[test]
426 fn custom_id_disambiguates_same_text() {
427 let w = check("# Repeat {#first}\n\n## Repeat {#second}\n");
429 assert!(w.is_empty(), "explicit ids disambiguate: {w:?}");
430 }
431
432 #[test]
433 fn ignores_headings_in_code_fences() {
434 let w = check("# Title\n\n```\n# Title\n```\n");
435 assert!(w.is_empty(), "fenced `# Title` is not a heading: {w:?}");
436 }
437
438 #[test]
439 fn ignores_front_matter() {
440 let w = check("---\ntitle: Title\n---\n\n# Title\n\n## Title\n");
441 assert_eq!(w.len(), 1, "got: {w:?}");
443 assert_eq!(w[0].line, 7);
444 }
445
446 #[test]
447 fn levels_filter_restricts_scope() {
448 let cfg = MD080Config {
450 anchor_style: AnchorStyle::GitHub,
451 levels: vec![1, 2],
452 };
453 let w = check_with(cfg, "# Page\n\n### Dup\n\n### Dup\n");
454 assert!(w.is_empty(), "H3 collisions excluded by levels=[1,2]: {w:?}");
455 }
456
457 #[test]
458 fn anchor_style_changes_collision_outcome() {
459 let content = "# a_b\n\n## ab\n";
462 assert!(
463 check_with(
464 MD080Config {
465 anchor_style: AnchorStyle::GitHub,
466 levels: default_levels()
467 },
468 content
469 )
470 .is_empty(),
471 "GitHub keeps the underscore, slugs stay distinct"
472 );
473 assert_eq!(
474 check_with(
475 MD080Config {
476 anchor_style: AnchorStyle::Kramdown,
477 levels: default_levels()
478 },
479 content
480 )
481 .len(),
482 1,
483 "Kramdown removes `_`, so both headings slug to `ab`"
484 );
485 }
486
487 #[test]
488 fn flags_setext_heading_collision() {
489 let w = check("Intro\n=====\n\nbody\n\n## Intro\n");
492 assert_eq!(w.len(), 1, "setext + atx slug collision must flag: {w:?}");
493 assert_eq!(w[0].line, 6);
494 }
495
496 #[test]
497 fn custom_id_case_is_significant() {
498 let w = check("# Alpha {#API}\n\n## Beta {#api}\n");
501 assert!(w.is_empty(), "custom ids differing only in case are distinct: {w:?}");
502 }
503
504 #[test]
505 fn flags_blockquote_heading_collision() {
506 let w = check("> ## Intro\n\n## Intro\n");
509 assert_eq!(w.len(), 1, "blockquote heading slug collision must flag: {w:?}");
510 assert_eq!(w[0].line, 3);
511 }
512
513 #[test]
514 fn blockquote_in_html_block_mirrors_md051_anchor_model() {
515 let w = check("<div>\n> ## Intro\n</div>\n\n## Intro\n");
523 assert_eq!(w.len(), 1, "must agree with MD051's anchor model: {w:?}");
524 assert_eq!(w[0].line, 5);
525 }
526
527 #[test]
528 fn no_auto_fix_offered() {
529 let w = check("# Dup\n\n## Dup\n");
530 assert!(w[0].fix.is_none());
531 let ctx = LintContext::new("# Dup\n\n## Dup\n", MarkdownFlavor::Standard, None);
532 assert!(MD080HeadingAnchorCollision::new().fix(&ctx).is_err());
533 }
534
535 #[test]
536 fn empty_document_is_clean() {
537 assert!(check("").is_empty());
538 assert!(check("Just prose, no headings.\n").is_empty());
539 }
540}