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