1use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct Header {
7 pub text: String,
9 pub level: u8,
11 pub id: String,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct IncludedFile {
18 pub path: String,
20 pub custom_output: Option<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DuplicateAnchor {
27 pub id: String,
29 pub first_text: String,
31 pub first_level: u8,
33 pub second_text: String,
35 pub second_level: u8,
37}
38
39impl std::fmt::Display for DuplicateAnchor {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 write!(
42 f,
43 "Duplicate anchor ID '{}' found: '{}' (h{}) and '{}' (h{})",
44 self.id,
45 self.first_text,
46 self.first_level,
47 self.second_text,
48 self.second_level
49 )
50 }
51}
52
53#[derive(Debug, Clone)]
55pub struct DuplicateAnchorError {
56 pub duplicates: Vec<DuplicateAnchor>,
58}
59
60impl std::fmt::Display for DuplicateAnchorError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 writeln!(f, "Found {} duplicate anchor ID(s):", self.duplicates.len())?;
63 for dup in &self.duplicates {
64 writeln!(f, " {dup}")?;
65 }
66 Ok(())
67 }
68}
69
70impl std::error::Error for DuplicateAnchorError {}
71
72pub fn deduplicate_anchor_ids(
81 headers: &mut [Header],
82 html: &mut String,
83) -> Vec<(String, String)> {
84 use std::collections::VecDeque;
85
86 use rustc_hash::{FxHashMap, FxHashSet};
87
88 let mut reserved = FxHashSet::default();
89 let mut rest = html.as_str();
90 let mut has_duplicates = false;
91 while let Some(start) = rest.find("id=\"") {
92 rest = &rest[start + 4..];
93 let Some(end) = rest.find('"') else {
94 break;
95 };
96 if !reserved.insert(&rest[..end]) {
97 has_duplicates = true;
98 }
99 rest = &rest[end + 1..];
100 }
101 if !has_duplicates {
102 return Vec::new();
103 }
104
105 let mut header_positions: FxHashMap<(String, u8), VecDeque<usize>> =
108 FxHashMap::default();
109 for (index, header) in headers.iter().enumerate() {
110 header_positions
111 .entry((header.id.clone(), header.level))
112 .or_default()
113 .push_back(index);
114 }
115
116 let mut used = FxHashSet::default();
117 let mut next_suffix: FxHashMap<&str, usize> = FxHashMap::default();
118 let mut renames = Vec::new();
119 let mut result = String::with_capacity(html.len());
120 let mut rest = html.as_str();
121 while let Some(start) = rest.find("id=\"") {
122 result.push_str(&rest[..start + 4]);
123 rest = &rest[start + 4..];
124 let Some(end) = rest.find('"') else {
125 result.push_str(rest);
126 rest = "";
127 break;
128 };
129 let old = &rest[..end];
130 let tag = result.rsplit('<').next().unwrap_or("");
131 let level = tag.as_bytes().get(0..2).and_then(|bytes| {
132 match bytes {
133 [b'h', digit @ b'1'..=b'6'] if tag.as_bytes().get(2) == Some(&b' ') => {
134 Some(digit - b'0')
135 },
136 _ => None,
137 }
138 });
139 let level = level.or_else(|| {
140 (tag.starts_with("span ")
141 && rest[end + 1..]
142 .split_once('>')
143 .is_some_and(|(attrs, _)| attrs.contains("nixos-anchor")))
144 .then_some(2)
145 });
146
147 let new_id = if used.insert(old.to_owned()) {
148 None
149 } else {
150 let suffix = next_suffix.entry(old).or_insert(1);
151 let candidate = loop {
152 let candidate = format!("{old}-{suffix}");
153 *suffix += 1;
154 if !reserved.contains(candidate.as_str()) && !used.contains(&candidate)
155 {
156 break candidate;
157 }
158 };
159 used.insert(candidate.clone());
160 renames.push((old.to_owned(), candidate.clone()));
161 Some(candidate)
162 };
163 if let Some(level) = level
164 && let Some(index) = header_positions
165 .get_mut(&(old.to_owned(), level))
166 .and_then(VecDeque::pop_front)
167 && let Some(new_id) = &new_id
168 {
169 headers[index].id.clone_from(new_id);
170 }
171 result.push_str(new_id.as_deref().unwrap_or(old));
172 rest = &rest[end..];
173 }
174 result.push_str(rest);
175 *html = result;
176 renames
177}
178
179pub fn validate_anchor_ids(
192 headers: &[Header],
193) -> Result<(), DuplicateAnchorError> {
194 use rustc_hash::FxHashMap;
195
196 let mut seen: FxHashMap<&str, (&str, u8)> = FxHashMap::default();
197 let mut duplicates = Vec::new();
198
199 for header in headers {
200 if let Some(&(first_text, first_level)) = seen.get(header.id.as_str()) {
201 duplicates.push(DuplicateAnchor {
202 id: header.id.clone(),
203 first_text: first_text.to_string(),
204 first_level,
205 second_text: header.text.clone(),
206 second_level: header.level,
207 });
208 } else {
209 seen.insert(&header.id, (&header.text, header.level));
210 }
211 }
212
213 if duplicates.is_empty() {
214 Ok(())
215 } else {
216 Err(DuplicateAnchorError { duplicates })
217 }
218}
219
220pub fn validate_rendered_anchor_ids(
226 headers: &[Header],
227 html: &str,
228) -> Result<(), DuplicateAnchorError> {
229 validate_anchor_ids(headers)?;
230
231 let mut seen = rustc_hash::FxHashSet::default();
232 let mut duplicate_ids = Vec::new();
233 let mut rest = html;
234 while let Some(start) = rest.find("id=\"") {
235 rest = &rest[start + 4..];
236 let Some(end) = rest.find('"') else {
237 break;
238 };
239 let id = &rest[..end];
240 if !seen.insert(id) && !duplicate_ids.iter().any(|seen| seen == id) {
241 duplicate_ids.push(id.to_owned());
242 }
243 rest = &rest[end + 1..];
244 }
245
246 if duplicate_ids.is_empty() {
247 return Ok(());
248 }
249
250 let duplicates = duplicate_ids
251 .into_iter()
252 .map(|id| {
253 let heading = headers.iter().find(|header| header.id == id);
254 let (first_text, first_level) = heading.map_or_else(
255 || ("non-heading anchor".to_owned(), 0),
256 |header| (header.text.clone(), header.level),
257 );
258 DuplicateAnchor {
259 id,
260 first_text,
261 first_level,
262 second_text: "non-heading anchor".to_owned(),
263 second_level: 0,
264 }
265 })
266 .collect();
267
268 Err(DuplicateAnchorError { duplicates })
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct MarkdownResult {
274 pub html: String,
276
277 pub headers: Vec<Header>,
279
280 pub title: Option<String>,
282
283 pub included_files: Vec<IncludedFile>,
285}
286
287#[cfg(test)]
288#[expect(clippy::unwrap_used, reason = "Fine in tests")]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_validate_anchor_ids_unique() {
294 let headers = vec![
295 Header {
296 text: "First".to_string(),
297 level: 1,
298 id: "first".to_string(),
299 },
300 Header {
301 text: "Second".to_string(),
302 level: 2,
303 id: "second".to_string(),
304 },
305 ];
306 assert!(validate_anchor_ids(&headers).is_ok());
307 }
308
309 #[test]
310 fn test_validate_anchor_ids_duplicate() {
311 let headers = vec![
312 Header {
313 text: "First".to_string(),
314 level: 1,
315 id: "same".to_string(),
316 },
317 Header {
318 text: "Second".to_string(),
319 level: 2,
320 id: "same".to_string(),
321 },
322 ];
323 let err = validate_anchor_ids(&headers).unwrap_err();
324 assert_eq!(err.duplicates.len(), 1);
325 assert_eq!(err.duplicates[0].id, "same");
326 }
327
328 #[test]
329 fn test_validate_anchor_ids_multiple_duplicates() {
330 let headers = vec![
331 Header {
332 text: "A".to_string(),
333 level: 1,
334 id: "x".to_string(),
335 },
336 Header {
337 text: "B".to_string(),
338 level: 2,
339 id: "x".to_string(),
340 },
341 Header {
342 text: "C".to_string(),
343 level: 1,
344 id: "y".to_string(),
345 },
346 Header {
347 text: "D".to_string(),
348 level: 2,
349 id: "y".to_string(),
350 },
351 ];
352 let err = validate_anchor_ids(&headers).unwrap_err();
353 assert_eq!(err.duplicates.len(), 2);
354 }
355
356 #[test]
357 fn test_validate_anchor_ids_empty() {
358 assert!(validate_anchor_ids(&[]).is_ok());
359 }
360
361 #[test]
362 fn test_deduplicate_anchor_ids_suffixes() {
363 let mut headers = vec![
364 Header {
365 text: "Inputs".to_string(),
366 level: 3,
367 id: "inputs".to_string(),
368 },
369 Header {
370 text: "Inputs".to_string(),
371 level: 3,
372 id: "inputs".to_string(),
373 },
374 Header {
375 text: "Type".to_string(),
376 level: 3,
377 id: "type".to_string(),
378 },
379 ];
380 let mut html = String::from(
381 "<h3 id=\"inputs\">Inputs</h3><h3 id=\"inputs\">Inputs</h3><h3 \
382 id=\"type\">Type</h3>",
383 );
384
385 let renames = deduplicate_anchor_ids(&mut headers, &mut html);
386 assert_eq!(headers[0].id, "inputs");
387 assert_eq!(headers[1].id, "inputs-1");
388 assert_eq!(headers[2].id, "type");
389 assert!(html.contains("id=\"inputs-1\""));
390 assert_eq!(renames, vec![(
391 "inputs".to_string(),
392 "inputs-1".to_string()
393 )]);
394 assert!(validate_anchor_ids(&headers).is_ok());
395 assert!(validate_rendered_anchor_ids(&headers, &html).is_ok());
396 }
397
398 #[test]
399 fn test_deduplicate_skips_existing_suffixed_id() {
400 let mut headers = vec![
401 Header {
402 text: "A".to_string(),
403 level: 2,
404 id: "x".to_string(),
405 },
406 Header {
407 text: "B".to_string(),
408 level: 2,
409 id: "x-1".to_string(),
410 },
411 Header {
412 text: "C".to_string(),
413 level: 2,
414 id: "x".to_string(),
415 },
416 ];
417 let mut html = String::from(
418 "<h2 id=\"x\">A</h2><h2 id=\"x-1\">B</h2><h2 id=\"x\">C</h2>",
419 );
420
421 deduplicate_anchor_ids(&mut headers, &mut html);
422 assert_eq!(headers[2].id, "x-2");
424 assert!(html.contains("id=\"x-2\""));
425 }
426
427 #[test]
428 fn test_deduplicate_preserves_heading_links_with_non_heading_collision() {
429 let mut headers = vec![
430 Header {
431 text: "Inputs".to_string(),
432 level: 2,
433 id: "inputs".to_string(),
434 },
435 Header {
436 text: "Inputs".to_string(),
437 level: 2,
438 id: "inputs".to_string(),
439 },
440 Header {
441 text: "Reserved".to_string(),
442 level: 2,
443 id: "inputs-1".to_string(),
444 },
445 ];
446 let mut html = String::from(
447 "<span id=\"inputs\"></span><h2 id=\"inputs\">Inputs</h2><h2 \
448 id=\"inputs\">Inputs</h2><h2 id=\"inputs-1\">Reserved</h2>",
449 );
450
451 deduplicate_anchor_ids(&mut headers, &mut html);
452 assert_eq!(
453 headers
454 .iter()
455 .map(|header| header.id.as_str())
456 .collect::<Vec<_>>(),
457 ["inputs-2", "inputs-3", "inputs-1"]
458 );
459 assert!(html.contains("<h2 id=\"inputs-2\">Inputs</h2>"));
460 assert!(html.contains("<h2 id=\"inputs-3\">Inputs</h2>"));
461 assert!(html.contains("<h2 id=\"inputs-1\">Reserved</h2>"));
462 validate_rendered_anchor_ids(&headers, &html).unwrap();
463 }
464
465 #[test]
466 fn test_deduplicate_updates_inline_anchor_metadata() {
467 let mut headers = vec![Header {
468 text: "Inline anchor".to_string(),
469 level: 2,
470 id: "shared".to_string(),
471 }];
472 let mut html = String::from(
473 "<span id=\"shared\"></span><span id=\"shared\" \
474 class=\"nixos-anchor\"></span>",
475 );
476
477 deduplicate_anchor_ids(&mut headers, &mut html);
478 assert_eq!(headers[0].id, "shared-1");
479 assert!(html.contains("<span id=\"shared-1\" class=\"nixos-anchor\">"));
480 validate_rendered_anchor_ids(&headers, &html).unwrap();
481 }
482}