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::sync::LazyLock;
24
25use comrak::nodes::AstNode;
26use regex::Regex;
27use rustc_hash::{FxHashMap, FxHashSet};
28
29static COMMAND_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
30  Regex::new(r"^\s*\$\s+(.+)$").unwrap_or_else(|e| {
31    log::error!(
32      "Failed to compile COMMAND_PROMPT_RE regex: {e}\n Falling back to never \
33       matching regex."
34    );
35    crate::utils::never_matching_regex().unwrap_or_else(|_| {
36      #[expect(
37        clippy::expect_used,
38        reason = "This pattern is guaranteed to be valid"
39      )]
40      Regex::new(r"[^\s\S]")
41        .expect("regex pattern [^\\s\\S] should always compile")
42    })
43  })
44});
45
46static REPL_PROMPT_RE: LazyLock<Regex> = LazyLock::new(|| {
47  Regex::new(r"^nix-repl>\s*(.*)$").unwrap_or_else(|e| {
48    log::error!(
49      "Failed to compile REPL_PROMPT_RE regex: {e}\n Falling back to never \
50       matching regex."
51    );
52    crate::utils::never_matching_regex().unwrap_or_else(|_| {
53      #[expect(
54        clippy::expect_used,
55        reason = "This pattern is guaranteed to be valid"
56      )]
57      Regex::new(r"[^\s\S]")
58        .expect("regex pattern [^\\s\\S] should always compile")
59    })
60  })
61});
62
63/// Options for configuring the Markdown processor.
64#[derive(Debug, Clone)]
65#[expect(
66  clippy::struct_excessive_bools,
67  reason = "Config struct with related boolean flags"
68)]
69pub struct MarkdownOptions {
70  /// Enable GitHub Flavored Markdown (GFM) extensions.
71  pub gfm: bool,
72
73  /// Enable Nixpkgs/NixOS documentation extensions.
74  pub nixpkgs: bool,
75
76  /// Enable syntax highlighting for code blocks.
77  pub highlight_code: bool,
78
79  /// Optional: Custom syntax highlighting theme name.
80  pub highlight_theme: Option<String>,
81
82  /// Optional: Path to manpage URL mappings (for {manpage} roles).
83  pub manpage_urls_path: Option<String>,
84
85  /// Optional: Path to user-provided Tree-sitter query overrides.
86  ///
87  /// Expected layout:
88  /// `queries/<language>/{highlights,injections,locals}.scm`
89  pub syntax_queries_path: Option<String>,
90
91  /// Enable automatic linking for option role markup.
92  /// When `true`, `{option}` roles will be converted to links to options.html.
93  /// When `false`, they will be rendered as plain `<code>` elements.
94  pub auto_link_options: bool,
95
96  /// Optional: Set of valid option names for validation.
97  /// When provided, only options in this set will be auto-linked.
98  /// When `None`, all options will be linked (no validation).
99  pub valid_options: Option<FxHashSet<String>>,
100
101  /// How to handle hard tabs in code blocks.
102  pub tab_style: TabStyle,
103}
104
105/// Configuration for handling hard tabs in code blocks.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum TabStyle {
108  /// Leave hard tabs unchanged
109  None,
110  /// Issue a warning when hard tabs are detected
111  Warn,
112  /// Automatically convert hard tabs to spaces (using 2 spaces per tab)
113  Normalize,
114}
115
116impl MarkdownOptions {
117  /// Enable all available features based on compile-time feature flags.
118  #[must_use]
119  pub const fn with_all_features() -> Self {
120    Self {
121      gfm:                 cfg!(feature = "gfm"),
122      nixpkgs:             cfg!(feature = "nixpkgs"),
123      highlight_code:      cfg!(any(
124        feature = "syntastica",
125        feature = "syntect"
126      )),
127      highlight_theme:     None,
128      manpage_urls_path:   None,
129      syntax_queries_path: None,
130      auto_link_options:   true,
131      valid_options:       None,
132      tab_style:           TabStyle::None,
133    }
134  }
135
136  /// Create options with runtime feature overrides.
137  #[must_use]
138  pub const fn with_features(
139    gfm: bool,
140    nixpkgs: bool,
141    highlight_code: bool,
142  ) -> Self {
143    Self {
144      gfm,
145      nixpkgs,
146      highlight_code,
147      highlight_theme: None,
148      manpage_urls_path: None,
149      syntax_queries_path: None,
150      auto_link_options: true,
151      valid_options: None,
152      tab_style: TabStyle::None,
153    }
154  }
155}
156
157impl Default for MarkdownOptions {
158  fn default() -> Self {
159    Self {
160      gfm:                 cfg!(feature = "gfm"),
161      nixpkgs:             cfg!(feature = "nixpkgs"),
162      highlight_code:      cfg!(feature = "syntastica"),
163      manpage_urls_path:   None,
164      syntax_queries_path: None,
165      highlight_theme:     None,
166      auto_link_options:   true,
167      valid_options:       None,
168      tab_style:           TabStyle::None,
169    }
170  }
171}
172
173/// Main Markdown processor.
174///
175/// Can be cheaply cloned since it uses `Arc` internally for the syntax manager.
176#[derive(Clone)]
177pub struct MarkdownProcessor {
178  pub(crate) options:        MarkdownOptions,
179  pub(crate) manpage_urls:   Option<FxHashMap<String, String>>,
180  pub(crate) syntax_manager: Option<crate::syntax::SyntaxManager>,
181  pub(crate) base_dir:       std::path::PathBuf,
182}
183
184/// Trait for AST transformations (e.g., prompt highlighting).
185pub trait AstTransformer {
186  fn transform<'a>(&self, node: &'a AstNode<'a>);
187}
188
189/// AST transformer for processing command and REPL prompts in inline code
190/// blocks.
191pub struct PromptTransformer;
192
193impl AstTransformer for PromptTransformer {
194  fn transform<'a>(&self, node: &'a AstNode<'a>) {
195    use comrak::nodes::NodeValue;
196
197    for child in node.children() {
198      {
199        let mut data = child.data.borrow_mut();
200        if let NodeValue::Code(ref code) = data.value {
201          let literal = code.literal.trim();
202
203          // Match command prompts with flexible whitespace
204          if let Some(caps) = COMMAND_PROMPT_RE.captures(literal) {
205            // Skip escaped prompts
206            if !literal.starts_with("\\$") && !literal.starts_with("$$") {
207              let command = caps[1].trim();
208              let html = format!(
209                "<code class=\"terminal\"><span class=\"prompt\">$</span> \
210                 {command}</code>"
211              );
212              data.value = NodeValue::HtmlInline(html);
213            }
214          } else if let Some(caps) = REPL_PROMPT_RE.captures(literal) {
215            // Skip double prompts
216            if !literal.starts_with("nix-repl>>") {
217              let expression = caps[1].trim();
218              let html = format!(
219                "<code class=\"nix-repl\"><span \
220                 class=\"prompt\">nix-repl&gt;</span> {expression}</code>"
221              );
222              data.value = NodeValue::HtmlInline(html);
223            }
224          }
225        }
226      }
227      self.transform(child);
228    }
229  }
230}
231
232/// Builder for constructing `MarkdownOptions` with method chaining.
233#[derive(Debug, Clone)]
234pub struct MarkdownOptionsBuilder {
235  options: MarkdownOptions,
236}
237
238impl MarkdownOptionsBuilder {
239  /// Create a new builder with default options.
240  #[must_use]
241  pub fn new() -> Self {
242    Self {
243      options: MarkdownOptions::default(),
244    }
245  }
246
247  /// Enable or disable GitHub Flavored Markdown.
248  #[must_use]
249  pub const fn gfm(mut self, enabled: bool) -> Self {
250    self.options.gfm = enabled;
251    self
252  }
253
254  /// Enable or disable Nixpkgs extensions.
255  #[must_use]
256  pub const fn nixpkgs(mut self, enabled: bool) -> Self {
257    self.options.nixpkgs = enabled;
258    self
259  }
260
261  /// Enable or disable syntax highlighting.
262  #[must_use]
263  pub const fn highlight_code(mut self, enabled: bool) -> Self {
264    self.options.highlight_code = enabled;
265    self
266  }
267
268  /// Set the syntax highlighting theme.
269  #[must_use]
270  pub fn highlight_theme<S: Into<String>>(self, theme: Option<S>) -> Self {
271    fn inner(
272      mut this: MarkdownOptionsBuilder,
273      theme: Option<String>,
274    ) -> MarkdownOptionsBuilder {
275      this.options.highlight_theme = theme;
276      this
277    }
278    inner(self, theme.map(Into::into))
279  }
280
281  /// Set the manpage URLs path.
282  #[must_use]
283  pub fn manpage_urls_path<S: Into<String>>(self, path: Option<S>) -> Self {
284    fn inner(
285      mut this: MarkdownOptionsBuilder,
286      path: Option<String>,
287    ) -> MarkdownOptionsBuilder {
288      this.options.manpage_urls_path = path;
289      this
290    }
291    inner(self, path.map(Into::into))
292  }
293
294  /// Set the path to user-provided Tree-sitter query overrides.
295  #[must_use]
296  pub fn syntax_queries_path<S: Into<String>>(self, path: Option<S>) -> Self {
297    fn inner(
298      mut this: MarkdownOptionsBuilder,
299      path: Option<String>,
300    ) -> MarkdownOptionsBuilder {
301      this.options.syntax_queries_path = path;
302      this
303    }
304    inner(self, path.map(Into::into))
305  }
306
307  /// Enable or disable automatic linking for {option} role markup.
308  #[must_use]
309  pub const fn auto_link_options(mut self, enabled: bool) -> Self {
310    self.options.auto_link_options = enabled;
311    self
312  }
313
314  /// Set the valid options for validation.
315  #[must_use]
316  pub fn valid_options(mut self, options: Option<FxHashSet<String>>) -> Self {
317    self.options.valid_options = options;
318    self
319  }
320
321  /// Set how to handle hard tabs in code blocks.
322  #[must_use]
323  pub const fn tab_style(mut self, style: TabStyle) -> Self {
324    self.options.tab_style = style;
325    self
326  }
327
328  /// Build the final `MarkdownOptions`.
329  #[must_use]
330  pub fn build(self) -> MarkdownOptions {
331    self.options
332  }
333}
334
335impl Default for MarkdownOptionsBuilder {
336  fn default() -> Self {
337    Self::new()
338  }
339}