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