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