Skip to main content

ndg_commonmark/
types.rs

1//! Types for ndg-commonmark public API and internal use.
2use serde::{Deserialize, Serialize};
3
4/// Represents a header in a Markdown document.
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6pub struct Header {
7  /// Header text (inline content, no markdown formatting).
8  pub text:  String,
9  /// Header level (1-6).
10  pub level: u8,
11  /// Generated or explicit anchor ID for the header.
12  pub id:    String,
13}
14
15/// Represents a file that was included via `{=include=}` directive.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct IncludedFile {
18  /// Path to the included file.
19  pub path:          String,
20  /// Optional custom output path from `html:into-file` directive.
21  pub custom_output: Option<String>,
22}
23
24/// A single duplicate anchor occurrence with context.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DuplicateAnchor {
27  /// The duplicated anchor ID.
28  pub id:           String,
29  /// Text of the first heading with this ID.
30  pub first_text:   String,
31  /// Level of the first heading.
32  pub first_level:  u8,
33  /// Text of the second heading with this ID.
34  pub second_text:  String,
35  /// Level of the second heading.
36  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/// Error returned when duplicate anchor IDs are detected.
54#[derive(Debug, Clone)]
55pub struct DuplicateAnchorError {
56  /// All duplicate anchors found in the document.
57  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
72/// Make all rendered anchor IDs unique with deterministic `-1`, `-2`, etc.
73/// suffixes, updating matching heading metadata for navigation and search.
74///
75/// The first occurrence keeps its ID. Generated suffixes skip IDs already
76/// present anywhere in the document, preserving explicit anchors even when
77/// they occur after a duplicate.
78///
79/// Returns a list of `(old_id, new_id)` renames for logging.
80pub 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  // Only actual headings and generated inline anchors correspond to Header
106  // entries. Raw HTML IDs must not consume a heading's occurrence.
107  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
179/// Validate that all heading anchor IDs are unique.
180///
181/// Returns `Err(DuplicateAnchorError)` if any two headings share the same
182/// anchor ID, which would produce invalid HTML and broken navigation links.
183///
184/// # Arguments
185///
186/// * `headers` - Slice of extracted headers to validate
187///
188/// # Errors
189///
190/// Returns an error listing all duplicate anchor IDs found.
191pub 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
220/// Validate that all IDs in rendered HTML are unique.
221///
222/// # Errors
223///
224/// Returns an error listing all duplicate anchor IDs found.
225pub 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/// Result of Markdown processing.
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273pub struct MarkdownResult {
274  /// Rendered HTML output.
275  pub html: String,
276
277  /// Extracted headers (for `ToC`, navigation, etc).
278  pub headers: Vec<Header>,
279
280  /// Title of the document, if found (usually first H1).
281  pub title: Option<String>,
282
283  /// Files that were included via `{=include=}` directives.
284  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    // Third header must skip the taken `x-1` and become `x-2`.
423    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}