Skip to main content

ndg_commonmark/utils/
mod.rs

1use std::{
2  path::{Path, PathBuf},
3  sync::{LazyLock, OnceLock, RwLock},
4};
5
6use rustc_hash::FxHashMap;
7pub mod codeblock;
8
9use comrak::{
10  Arena,
11  nodes::{AstNode, NodeHeading, NodeValue},
12  options::Options,
13  parse_document,
14};
15use regex::Regex;
16
17/// Error type for utility operations.
18#[derive(Debug, thiserror::Error)]
19pub enum UtilError {
20  #[error("Regex compilation failed: {0}")]
21  RegexError(#[from] regex::Error),
22}
23
24/// Result type for utility operations.
25pub type UtilResult<T> = Result<T, UtilError>;
26
27/// Slugify a string for use as an anchor ID. Converts to lowercase, replaces
28/// non-alphanumeric characters with dashes, and trims leading/trailing dashes.
29#[must_use]
30pub fn slugify(text: &str) -> String {
31  static CACHE: LazyLock<RwLock<FxHashMap<String, String>>> =
32    LazyLock::new(|| RwLock::new(FxHashMap::default()));
33
34  {
35    let cache = CACHE
36      .read()
37      .unwrap_or_else(std::sync::PoisonError::into_inner);
38    if let Some(cached) = cache.get(text) {
39      return cached.clone();
40    }
41  }
42
43  let result = text
44    .to_lowercase()
45    .replace(|c: char| !c.is_alphanumeric() && c != '-' && c != '_', "-")
46    .trim_matches('-')
47    .to_string();
48
49  let mut cache = CACHE
50    .write()
51    .unwrap_or_else(std::sync::PoisonError::into_inner);
52  if cache.len() < 2048 {
53    cache.insert(text.to_string(), result.clone());
54  }
55
56  result
57}
58
59/// Extract the first heading from markdown content as the page title.
60/// Returns [`None`] if no heading is found.
61#[must_use]
62pub fn extract_markdown_title(content: &str) -> Option<String> {
63  let arena = Arena::new();
64  let mut options = Options::default();
65  options.extension.table = true;
66  options.extension.footnotes = true;
67  options.extension.strikethrough = true;
68  options.extension.tasklist = true;
69  options.extension.superscript = true;
70  options.render.r#unsafe = true;
71
72  let root = parse_document(&arena, content, &options);
73
74  for node in root.descendants() {
75    if let NodeValue::Heading(NodeHeading { level, .. }) =
76      &node.data.borrow().value
77      && *level == 1
78    {
79      let text = extract_inline_text_from_node(node);
80      if !text.trim().is_empty() {
81        return Some(text.trim().to_string());
82      }
83    }
84  }
85  None
86}
87
88/// Extract all inline text from a node, recursively handling all inline
89/// elements.
90fn extract_inline_text_from_node<'a>(node: &'a AstNode<'a>) -> String {
91  let mut text = String::new();
92  for child in node.children() {
93    match &child.data.borrow().value {
94      NodeValue::Text(t) => text.push_str(t),
95      NodeValue::Code(t) => text.push_str(&t.literal),
96      NodeValue::Link(..)
97      | NodeValue::Emph
98      | NodeValue::Strong
99      | NodeValue::Strikethrough
100      | NodeValue::Superscript
101      | NodeValue::FootnoteReference(..) => {
102        text.push_str(&extract_inline_text_from_node(child));
103      },
104      #[expect(clippy::match_same_arms, reason = "Explicit for clarity")]
105      NodeValue::HtmlInline(_) | NodeValue::Image(..) => {},
106      _ => {},
107    }
108  }
109  text
110}
111
112/// Extract the first H1 heading from markdown content as the document title.
113/// Removes inline anchors and other markup from the title text.
114///
115/// # Returns
116///
117/// [`None`] if no H1 heading is found.
118///
119/// `Some(title, id)` if a H1 heading is found. id can be None if inline anchor
120/// does not exist.
121///
122/// # Panics
123///
124/// Panics if the fallback regex pattern fails to compile, which should never
125/// happen with the hardcoded pattern.
126#[must_use]
127pub fn extract_markdown_title_and_id(
128  content: &str,
129) -> Option<(String, Option<String>)> {
130  let arena = Arena::new();
131  let mut options = Options::default();
132  options.extension.table = true;
133  options.extension.footnotes = true;
134  options.extension.strikethrough = true;
135  options.extension.tasklist = true;
136  options.render.r#unsafe = true;
137
138  let root = parse_document(&arena, content, &options);
139
140  // Use a static regex to avoid compilation failures at runtime
141  #[expect(
142    clippy::items_after_statements,
143    reason = "Static is Scoped to function for clarity"
144  )]
145  static ANCHOR_RE: OnceLock<Regex> = OnceLock::new();
146  let anchor_re = ANCHOR_RE.get_or_init(|| {
147    Regex::new(r"(\[\])?\{#(.*?)\}").unwrap_or_else(|e| {
148      log::error!(
149        "Failed to compile ANCHOR_RE regex in extract_h1_title: {e}\n Falling \
150         back to never matching regex."
151      );
152      never_matching_regex().unwrap_or_else(|_| {
153        // As a last resort, create a regex that matches nothing
154        #[expect(
155          clippy::expect_used,
156          reason = "This pattern is guaranteed to be valid"
157        )]
158        Regex::new(r"[^\s\S]")
159          .expect("regex pattern [^\\s\\S] should always compile")
160      })
161    })
162  });
163
164  for node in root.descendants() {
165    if let NodeValue::Heading(NodeHeading { level, .. }) =
166      &node.data.borrow().value
167      && *level == 1
168    {
169      let text = extract_inline_text_from_node(node);
170      // Clean the title by removing inline anchors and other NDG markup
171      let anchor_id = anchor_re
172        .captures(&text)
173        .and_then(|caps| caps.get(2).map(|m| m.as_str().to_string()));
174      let clean_title = anchor_re.replace_all(&text, "").trim().to_string();
175      if !clean_title.is_empty() {
176        return Some((clean_title, anchor_id));
177      }
178    }
179  }
180  None
181}
182
183/// Clean anchor patterns from text (removes `{#anchor-id}` patterns).
184/// This is useful for cleaning titles and navigation text.
185///
186/// # Panics
187///
188/// Panics if fallback regex pattern fails to compile, which should never happen
189/// with hardcoded pattern.
190#[must_use]
191pub fn clean_anchor_patterns(text: &str) -> String {
192  static ANCHOR_PATTERN: OnceLock<Regex> = OnceLock::new();
193  let anchor_pattern = ANCHOR_PATTERN.get_or_init(|| {
194    Regex::new(r"\s*\{#[a-zA-Z0-9_-]+\}\s*$").unwrap_or_else(|e| {
195      log::error!(
196        "Failed to compile ANCHOR_PATTERN regex in clean_anchor_patterns: \
197         {e}\n Falling back to never matching regex."
198      );
199      never_matching_regex().unwrap_or_else(|_| {
200        // As a last resort, create a regex that matches nothing
201        #[expect(
202          clippy::expect_used,
203          reason = "This pattern is guaranteed to be valid"
204        )]
205        Regex::new(r"[^\s\S]")
206          .expect("regex pattern [^\\s\\S] should always compile")
207      })
208    })
209  });
210  anchor_pattern.replace_all(text.trim(), "").to_string()
211}
212
213/// Strip markdown formatting and return plain text.
214///
215/// This processes the markdown through the AST and extracts only text content,
216/// excluding code blocks and other formatting.
217#[must_use]
218pub fn strip_markdown(content: &str) -> String {
219  let arena = Arena::new();
220  let mut options = Options::default();
221  options.extension.table = true;
222  options.extension.footnotes = true;
223  options.extension.strikethrough = true;
224  options.extension.tasklist = true;
225  options.render.r#unsafe = true;
226
227  let root = parse_document(&arena, content, &options);
228
229  let mut plain_text = String::new();
230
231  #[expect(
232    clippy::items_after_statements,
233    reason = "Helper scoped for clarity"
234  )]
235  fn extract_text<'a>(
236    node: &'a AstNode<'a>,
237    plain_text: &mut String,
238    in_code_block: &mut bool,
239  ) {
240    match &node.data.borrow().value {
241      NodeValue::Paragraph => {
242        for child in node.children() {
243          extract_text(child, plain_text, in_code_block);
244        }
245        // Add paragraph break after each paragraph
246        plain_text.push('\n');
247      },
248      NodeValue::Heading(_) => {
249        for child in node.children() {
250          extract_text(child, plain_text, in_code_block);
251        }
252        // Add line break after heading
253        plain_text.push('\n');
254      },
255      NodeValue::Text(t) => {
256        if !*in_code_block {
257          plain_text.push_str(t);
258        }
259      },
260      NodeValue::CodeBlock(_) => {
261        *in_code_block = true;
262        for child in node.children() {
263          extract_text(child, plain_text, in_code_block);
264        }
265        *in_code_block = false;
266      },
267      NodeValue::SoftBreak => {
268        plain_text.push(' ');
269      },
270      NodeValue::LineBreak => {
271        plain_text.push('\n');
272      },
273      NodeValue::List(_) => {
274        for child in node.children() {
275          extract_text(child, plain_text, in_code_block);
276        }
277        plain_text.push('\n');
278      },
279      NodeValue::Code(c) => {
280        if !*in_code_block {
281          plain_text.push_str(&c.literal);
282        }
283      },
284      _ => {
285        for child in node.children() {
286          extract_text(child, plain_text, in_code_block);
287        }
288      },
289    }
290  }
291
292  let mut in_code_block = false;
293  extract_text(root, &mut plain_text, &mut in_code_block);
294  plain_text
295}
296
297/// Capitalize the first letter of a string.
298pub fn capitalize_first(s: &str) -> String {
299  let mut chars = s.chars();
300  chars.next().map_or_else(String::new, |c| {
301    c.to_uppercase().collect::<String>() + chars.as_str()
302  })
303}
304
305/// Return true if the string looks like a markdown header (starts with #).
306#[must_use]
307pub fn is_markdown_header(line: &str) -> bool {
308  line.trim_start().starts_with('#')
309}
310
311/// Errors while loading manpage URL mappings.
312#[derive(Debug, thiserror::Error)]
313pub enum ManpageUrlsError {
314  /// The mappings file could not be read.
315  #[error("failed to read manpage URL mappings from {path}")]
316  Read {
317    /// Path of the mappings file.
318    path:   PathBuf,
319    /// Underlying filesystem error.
320    #[source]
321    source: std::io::Error,
322  },
323  /// The mappings file was not valid JSON.
324  #[error("failed to parse manpage URL mappings from {path}")]
325  Parse {
326    /// Path of the mappings file.
327    path:   PathBuf,
328    /// Underlying JSON error.
329    #[source]
330    source: serde_json::Error,
331  },
332}
333
334/// Load manpage URL mappings from a JSON file.
335///
336/// # Errors
337///
338/// Returns an error if the file cannot be read or if the JSON is invalid.
339pub fn load_manpage_urls(
340  path: impl AsRef<Path>,
341) -> Result<FxHashMap<String, String>, ManpageUrlsError> {
342  let path = path.as_ref();
343  let content = std::fs::read_to_string(path).map_err(|source| {
344    ManpageUrlsError::Read {
345      path: path.to_path_buf(),
346      source,
347    }
348  })?;
349  let mappings = serde_json::from_str(&content).map_err(|source| {
350    ManpageUrlsError::Parse {
351      path: path.to_path_buf(),
352      source,
353    }
354  })?;
355  Ok(mappings)
356}
357
358/// Create a regex that never matches anything.
359///
360/// This is used as a fallback pattern when a regex fails to compile.
361/// It will never match any input, which is safer than using a trivial regex
362/// like `^$` which would match empty strings.
363///
364/// # Errors
365///
366/// Returns an error if both primary and fallback regex patterns fail to
367/// compile, which should never happen with hardcoded patterns.
368pub fn never_matching_regex() -> Result<regex::Regex, regex::Error> {
369  // Use a pattern that will never match anything because it asserts something
370  // impossible - this pattern is guaranteed to be valid
371  regex::Regex::new(r"[^\s\S]").or_else(|_| {
372    // As an ultimate fallback, use an empty pattern that matches nothing
373    // This SHOULD NOT happen.
374    regex::Regex::new(r"^\b$")
375  })
376}