Skip to main content

ndg_commonmark/processor/
process.rs

1//! Main processing functions for Markdown content.
2use std::{
3  fs,
4  io::Error,
5  path::{Path, PathBuf},
6};
7
8use log::error;
9
10use super::types::{MarkdownOptions, MarkdownProcessor, TabStyle};
11use crate::types::MarkdownResult;
12
13/// Process markdown content with error recovery.
14///
15/// Attempts to process the markdown content and falls back to
16/// safe alternatives if processing fails at any stage.
17///
18/// # Arguments
19///
20/// * `processor` - The configured markdown processor
21/// * `content` - The raw markdown content to process
22///
23/// # Returns
24///
25/// A `MarkdownResult` with processed HTML, headers, and title
26#[must_use]
27pub fn process_with_recovery(
28  processor: &MarkdownProcessor,
29  content: &str,
30) -> MarkdownResult {
31  match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
32    processor.render(content)
33  })) {
34    Ok(result) => result,
35    Err(panic_err) => {
36      error!("Panic during markdown processing: {panic_err:?}");
37      MarkdownResult {
38        html: "<div class=\"error\">Critical error processing markdown \
39               content</div>"
40          .to_string(),
41
42        headers:        Vec::new(),
43        title:          None,
44        included_files: Vec::new(),
45      }
46    },
47  }
48}
49
50/// Safely process markup content with error recovery.
51///
52/// Provides a safe wrapper around markup processing operations
53/// that may fail, and ensures that partial or fallback content is returned
54/// rather than complete failure.
55///
56/// # Arguments
57///
58/// * `content` - The content to process
59/// * `processor_fn` - The processing function to apply
60/// * `fallback` - Fallback content to use if processing fails
61///
62/// # Returns
63///
64/// The processed content or fallback on error
65pub fn process_safe<F>(content: &str, processor_fn: F, fallback: &str) -> String
66where
67  F: FnOnce(&str) -> String,
68{
69  // Avoid processing empty strings
70  if content.is_empty() {
71    return String::new();
72  }
73
74  // Catch any potential panics caused by malformed input or processing errors
75  let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
76    processor_fn(content)
77  }));
78
79  match result {
80    Ok(processed_text) => processed_text,
81    Err(e) => {
82      // Log the error but allow the program to continue
83      if let Some(error_msg) = e.downcast_ref::<String>() {
84        error!("Error processing markup: {error_msg}");
85      } else if let Some(error_msg) = e.downcast_ref::<&str>() {
86        error!("Error processing markup: {error_msg}");
87      } else {
88        error!("Unknown error occurred while processing markup");
89      }
90
91      // Return the original text or default value to prevent breaking the
92      // entire document
93      if fallback.is_empty() {
94        content.to_string()
95      } else {
96        fallback.to_string()
97      }
98    },
99  }
100}
101
102/// Process a batch of markdown files with consistent error handling.
103///
104/// This function processes multiple markdown files using the same processor
105/// configuration, collecting results and handling errors gracefully.
106///
107/// # Arguments
108/// * `processor` - The configured markdown processor
109/// * `files` - Iterator of file paths to process
110/// * `read_file_fn` - Function to read file content from path
111///
112/// # Returns
113/// Vector of tuples containing (`file_path`, `processing_result`)
114pub fn process_batch<I, F>(
115  processor: &MarkdownProcessor,
116  files: I,
117  read_file_fn: F,
118) -> Vec<(String, Result<MarkdownResult, String>)>
119where
120  I: Iterator<Item = PathBuf>,
121  F: Fn(&Path) -> Result<String, Error>,
122{
123  files
124    .map(|path| {
125      let path_str = path.display().to_string();
126      let result = match read_file_fn(&path) {
127        Ok(content) => Ok(process_with_recovery(processor, &content)),
128        Err(e) => Err(format!("Failed to read file: {e}")),
129      };
130      (path_str, result)
131    })
132    .collect()
133}
134
135/// Create a processor with sensible defaults for library usage.
136///
137/// Provides a convenient way to create a processor with
138/// commonly used settings for different use cases.
139///
140/// # Arguments
141///
142/// * `preset` - The preset configuration to use
143///
144/// # Returns
145///
146/// A configured `MarkdownProcessor`
147#[must_use]
148pub fn create_processor(preset: ProcessorPreset) -> MarkdownProcessor {
149  MarkdownProcessor::new(options_for_preset(preset))
150}
151
152fn options_for_preset(preset: ProcessorPreset) -> MarkdownOptions {
153  match preset {
154    ProcessorPreset::Basic => {
155      MarkdownOptions {
156        gfm:                 true,
157        nixpkgs:             false,
158        highlight_code:      true,
159        extensions:          Vec::new(),
160        highlight_theme:     None,
161        manpage_urls:        None,
162        syntax_queries_path: None,
163        auto_link_options:   true,
164        tab_style:           TabStyle::None,
165        valid_options:       None,
166      }
167    },
168    ProcessorPreset::Ndg => {
169      MarkdownOptions {
170        gfm:                 true,
171        nixpkgs:             false,
172        highlight_code:      true,
173        extensions:          Vec::new(),
174        highlight_theme:     Some("github".to_string()),
175        manpage_urls:        None,
176        syntax_queries_path: None,
177        auto_link_options:   true,
178        tab_style:           TabStyle::None,
179        valid_options:       None,
180      }
181    },
182    ProcessorPreset::Nixpkgs => {
183      MarkdownOptions {
184        gfm:                 true,
185        nixpkgs:             true,
186        highlight_code:      true,
187        extensions:          Vec::new(),
188        highlight_theme:     Some("github".to_string()),
189        manpage_urls:        None,
190        syntax_queries_path: None,
191        auto_link_options:   true,
192        tab_style:           TabStyle::None,
193        valid_options:       None,
194      }
195    },
196  }
197}
198
199/// Preset configurations for common use cases. In some cases those presets will
200/// require certain feature flags to be enabled.
201#[derive(Debug, Clone, Copy)]
202pub enum ProcessorPreset {
203  /// Markdown processing with only Github Flavored Markdown (GFM) support
204  Basic,
205  /// Markdown processing with only Nixpkgs-flavored `CommonMark` support
206  Nixpkgs,
207  /// Enhanced Markdown processing with support for GFM and Nixpkgs-flavored
208  /// `CommonMark` support
209  Ndg,
210}
211
212/// Process markdown content from a string with error recovery.
213///
214/// This is a convenience function that combines processor creation and
215/// content processing in a single call.
216///
217/// # Arguments
218/// * `content` - The markdown content to process
219/// * `preset` - The processor preset to use
220///
221/// # Returns
222/// A `MarkdownResult` with processed content
223#[must_use]
224pub fn process_markdown_string(
225  content: &str,
226  preset: ProcessorPreset,
227) -> MarkdownResult {
228  let processor = create_processor(preset);
229  process_with_recovery(&processor, content)
230}
231
232/// Process markdown content from a file with error recovery.
233///
234/// This function reads a markdown file and processes it with the specified
235/// configuration, handling both file I/O and processing errors.
236///
237/// # Arguments
238/// * `file_path` - Path to the markdown file
239/// * `preset` - The processor preset to use
240///
241/// # Returns
242/// A `Result` containing the `MarkdownResult` or an error message
243///
244/// # Errors
245///
246/// Returns an error if the file cannot be read.
247pub fn process_markdown_file(
248  file_path: &Path,
249  preset: ProcessorPreset,
250) -> Result<MarkdownResult, String> {
251  let content = fs::read_to_string(file_path).map_err(|e| {
252    format!("Failed to read file {}: {}", file_path.display(), e)
253  })?;
254
255  let base_dir = file_path.parent().unwrap_or_else(|| Path::new("."));
256  let processor = create_processor(preset).with_base_dir(base_dir);
257  Ok(process_with_recovery(&processor, &content))
258}
259
260/// Process markdown content from a file with custom base directory.
261///
262/// This function reads a markdown file and processes it with the specified
263/// configuration and base directory for resolving includes.
264///
265/// # Arguments
266///
267/// * `file_path` - Path to the markdown file
268/// * `base_dir` - Base directory for resolving relative includes
269/// * `preset` - The processor preset to use
270///
271/// # Returns
272///
273/// A `Result` containing the `MarkdownResult` or an error message
274///
275/// # Errors
276///
277/// Returns an error if the file cannot be read.
278pub fn process_markdown_file_with_basedir(
279  file_path: &Path,
280  base_dir: &Path,
281  preset: ProcessorPreset,
282) -> Result<MarkdownResult, String> {
283  let content = fs::read_to_string(file_path).map_err(|e| {
284    format!("Failed to read file {}: {}", file_path.display(), e)
285  })?;
286
287  let processor = create_processor(preset).with_base_dir(base_dir);
288  Ok(process_with_recovery(&processor, &content))
289}
290
291#[cfg(test)]
292mod tests {
293  use std::path::Path;
294
295  use super::*;
296
297  #[test]
298  fn test_safely_process_markup_success() {
299    let content = "test content";
300    let result =
301      process_safe(content, |s| format!("processed: {s}"), "fallback");
302    assert_eq!(result, "processed: test content");
303  }
304
305  #[test]
306  #[expect(clippy::panic, reason = "deliberate panic to test fallback path")]
307  fn test_safely_process_markup_fallback() {
308    let content = "test content";
309    let result = process_safe(content, |_| panic!("test panic"), "fallback");
310    assert_eq!(result, "fallback");
311  }
312
313  #[test]
314  fn test_create_processor_presets() {
315    let basic = options_for_preset(ProcessorPreset::Basic);
316    assert!(basic.gfm);
317    assert!(!basic.nixpkgs);
318    assert!(basic.highlight_code);
319
320    let enhanced = options_for_preset(ProcessorPreset::Ndg);
321    assert!(enhanced.gfm);
322    assert!(!enhanced.nixpkgs);
323    assert!(enhanced.highlight_code);
324
325    let nixpkgs = options_for_preset(ProcessorPreset::Nixpkgs);
326    assert!(nixpkgs.gfm);
327    assert!(nixpkgs.nixpkgs);
328    assert!(nixpkgs.highlight_code);
329  }
330
331  #[test]
332  #[expect(
333    clippy::panic,
334    reason = "deliberate panic to test error propagation"
335  )]
336  fn test_process_batch() {
337    let processor = MarkdownProcessor::new(MarkdownOptions {
338      highlight_code: false,
339      ..options_for_preset(ProcessorPreset::Basic)
340    });
341    let paths = vec![Path::new("test1.md"), Path::new("test2.md")];
342
343    let read_fn = |path: &Path| -> Result<String, std::io::Error> {
344      match path.file_name().and_then(|n| n.to_str()) {
345        Some("test1.md") => Ok("# Test 1".to_string()),
346        Some("test2.md") => Ok("# Test 2".to_string()),
347        _ => {
348          Err(std::io::Error::new(
349            std::io::ErrorKind::NotFound,
350            "File not found",
351          ))
352        },
353      }
354    };
355
356    let results = process_batch(
357      &processor,
358      paths.into_iter().map(std::path::Path::to_path_buf),
359      read_fn,
360    );
361    assert_eq!(results.len(), 2);
362
363    for (path, result) in results {
364      match result {
365        Ok(markdown_result) => {
366          assert!(markdown_result.html.contains("<h1"));
367          if path.contains("test1") {
368            assert!(markdown_result.html.contains("Test 1"));
369          } else {
370            assert!(markdown_result.html.contains("Test 2"));
371          }
372        },
373        Err(e) => panic!("Unexpected error for path {path}: {e}"),
374      }
375    }
376  }
377}