Skip to main content

libmandoc_rs/
parser.rs

1//! Public parser configuration, input handling, and typed failure boundary.
2
3use std::{
4    ffi::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
17#[cfg(windows)]
18use flate2::read::MultiGzDecoder;
19
20use crate::{Diagnostic, Document, RawDocument, diagnostics, ffi};
21
22/// Policy controlling whether `.so` requests may resolve files.
23#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
24#[derive(Clone, Debug, Default, Eq, PartialEq)]
25pub enum IncludePolicy {
26    /// Reject `.so` expansion. This is the safe default for arbitrary input.
27    #[default]
28    Deny,
29    /// Resolve `.so` files using Unix libmandoc-compatible source-tree and
30    /// process-working-directory lookup.
31    ///
32    /// This compatibility policy is for trusted manual trees, not strict
33    /// containment, and is unavailable on Windows.
34    SourceTree,
35    /// Resolve `.so` files below one caller-approved directory without
36    /// traversing symbolic links beneath that root or falling back elsewhere.
37    ///
38    /// The approved root itself may be a symbolic link. This strict policy is
39    /// currently unavailable on Windows.
40    Root(PathBuf),
41}
42
43/// How the parser receives a manual source's top-level compression.
44#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
45#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum Compression {
47    /// Detect supported compression at the relevant input boundary.
48    ///
49    /// File input uses a `.zst` suffix for zstd. Windows additionally uses a
50    /// `.gz` suffix for gzip; other Unix file input goes through libmandoc's
51    /// native reader. Byte input recognizes zstd magic but not gzip.
52    #[default]
53    Auto,
54    /// Treat the source bytes as uncompressed roff input.
55    Plain,
56    /// Decode the source as a zstd frame before parsing it.
57    Zstd,
58}
59
60/// Configuration for one [`Parser`] instance.
61#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
62#[derive(Clone, Debug, Default, Eq, PartialEq)]
63pub struct ParseOptions {
64    /// Policy for resolving roff `.so` include requests.
65    pub includes: IncludePolicy,
66    /// Compression expected at the outermost source boundary.
67    pub compression: Compression,
68}
69
70/// Completed owned document and any non-fatal parser findings.
71#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ParseReport {
74    /// Fully owned syntax tree and metadata.
75    pub document: Document,
76    /// Non-fatal findings emitted while validating the source.
77    pub diagnostics: Vec<Diagnostic>,
78}
79
80/// Categorizes a source-level failure without exposing C implementation details.
81#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub enum ParseErrorKind {
84    /// A path cannot be represented safely at the native API boundary.
85    InvalidPath,
86    /// Source bytes could not be read.
87    Read,
88    /// Compressed source bytes could not be decoded.
89    Decompression,
90    /// The selected parsing policy is unavailable on this platform.
91    Unsupported,
92    /// libmandoc rejected the source or failed to produce a document.
93    Parse,
94}
95
96/// File-level failure reported without leaking C or runtime diagnostics.
97#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
98#[derive(Clone, Debug, Eq, PartialEq)]
99pub struct ParseError {
100    /// Source path associated with the failure.
101    pub path: PathBuf,
102    /// Stable category suitable for programmatic handling.
103    pub kind: ParseErrorKind,
104    /// Human-readable detail without unstable native diagnostic structure.
105    pub message: String,
106}
107
108impl fmt::Display for ParseError {
109    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(formatter, "{}: {}", self.path.display(), self.message)
111    }
112}
113
114impl std::error::Error for ParseError {}
115
116/// Reusable parser with an explicit input policy.
117///
118/// Independent calls may run concurrently. The bundled C parser keeps its
119/// mutable session state in static thread-local storage; this type does not
120/// support recursive re-entry on one OS thread. Owned node and equation copies
121/// stop after 256 levels and omit deeper descendants from pathological input.
122#[derive(Clone, Debug, Default, Eq, PartialEq)]
123pub struct Parser {
124    options: ParseOptions,
125}
126
127impl Parser {
128    /// Create a parser with the supplied include and compression policies.
129    #[must_use]
130    pub const fn new(options: ParseOptions) -> Self {
131        Self { options }
132    }
133
134    /// Return this parser's immutable configuration.
135    #[must_use]
136    pub const fn options(&self) -> &ParseOptions {
137        &self.options
138    }
139
140    /// Parse one source path into an owned document.
141    ///
142    /// Auto-detected file input selects Rust zstd decoding for `.zst`; Windows
143    /// also selects Rust gzip decoding for `.gz`, while other Unix paths use
144    /// libmandoc's native reader. `.so` expansion is governed by
145    /// [`IncludePolicy`].
146    ///
147    /// # Errors
148    ///
149    /// Returns [`ParseError`] when the path cannot be represented for C, the
150    /// source cannot be read or decoded, or libmandoc rejects the source.
151    pub fn parse_file(&self, path: impl AsRef<Path>) -> Result<ParseReport, ParseError> {
152        let path = path.as_ref();
153        match self.options.compression {
154            Compression::Auto if path.extension().is_some_and(|extension| extension == "zst") => {
155                self.parse_zstd_file(path)
156            }
157            Compression::Auto => self.parse_auto_file(path),
158            Compression::Plain => {
159                let source = std::fs::read(path).map_err(|error| read_error(path, &error))?;
160                self.parse_plain_bytes(path, &source)
161            }
162            Compression::Zstd => self.parse_zstd_file(path),
163        }
164    }
165
166    /// Parse caller-owned source bytes under a logical source path.
167    ///
168    /// Byte input is useful when a caller owns its transport or decompression
169    /// layer. In auto mode zstd magic is recognized. Callers must decompress
170    /// gzip byte input themselves, or pass a gzip file to
171    /// [`Parser::parse_file`].
172    ///
173    /// # Errors
174    ///
175    /// Returns [`ParseError`] when the logical path is invalid, the requested
176    /// zstd decoding fails, or libmandoc rejects the supplied roff bytes.
177    pub fn parse_bytes(
178        &self,
179        source_path: impl AsRef<Path>,
180        source: &[u8],
181    ) -> Result<ParseReport, ParseError> {
182        let path = source_path.as_ref();
183        match self.options.compression {
184            Compression::Auto if has_zstd_magic(source) => self.parse_zstd_bytes(path, source),
185            Compression::Auto | Compression::Plain => self.parse_plain_bytes(path, source),
186            Compression::Zstd => self.parse_zstd_bytes(path, source),
187        }
188    }
189
190    fn parse_zstd_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
191        let source = File::open(path)
192            .and_then(zstd::stream::decode_all)
193            .map_err(|error| decompression_error(path, &error))?;
194        self.parse_plain_bytes(path, &source)
195    }
196
197    #[cfg(unix)]
198    fn parse_auto_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
199        self.parse_native_file(path)
200    }
201
202    #[cfg(windows)]
203    fn parse_auto_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
204        let source = File::open(path).map_err(|error| read_error(path, &error))?;
205        if path.extension().is_some_and(|extension| extension == "gz") {
206            let mut decoded = Vec::new();
207            MultiGzDecoder::new(source)
208                .read_to_end(&mut decoded)
209                .map_err(|error| gzip_decompression_error(path, &error))?;
210            self.parse_plain_bytes(path, &decoded)
211        } else {
212            let mut source = source;
213            let mut bytes = Vec::new();
214            source
215                .read_to_end(&mut bytes)
216                .map_err(|error| read_error(path, &error))?;
217            self.parse_plain_bytes(path, &bytes)
218        }
219    }
220
221    fn parse_zstd_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
222        let source =
223            zstd::stream::decode_all(source).map_err(|error| decompression_error(path, &error))?;
224        self.parse_plain_bytes(path, &source)
225    }
226
227    #[cfg(unix)]
228    fn parse_native_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
229        self.finish(path, |c_path, include_root, allow_includes| {
230            ffi::parse_file(c_path, include_root.map(CString::as_c_str), allow_includes)
231        })
232    }
233
234    fn parse_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
235        self.finish(path, |c_path, include_root, allow_includes| {
236            ffi::parse_buffer(
237                c_path,
238                source,
239                include_root.map(CString::as_c_str),
240                allow_includes,
241            )
242        })
243    }
244
245    fn finish(
246        &self,
247        path: &Path,
248        parse: impl FnOnce(&CString, Option<&CString>, bool) -> Result<RawDocument, String>,
249    ) -> Result<ParseReport, ParseError> {
250        let c_path = path_label(path).map_err(|_| ParseError {
251            path: path.to_path_buf(),
252            kind: ParseErrorKind::InvalidPath,
253            message: "manual source path contains a NUL byte".into(),
254        })?;
255        let (include_root, allow_includes) = self.include_root()?;
256        let raw = parse(&c_path, include_root.as_ref(), allow_includes).map_err(|message| {
257            ParseError {
258                path: path.to_path_buf(),
259                kind: ParseErrorKind::Parse,
260                message,
261            }
262        })?;
263        Ok(ParseReport {
264            document: raw.document,
265            diagnostics: diagnostics::parse_diagnostics(&raw.diagnostics),
266        })
267    }
268
269    fn include_root(&self) -> Result<(Option<CString>, bool), ParseError> {
270        match &self.options.includes {
271            IncludePolicy::Deny => Ok((None, false)),
272            #[cfg(unix)]
273            IncludePolicy::SourceTree => Ok((None, true)),
274            #[cfg(windows)]
275            IncludePolicy::SourceTree => Err(unsupported_includes(PathBuf::new())),
276            IncludePolicy::Root(root) if root.as_os_str().is_empty() => Err(ParseError {
277                path: root.clone(),
278                kind: ParseErrorKind::InvalidPath,
279                message: "manual include root is empty".into(),
280            }),
281            #[cfg(unix)]
282            IncludePolicy::Root(root) => CString::new(root.as_os_str().as_bytes())
283                .map(Some)
284                .map(|root| (root, true))
285                .map_err(|_| ParseError {
286                    path: root.clone(),
287                    kind: ParseErrorKind::InvalidPath,
288                    message: "manual include root contains a NUL byte".into(),
289                }),
290            #[cfg(windows)]
291            IncludePolicy::Root(root) => Err(unsupported_includes(root.clone())),
292        }
293    }
294}
295
296#[cfg(unix)]
297fn path_label(path: &Path) -> Result<CString, std::ffi::NulError> {
298    CString::new(path.as_os_str().as_bytes())
299}
300
301#[cfg(windows)]
302fn path_label(path: &Path) -> Result<CString, std::ffi::NulError> {
303    CString::new(path.to_string_lossy().as_bytes())
304}
305
306#[cfg(windows)]
307fn unsupported_includes(path: PathBuf) -> ParseError {
308    ParseError {
309        path,
310        kind: ParseErrorKind::Unsupported,
311        message:
312            "libmandoc file inclusion is unavailable on Windows; resolve .so sources before parsing"
313                .into(),
314    }
315}
316
317fn has_zstd_magic(source: &[u8]) -> bool {
318    source.starts_with(&[0x28, 0xb5, 0x2f, 0xfd])
319}
320
321fn read_error(path: &Path, error: &io::Error) -> ParseError {
322    ParseError {
323        path: path.to_path_buf(),
324        kind: ParseErrorKind::Read,
325        message: error.to_string(),
326    }
327}
328
329fn decompression_error(path: &Path, error: &io::Error) -> ParseError {
330    ParseError {
331        path: path.to_path_buf(),
332        kind: ParseErrorKind::Decompression,
333        message: format!("could not decompress zstd manual source: {error}"),
334    }
335}
336
337#[cfg(windows)]
338fn gzip_decompression_error(path: &Path, error: &io::Error) -> ParseError {
339    ParseError {
340        path: path.to_path_buf(),
341        kind: ParseErrorKind::Decompression,
342        message: format!("could not decompress gzip manual source: {error}"),
343    }
344}