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