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