Skip to main content

ndg_commonmark/processor/
types.rs

1//! Type definitions for the Markdown processor.
2//!
3//! Contains all the core types used by the processor, including:
4//! - Configuration options (`MarkdownOptions`)
5//! - The main processor struct (`MarkdownProcessor`)
6//! - AST transformation traits and implementations
7//!
8//! # Examples
9//!
10//! ```
11//! use ndg_commonmark::{MarkdownOptions, MarkdownProcessor};
12//!
13//! let options = MarkdownOptions {
14//!   gfm: true,
15//!   nixpkgs: true,
16//!   highlight_code: true,
17//!   ..Default::default()
18//! };
19//!
20//! let processor = MarkdownProcessor::new(options);
21//! ```
22
23use std::{str::FromStr, sync::LazyLock};
24
25use comrak::nodes::AstNode;
26use regex::Regex;
27use rustc_hash::{FxHashMap, FxHashSet};
28use serde::{Deserialize, Serialize};
29
30static COMMAND_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
31  Regex::new(r"^\s*\$\s+(.+)$").unwrap_or_else(|e| {
32    log::error!(
33      "Failed to compile COMMAND_PROMPT_RE regex: {e}\n Falling back to never \
34       matching regex."
35    );
36    crate::utils::never_matching_regex().unwrap_or_else(|_| {
37      #[expect(
38        clippy::expect_used,
39        reason = "This pattern is guaranteed to be valid"
40      )]
41      Regex::new(r"[^\s\S]")
42        .expect("regex pattern [^\\s\\S] should always compile")
43    })
44  })
45});
46
47static REPL_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
48  Regex::new(r"^nix-repl>\s*(.*)$").unwrap_or_else(|e| {
49    log::error!(
50      "Failed to compile REPL_PROMPT_RE regex: {e}\n Falling back to never \
51       matching regex."
52    );
53    crate::utils::never_matching_regex().unwrap_or_else(|_| {
54      #[expect(
55        clippy::expect_used,
56        reason = "This pattern is guaranteed to be valid"
57      )]
58      Regex::new(r"[^\s\S]")
59        .expect("regex pattern [^\\s\\S] should always compile")
60    })
61  })
62});
63
64/// Options for configuring the Markdown processor.
65#[derive(Debug, Clone)]
66#[expect(
67  clippy::struct_excessive_bools,
68  reason = "Config struct with related boolean flags"
69)]
70pub struct MarkdownOptions {
71  /// Enable GitHub Flavored Markdown (GFM) extensions.
72  pub gfm: bool,
73
74  /// Enable Nixpkgs/NixOS documentation extensions.
75  pub nixpkgs: bool,
76
77  /// Enable syntax highlighting for code blocks.
78  pub highlight_code: bool,
79
80  /// Additional Comrak Markdown extensions to enable.
81  pub extensions: Vec<MarkdownExtension>,
82
83  /// Optional: Custom syntax highlighting theme name.
84  pub highlight_theme: Option<String>,
85
86  /// Optional URL mappings for `{manpage}` roles.
87  pub manpage_urls: Option<FxHashMap<String, String>>,
88
89  /// Optional: Path to user-provided Tree-sitter query overrides.
90  ///
91  /// Expected layout:
92  /// `queries/<language>/{highlights,injections,locals}.scm`
93  pub syntax_queries_path: Option<String>,
94
95  /// Enable automatic linking for option role markup.
96  /// When `true`, `{option}` roles will be converted to links to options.html.
97  /// When `false`, they will be rendered as plain `<code>` elements.
98  pub auto_link_options: bool,
99
100  /// Optional: Set of valid option names for validation.
101  /// When provided, only options in this set will be auto-linked.
102  /// When `None`, all options will be linked (no validation).
103  pub valid_options: Option<FxHashSet<String>>,
104
105  /// How to handle hard tabs in code blocks.
106  pub tab_style: TabStyle,
107}
108
109/// Configuration for handling hard tabs in code blocks.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum TabStyle {
112  /// Leave hard tabs unchanged
113  None,
114  /// Issue a warning when hard tabs are detected
115  Warn,
116  /// Automatically convert hard tabs to spaces (using 2 spaces per tab)
117  Normalize,
118}
119
120impl MarkdownOptions {
121  /// Enable all available features based on compile-time feature flags.
122  #[must_use]
123  pub const fn with_all_features() -> Self {
124    Self {
125      gfm:                 cfg!(feature = "gfm"),
126      nixpkgs:             cfg!(feature = "nixpkgs"),
127      highlight_code:      cfg!(any(
128        feature = "syntastica",
129        feature = "syntect"
130      )),
131      extensions:          Vec::new(),
132      highlight_theme:     None,
133      manpage_urls:        None,
134      syntax_queries_path: None,
135      auto_link_options:   true,
136      valid_options:       None,
137      tab_style:           TabStyle::None,
138    }
139  }
140
141  /// Create options with runtime feature overrides.
142  #[must_use]
143  pub const fn with_features(
144    gfm: bool,
145    nixpkgs: bool,
146    highlight_code: bool,
147  ) -> Self {
148    Self {
149      gfm,
150      nixpkgs,
151      highlight_code,
152      extensions: Vec::new(),
153      highlight_theme: None,
154      manpage_urls: None,
155      syntax_queries_path: None,
156      auto_link_options: true,
157      valid_options: None,
158      tab_style: TabStyle::None,
159    }
160  }
161}
162
163impl Default for MarkdownOptions {
164  fn default() -> Self {
165    Self {
166      gfm:                 cfg!(feature = "gfm"),
167      nixpkgs:             cfg!(feature = "nixpkgs"),
168      highlight_code:      cfg!(feature = "syntastica"),
169      extensions:          Vec::new(),
170      manpage_urls:        None,
171      syntax_queries_path: None,
172      highlight_theme:     None,
173      auto_link_options:   true,
174      valid_options:       None,
175      tab_style:           TabStyle::None,
176    }
177  }
178}
179
180/// Optional Markdown syntax extensions provided by Comrak.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "kebab-case")]
183pub enum MarkdownExtension {
184  /// GitHub-style alerts.
185  Alerts,
186  /// Automatic links.
187  Autolink,
188  /// Container block directives using `:::`.
189  BlockDirective,
190  /// CJK-friendly emphasis parsing.
191  CjkFriendlyEmphasis,
192  /// Greentext parsing.
193  Greentext,
194  /// Description lists.
195  DescriptionLists,
196  /// Footnotes.
197  Footnotes,
198  /// Highlighted text using `==`.
199  Highlight,
200  /// Inserted text using `++`.
201  Insert,
202  /// Inline footnotes using `^[...]`.
203  InlineFootnotes,
204  /// Math using code syntax.
205  MathCode,
206  /// Math using dollar syntax.
207  MathDollars,
208  /// Math using LaTeX delimiters.
209  MathLatex,
210  /// Multiline block quotes.
211  MultilineBlockQuotes,
212  /// Spoiler text using `||`.
213  Spoiler,
214  /// Strikethrough text.
215  Strikethrough,
216  /// Subscript text using `~`.
217  Subscript,
218  /// Block-scoped subtext.
219  Subtext,
220  /// Superscript text.
221  Superscript,
222  /// Tables.
223  Table,
224  /// Task lists.
225  Tasklist,
226  /// Underlined text using `__`.
227  Underline,
228  /// Wikilinks with the title after the pipe.
229  WikilinksTitleAfterPipe,
230  /// Wikilinks with the title before the pipe.
231  WikilinksTitleBeforePipe,
232}
233
234impl FromStr for MarkdownExtension {
235  type Err = String;
236
237  fn from_str(value: &str) -> Result<Self, Self::Err> {
238    match value {
239      "alerts" => Ok(Self::Alerts),
240      "autolink" => Ok(Self::Autolink),
241      "block-directive" => Ok(Self::BlockDirective),
242      "cjk-friendly-emphasis" => Ok(Self::CjkFriendlyEmphasis),
243      "greentext" => Ok(Self::Greentext),
244      "description-lists" => Ok(Self::DescriptionLists),
245      "footnotes" => Ok(Self::Footnotes),
246      "highlight" => Ok(Self::Highlight),
247      "insert" => Ok(Self::Insert),
248      "inline-footnotes" => Ok(Self::InlineFootnotes),
249      "math-code" => Ok(Self::MathCode),
250      "math-dollars" => Ok(Self::MathDollars),
251      "math-latex" => Ok(Self::MathLatex),
252      "multiline-block-quotes" => Ok(Self::MultilineBlockQuotes),
253      "spoiler" => Ok(Self::Spoiler),
254      "strikethrough" => Ok(Self::Strikethrough),
255      "subscript" => Ok(Self::Subscript),
256      "subtext" => Ok(Self::Subtext),
257      "superscript" => Ok(Self::Superscript),
258      "table" => Ok(Self::Table),
259      "tasklist" => Ok(Self::Tasklist),
260      "underline" => Ok(Self::Underline),
261      "wikilinks-title-after-pipe" => Ok(Self::WikilinksTitleAfterPipe),
262      "wikilinks-title-before-pipe" => Ok(Self::WikilinksTitleBeforePipe),
263      _ => Err(format!("unknown Markdown extension: {value}")),
264    }
265  }
266}
267
268/// Main Markdown processor.
269///
270/// Can be cheaply cloned since it uses `Arc` internally for the syntax manager.
271#[derive(Clone)]
272pub struct MarkdownProcessor {
273  pub(crate) options:        MarkdownOptions,
274  pub(crate) manpage_urls:   Option<FxHashMap<String, String>>,
275  pub(crate) syntax_manager: Option<crate::syntax::SyntaxManager>,
276  pub(crate) base_dir:       std::path::PathBuf,
277}
278
279/// Trait for AST transformations (e.g., prompt highlighting).
280pub trait AstTransformer {
281  fn transform<'a>(&self, node: &'a AstNode<'a>);
282}
283
284/// AST transformer for processing command and REPL prompts in inline code
285/// blocks.
286pub struct PromptTransformer;
287
288impl AstTransformer for PromptTransformer {
289  fn transform<'a>(&self, node: &'a AstNode<'a>) {
290    use comrak::nodes::NodeValue;
291
292    for child in node.children() {
293      {
294        let mut data = child.data.borrow_mut();
295        if let NodeValue::Code(ref code) = data.value {
296          let literal = code.literal.trim();
297
298          // Match command prompts with flexible whitespace
299          if let Some(caps) = COMMAND_PROMPT_RE.captures(literal) {
300            // Skip escaped prompts
301            if !literal.starts_with("\\$") && !literal.starts_with("$$") {
302              let command = caps[1].trim();
303              let html = format!(
304                "<code class=\"terminal\"><span class=\"prompt\">$</span> \
305                 {command}</code>"
306              );
307              data.value = NodeValue::HtmlInline(html);
308            }
309          } else if let Some(caps) = REPL_PROMPT_RE.captures(literal) {
310            // Skip double prompts
311            if !literal.starts_with("nix-repl>>") {
312              let expression = caps[1].trim();
313              let html = format!(
314                "<code class=\"nix-repl\"><span \
315                 class=\"prompt\">nix-repl&gt;</span> {expression}</code>"
316              );
317              data.value = NodeValue::HtmlInline(html);
318            }
319          }
320        }
321      }
322      self.transform(child);
323    }
324  }
325}
326
327/// Builder for constructing `MarkdownOptions` with method chaining.
328#[derive(Debug, Clone)]
329pub struct MarkdownOptionsBuilder {
330  options: MarkdownOptions,
331}
332
333impl MarkdownOptionsBuilder {
334  /// Create a new builder with default options.
335  #[must_use]
336  pub fn new() -> Self {
337    Self {
338      options: MarkdownOptions::default(),
339    }
340  }
341
342  /// Enable or disable GitHub Flavored Markdown.
343  #[must_use]
344  pub const fn gfm(mut self, enabled: bool) -> Self {
345    self.options.gfm = enabled;
346    self
347  }
348
349  /// Enable or disable Nixpkgs extensions.
350  #[must_use]
351  pub const fn nixpkgs(mut self, enabled: bool) -> Self {
352    self.options.nixpkgs = enabled;
353    self
354  }
355
356  /// Enable or disable syntax highlighting.
357  #[must_use]
358  pub const fn highlight_code(mut self, enabled: bool) -> Self {
359    self.options.highlight_code = enabled;
360    self
361  }
362
363  /// Enable additional Comrak Markdown extensions.
364  #[must_use]
365  pub fn extensions(mut self, extensions: Vec<MarkdownExtension>) -> Self {
366    self.options.extensions = extensions;
367    self
368  }
369
370  /// Set the syntax highlighting theme.
371  #[must_use]
372  pub fn highlight_theme<S: Into<String>>(self, theme: Option<S>) -> Self {
373    fn inner(
374      mut this: MarkdownOptionsBuilder,
375      theme: Option<String>,
376    ) -> MarkdownOptionsBuilder {
377      this.options.highlight_theme = theme;
378      this
379    }
380    inner(self, theme.map(Into::into))
381  }
382
383  /// Set URL mappings for `{manpage}` roles.
384  #[must_use]
385  pub fn manpage_urls(
386    mut self,
387    mappings: Option<FxHashMap<String, String>>,
388  ) -> Self {
389    self.options.manpage_urls = mappings;
390    self
391  }
392
393  /// Set the path to user-provided Tree-sitter query overrides.
394  #[must_use]
395  pub fn syntax_queries_path<S: Into<String>>(self, path: Option<S>) -> Self {
396    fn inner(
397      mut this: MarkdownOptionsBuilder,
398      path: Option<String>,
399    ) -> MarkdownOptionsBuilder {
400      this.options.syntax_queries_path = path;
401      this
402    }
403    inner(self, path.map(Into::into))
404  }
405
406  /// Enable or disable automatic linking for {option} role markup.
407  #[must_use]
408  pub const fn auto_link_options(mut self, enabled: bool) -> Self {
409    self.options.auto_link_options = enabled;
410    self
411  }
412
413  /// Set the valid options for validation.
414  #[must_use]
415  pub fn valid_options(mut self, options: Option<FxHashSet<String>>) -> Self {
416    self.options.valid_options = options;
417    self
418  }
419
420  /// Set how to handle hard tabs in code blocks.
421  #[must_use]
422  pub const fn tab_style(mut self, style: TabStyle) -> Self {
423    self.options.tab_style = style;
424    self
425  }
426
427  /// Build the final `MarkdownOptions`.
428  #[must_use]
429  pub fn build(self) -> MarkdownOptions {
430    self.options
431  }
432}
433
434impl Default for MarkdownOptionsBuilder {
435  fn default() -> Self {
436    Self::new()
437  }
438}