Skip to main content

libmandoc_rs/
parser.rs

1//! Public parser configuration, input handling, and typed failure boundary.
2
3use std::{
4    ffi::{CStr, CString},
5    fmt,
6    fs::File,
7    io,
8    path::{Path, PathBuf},
9};
10
11#[cfg(unix)]
12use std::os::unix::ffi::OsStrExt;
13
14#[cfg(windows)]
15use std::io::Read;
16
17use crate::{
18    Diagnostic, DiagnosticLevel, Document, RawDocument, SourceBundle, compression, diagnostics, ffi,
19};
20
21/// Selects the macro language before parsing begins.
22#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
24pub enum InputFormat {
25    /// Detect mdoc from `.Dd`, man from `.TH`, and otherwise use man.
26    #[default]
27    Auto,
28    /// Parse the source as man regardless of its first macro.
29    Man,
30    /// Parse the source as mdoc regardless of its first macro.
31    Mdoc,
32}
33
34/// Policy controlling whether `.so` requests may resolve files.
35#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
36#[derive(Clone, Debug, Default, Eq, PartialEq)]
37pub enum IncludePolicy {
38    /// Reject `.so` expansion. This is the safe default for arbitrary input.
39    #[default]
40    Deny,
41    /// Resolve `.so` files using Unix libmandoc-compatible source-tree and
42    /// process-working-directory lookup.
43    ///
44    /// This compatibility policy is for trusted manual trees, not strict
45    /// containment, and is unavailable on Windows.
46    SourceTree,
47    /// Resolve `.so` files below one caller-approved directory without
48    /// traversing symbolic links beneath that root or falling back elsewhere.
49    ///
50    /// The approved root itself may be a symbolic link. On Windows, source
51    /// files are read by the Rust boundary and passed to memory-only
52    /// libmandoc; Unix retains its descriptor-relative native reader.
53    Root(PathBuf),
54}
55
56/// How the parser receives a manual source's top-level compression.
57#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
58#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
59pub enum Compression {
60    /// Detect supported compression at the relevant input boundary.
61    ///
62    /// File input uses a `.zst` suffix for zstd. Windows additionally uses a
63    /// `.gz` suffix for gzip; other Unix file input goes through libmandoc's
64    /// native reader. Byte input recognizes zstd magic but not gzip.
65    #[default]
66    Auto,
67    /// Treat the source bytes as uncompressed roff input.
68    Plain,
69    /// Decode the source as a zstd frame before parsing it.
70    Zstd,
71}
72
73/// Configuration for one [`Parser`] instance.
74#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
75#[derive(Clone, Debug, Default, Eq, PartialEq)]
76pub struct ParseOptions {
77    /// Policy for resolving roff `.so` include requests.
78    pub includes: IncludePolicy,
79    /// Compression expected at the outermost source boundary.
80    pub compression: Compression,
81}
82
83/// Completed owned document and any non-fatal parser findings.
84#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct ParseReport {
87    /// Fully owned syntax tree and metadata.
88    pub document: Document,
89    /// Non-fatal findings emitted while validating the source.
90    pub diagnostics: Vec<Diagnostic>,
91}
92
93/// Categorizes a source-level failure without exposing C implementation details.
94#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
95#[derive(Clone, Copy, Debug, Eq, PartialEq)]
96pub enum ParseErrorKind {
97    /// A path cannot be represented safely at the native API boundary.
98    InvalidPath,
99    /// Source bytes could not be read.
100    Read,
101    /// Compressed source bytes could not be decoded.
102    Decompression,
103    /// The selected parsing policy is unavailable on this platform.
104    Unsupported,
105    /// libmandoc rejected the source or failed to produce a document.
106    Parse,
107}
108
109/// File-level failure reported without leaking C or runtime diagnostics.
110#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct ParseError {
113    /// Source path associated with the failure.
114    pub path: PathBuf,
115    /// Stable category suitable for programmatic handling.
116    pub kind: ParseErrorKind,
117    /// Human-readable detail without unstable native diagnostic structure.
118    pub message: String,
119}
120
121impl fmt::Display for ParseError {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        write!(formatter, "{}: {}", self.path.display(), self.message)
124    }
125}
126
127impl std::error::Error for ParseError {}
128
129/// Reusable parser with an explicit input policy.
130///
131/// Independent calls may run concurrently. The bundled C parser keeps its
132/// mutable session state in static thread-local storage; this type does not
133/// support recursive re-entry on one OS thread. Owned node and equation copies
134/// stop after 256 levels, omit deeper descendants from pathological input, and
135/// report either truncation through [`ParseReport::diagnostics`].
136#[derive(Clone, Debug, Default, Eq, PartialEq)]
137pub struct Parser {
138    options: ParseOptions,
139    input_format: InputFormat,
140    mdoc_operating_system: Option<CString>,
141}
142
143impl Parser {
144    /// Create a parser with the supplied include and compression policies.
145    #[must_use]
146    pub const fn new(options: ParseOptions) -> Self {
147        Self {
148            options,
149            input_format: InputFormat::Auto,
150            mdoc_operating_system: None,
151        }
152    }
153
154    /// Return this parser's immutable configuration.
155    #[must_use]
156    pub const fn options(&self) -> &ParseOptions {
157        &self.options
158    }
159
160    /// Select the input macro language without changing the existing options shape.
161    #[must_use]
162    pub const fn with_input_format(mut self, input_format: InputFormat) -> Self {
163        self.input_format = input_format;
164        self
165    }
166
167    /// Return this parser's macro-language selection.
168    #[must_use]
169    pub const fn input_format(&self) -> InputFormat {
170        self.input_format
171    }
172
173    /// Override the operating-system name used by an argument-less mdoc
174    /// `.Os` macro.
175    ///
176    /// Without an override, libmandoc retains its native behavior: Unix uses
177    /// `uname(3)` and Windows uses its target configuration. An explicit `.Os
178    /// name` in the document still takes precedence over this value.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`std::ffi::NulError`] when the supplied name contains a NUL
183    /// byte and therefore cannot cross the native boundary.
184    pub fn with_mdoc_operating_system(
185        mut self,
186        operating_system: impl AsRef<str>,
187    ) -> Result<Self, std::ffi::NulError> {
188        self.mdoc_operating_system = Some(CString::new(operating_system.as_ref())?);
189        Ok(self)
190    }
191
192    /// Return the caller-selected operating-system override for bare `.Os`.
193    #[must_use]
194    pub fn mdoc_operating_system(&self) -> Option<&CStr> {
195        self.mdoc_operating_system.as_deref()
196    }
197
198    /// Parse one source path into an owned document.
199    ///
200    /// Auto-detected file input selects Rust zstd decoding for `.zst`; Windows
201    /// also selects Rust gzip decoding for `.gz`, while other Unix paths use
202    /// libmandoc's native reader. `.so` expansion is governed by
203    /// [`IncludePolicy`].
204    ///
205    /// # Errors
206    ///
207    /// Returns [`ParseError`] when the path cannot be represented for C, the
208    /// source cannot be read or decoded, or libmandoc rejects the source.
209    pub fn parse_file(&self, path: impl AsRef<Path>) -> Result<ParseReport, ParseError> {
210        let path = path.as_ref();
211        match self.options.compression {
212            Compression::Auto if path.extension().is_some_and(|extension| extension == "zst") => {
213                self.parse_zstd_file(path)
214            }
215            Compression::Auto => self.parse_auto_file(path),
216            Compression::Plain => {
217                let source = std::fs::read(path).map_err(|error| read_error(path, &error))?;
218                self.parse_plain_bytes(path, &source)
219            }
220            Compression::Zstd => self.parse_zstd_file(path),
221        }
222    }
223
224    /// Parse caller-owned source bytes under a logical source path.
225    ///
226    /// Byte input is useful when a caller owns its transport or decompression
227    /// layer. In auto mode zstd magic is recognized. Callers must decompress
228    /// gzip byte input themselves, or pass a gzip file to
229    /// [`Parser::parse_file`].
230    ///
231    /// # Errors
232    ///
233    /// Returns [`ParseError`] when the logical path is invalid, the requested
234    /// zstd decoding fails, or libmandoc rejects the supplied roff bytes.
235    pub fn parse_bytes(
236        &self,
237        source_path: impl AsRef<Path>,
238        source: &[u8],
239    ) -> Result<ParseReport, ParseError> {
240        let path = source_path.as_ref();
241        match self.options.compression {
242            Compression::Auto if has_zstd_magic(source) => self.parse_zstd_bytes(path, source),
243            Compression::Auto | Compression::Plain => self.parse_plain_bytes(path, source),
244            Compression::Zstd => self.parse_zstd_bytes(path, source),
245        }
246    }
247
248    /// Parse one root from a bounded, read-only virtual source tree.
249    ///
250    /// Bundle entries are uncompressed source bytes. `.so` requests resolve
251    /// first as exact bundle paths, then beside the including source, and
252    /// never fall back to the host filesystem. This boundary behaves the same
253    /// way on Unix and Windows.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`ParseError`] when the root is absent, its path cannot cross
258    /// the C boundary, or libmandoc rejects the root or an included source.
259    pub fn parse_bundle(
260        &self,
261        root: impl AsRef<Path>,
262        bundle: &SourceBundle,
263    ) -> Result<ParseReport, ParseError> {
264        let root = root.as_ref();
265        let root_label = root.to_str().ok_or_else(|| ParseError {
266            path: root.to_path_buf(),
267            kind: ParseErrorKind::InvalidPath,
268            message: "source bundle roots must be UTF-8 logical paths".into(),
269        })?;
270        if bundle.get(root_label).is_none() {
271            return Err(ParseError {
272                path: root.to_path_buf(),
273                kind: ParseErrorKind::Read,
274                message: "source bundle does not contain the requested root".into(),
275            });
276        }
277        self.finish(root, |c_path, _| {
278            ffi::parse_bundle(
279                c_path,
280                bundle,
281                self.input_format,
282                self.mdoc_operating_system(),
283            )
284        })
285    }
286
287    fn parse_zstd_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
288        let source = File::open(path)
289            .and_then(compression::decode_zstd)
290            .map_err(|error| decompression_error(path, &error))?;
291        self.parse_plain_bytes(path, &source)
292    }
293
294    #[cfg(unix)]
295    fn parse_auto_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
296        self.parse_native_file(path)
297    }
298
299    #[cfg(windows)]
300    fn parse_auto_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
301        let (source, gzip) =
302            compression::open_auto_file(path).map_err(|error| read_error(path, &error))?;
303        if gzip {
304            let decoded = compression::decode_gzip(source)
305                .map_err(|error| gzip_decompression_error(path, &error))?;
306            self.parse_plain_bytes(path, &decoded)
307        } else {
308            let mut source = source;
309            let mut bytes = Vec::new();
310            source
311                .read_to_end(&mut bytes)
312                .map_err(|error| read_error(path, &error))?;
313            self.parse_plain_bytes(path, &bytes)
314        }
315    }
316
317    fn parse_zstd_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
318        let source =
319            compression::decode_zstd(source).map_err(|error| decompression_error(path, &error))?;
320        self.parse_plain_bytes(path, &source)
321    }
322
323    #[cfg(unix)]
324    fn parse_native_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
325        self.finish(path, |c_path, includes| {
326            ffi::parse_file(
327                c_path,
328                includes.root.as_deref(),
329                includes.allow_includes,
330                self.input_format,
331                self.mdoc_operating_system(),
332            )
333        })
334    }
335
336    #[cfg(unix)]
337    fn parse_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
338        self.finish(path, |c_path, includes| {
339            ffi::parse_buffer(
340                c_path,
341                source,
342                includes.root.as_deref(),
343                includes.allow_includes,
344                self.input_format,
345                self.mdoc_operating_system(),
346            )
347        })
348    }
349
350    #[cfg(windows)]
351    fn parse_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
352        self.finish(path, |c_path, includes| {
353            ffi::parse_buffer(
354                c_path,
355                source,
356                includes.root.as_deref(),
357                includes.allow_includes,
358                self.input_format,
359                self.mdoc_operating_system(),
360            )
361        })
362    }
363
364    fn finish(
365        &self,
366        path: &Path,
367        parse: impl FnOnce(&CString, &IncludeSettings) -> Result<RawDocument, String>,
368    ) -> Result<ParseReport, ParseError> {
369        let c_path = path_label(path).map_err(|_| ParseError {
370            path: path.to_path_buf(),
371            kind: ParseErrorKind::InvalidPath,
372            message: "manual source path contains a NUL byte".into(),
373        })?;
374        let include_settings = self.include_settings(path)?;
375        let raw = parse(&c_path, &include_settings).map_err(|message| ParseError {
376            path: path.to_path_buf(),
377            kind: ParseErrorKind::Parse,
378            message,
379        })?;
380        let mut findings = diagnostics::parse_diagnostics(&raw.diagnostics);
381        if raw.node_truncated {
382            findings.push(Diagnostic {
383                level: DiagnosticLevel::Warning,
384                message: diagnostics::SYNTAX_TREE_DEPTH_MESSAGE.into(),
385                location: None,
386            });
387        }
388        if raw.equation_truncated {
389            findings.push(Diagnostic {
390                level: DiagnosticLevel::Warning,
391                message: diagnostics::EQUATION_TREE_DEPTH_MESSAGE.into(),
392                location: None,
393            });
394        }
395        Ok(ParseReport {
396            document: raw.document,
397            diagnostics: findings,
398        })
399    }
400
401    pub(crate) fn include_settings(
402        &self,
403        source_path: &Path,
404    ) -> Result<IncludeSettings, ParseError> {
405        #[cfg(unix)]
406        let _ = source_path;
407
408        match &self.options.includes {
409            IncludePolicy::Deny => Ok(IncludeSettings {
410                root: None,
411                allow_includes: false,
412            }),
413            #[cfg(unix)]
414            IncludePolicy::SourceTree => Ok(IncludeSettings {
415                root: None,
416                allow_includes: true,
417            }),
418            #[cfg(windows)]
419            IncludePolicy::SourceTree => Err(unsupported_includes(source_path.to_path_buf())),
420            IncludePolicy::Root(root) if root.as_os_str().is_empty() => Err(ParseError {
421                path: root.clone(),
422                kind: ParseErrorKind::InvalidPath,
423                message: "manual include root is empty".into(),
424            }),
425            #[cfg(unix)]
426            IncludePolicy::Root(root) => CString::new(root.as_os_str().as_bytes())
427                .map(|root| IncludeSettings {
428                    root: Some(root),
429                    allow_includes: true,
430                })
431                .map_err(|_| ParseError {
432                    path: root.clone(),
433                    kind: ParseErrorKind::InvalidPath,
434                    message: "manual include root contains a NUL byte".into(),
435                }),
436            #[cfg(windows)]
437            IncludePolicy::Root(root) => Ok(IncludeSettings {
438                root: Some(root.clone()),
439                allow_includes: true,
440            }),
441        }
442    }
443}
444
445pub(crate) struct IncludeSettings {
446    #[cfg(unix)]
447    pub(crate) root: Option<CString>,
448    #[cfg(windows)]
449    pub(crate) root: Option<PathBuf>,
450    pub(crate) allow_includes: bool,
451}
452
453#[cfg(unix)]
454pub(crate) fn path_label(path: &Path) -> Result<CString, std::ffi::NulError> {
455    CString::new(path.as_os_str().as_bytes())
456}
457
458#[cfg(windows)]
459pub(crate) fn path_label(path: &Path) -> Result<CString, std::ffi::NulError> {
460    CString::new(path.to_string_lossy().as_bytes())
461}
462
463#[cfg(windows)]
464fn unsupported_includes(path: PathBuf) -> ParseError {
465    ParseError {
466        path,
467        kind: ParseErrorKind::Unsupported,
468        message: "libmandoc-compatible source-tree inclusion is unavailable on Windows; use IncludePolicy::Root or SourceBundle"
469            .into(),
470    }
471}
472
473fn has_zstd_magic(source: &[u8]) -> bool {
474    source.starts_with(&[0x28, 0xb5, 0x2f, 0xfd])
475}
476
477fn read_error(path: &Path, error: &io::Error) -> ParseError {
478    ParseError {
479        path: path.to_path_buf(),
480        kind: ParseErrorKind::Read,
481        message: error.to_string(),
482    }
483}
484
485fn decompression_error(path: &Path, error: &io::Error) -> ParseError {
486    ParseError {
487        path: path.to_path_buf(),
488        kind: ParseErrorKind::Decompression,
489        message: format!("could not decompress zstd manual source: {error}"),
490    }
491}
492
493#[cfg(windows)]
494fn gzip_decompression_error(path: &Path, error: &io::Error) -> ParseError {
495    ParseError {
496        path: path.to_path_buf(),
497        kind: ParseErrorKind::Decompression,
498        message: format!("could not decompress gzip manual source: {error}"),
499    }
500}