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    os::unix::ffi::OsStrExt,
9    path::{Path, PathBuf},
10    sync::{Mutex, OnceLock},
11};
12
13use crate::{Diagnostic, Document, RawDocument, diagnostics, ffi};
14
15static PARSER_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
16
17/// Policy controlling whether `.so` requests may resolve files.
18#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub enum IncludePolicy {
21    /// Reject `.so` expansion. This is the safe default for arbitrary input.
22    #[default]
23    Deny,
24    /// Resolve `.so` files using the parsed source's manual tree.
25    SourceTree,
26    /// Resolve `.so` files from one caller-approved directory.
27    Root(PathBuf),
28}
29
30/// How the parser receives a manual source's top-level compression.
31#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum Compression {
34    /// Let file parsing use libmandoc's native gzip handling and recognize
35    /// zstd frames before sending a staged buffer to libmandoc.
36    #[default]
37    Auto,
38    /// Treat the source bytes as uncompressed roff input.
39    Plain,
40    /// Decode the source as a zstd frame before parsing it.
41    Zstd,
42}
43
44/// Configuration for one [`Parser`] instance.
45#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
46#[derive(Clone, Debug, Default, Eq, PartialEq)]
47pub struct ParseOptions {
48    pub includes: IncludePolicy,
49    pub compression: Compression,
50}
51
52/// Completed owned document and any non-fatal parser findings.
53#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct ParseReport {
56    pub document: Document,
57    pub diagnostics: Vec<Diagnostic>,
58}
59
60/// Categorizes a source-level failure without exposing C implementation details.
61#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum ParseErrorKind {
64    InvalidPath,
65    Read,
66    Decompression,
67    Parse,
68}
69
70/// File-level failure reported without leaking C or runtime diagnostics.
71#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct ParseError {
74    pub path: PathBuf,
75    pub kind: ParseErrorKind,
76    pub message: String,
77}
78
79impl fmt::Display for ParseError {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(formatter, "{}: {}", self.path.display(), self.message)
82    }
83}
84
85impl std::error::Error for ParseError {}
86
87/// Reusable parser with an explicit input policy.
88#[derive(Clone, Debug, Default, Eq, PartialEq)]
89pub struct Parser {
90    options: ParseOptions,
91}
92
93impl Parser {
94    /// Create a parser with the supplied include and compression policies.
95    #[must_use]
96    pub const fn new(options: ParseOptions) -> Self {
97        Self { options }
98    }
99
100    /// Return this parser's immutable configuration.
101    #[must_use]
102    pub const fn options(&self) -> &ParseOptions {
103        &self.options
104    }
105
106    /// Parse one source path into an owned document.
107    ///
108    /// Auto-detected file input supports libmandoc's native gzip handling and
109    /// zstd files.  `.so` expansion is governed by [`IncludePolicy`].
110    ///
111    /// # Errors
112    ///
113    /// Returns [`ParseError`] when the path cannot be represented for C, the
114    /// source cannot be read or decoded, or libmandoc rejects the source.
115    pub fn parse_file(&self, path: impl AsRef<Path>) -> Result<ParseReport, ParseError> {
116        let path = path.as_ref();
117        match self.options.compression {
118            Compression::Auto if path.extension().is_some_and(|extension| extension == "zst") => {
119                self.parse_zstd_file(path)
120            }
121            Compression::Auto => self.parse_native_file(path),
122            Compression::Plain => {
123                let source = std::fs::read(path).map_err(|error| read_error(path, &error))?;
124                self.parse_plain_bytes(path, &source)
125            }
126            Compression::Zstd => self.parse_zstd_file(path),
127        }
128    }
129
130    /// Parse caller-owned source bytes under a logical source path.
131    ///
132    /// Byte input is useful when a caller owns its transport or decompression
133    /// layer.  In auto mode zstd magic is recognized; gzip byte input should
134    /// use [`Parser::parse_file`] so libmandoc can open it natively.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`ParseError`] when the logical path is invalid, the requested
139    /// zstd decoding fails, or libmandoc rejects the supplied roff bytes.
140    pub fn parse_bytes(
141        &self,
142        source_path: impl AsRef<Path>,
143        source: &[u8],
144    ) -> Result<ParseReport, ParseError> {
145        let path = source_path.as_ref();
146        match self.options.compression {
147            Compression::Auto if has_zstd_magic(source) => self.parse_zstd_bytes(path, source),
148            Compression::Auto | Compression::Plain => self.parse_plain_bytes(path, source),
149            Compression::Zstd => self.parse_zstd_bytes(path, source),
150        }
151    }
152
153    fn parse_zstd_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
154        let source = File::open(path)
155            .and_then(zstd::stream::decode_all)
156            .map_err(|error| decompression_error(path, &error))?;
157        self.parse_plain_bytes(path, &source)
158    }
159
160    fn parse_zstd_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
161        let source =
162            zstd::stream::decode_all(source).map_err(|error| decompression_error(path, &error))?;
163        self.parse_plain_bytes(path, &source)
164    }
165
166    fn parse_native_file(&self, path: &Path) -> Result<ParseReport, ParseError> {
167        self.finish(path, |c_path, include_root, allow_includes| {
168            ffi::parse_file(c_path, include_root.map(CString::as_c_str), allow_includes)
169        })
170    }
171
172    fn parse_plain_bytes(&self, path: &Path, source: &[u8]) -> Result<ParseReport, ParseError> {
173        self.finish(path, |c_path, include_root, allow_includes| {
174            ffi::parse_buffer(
175                c_path,
176                source,
177                include_root.map(CString::as_c_str),
178                allow_includes,
179            )
180        })
181    }
182
183    fn finish(
184        &self,
185        path: &Path,
186        parse: impl FnOnce(&CString, Option<&CString>, bool) -> Result<RawDocument, String>,
187    ) -> Result<ParseReport, ParseError> {
188        let c_path = CString::new(path.as_os_str().as_bytes()).map_err(|_| ParseError {
189            path: path.to_path_buf(),
190            kind: ParseErrorKind::InvalidPath,
191            message: "manual source path contains a NUL byte".into(),
192        })?;
193        let lock = PARSER_LOCK.get_or_init(|| Mutex::new(()));
194        let _guard = lock
195            .lock()
196            .unwrap_or_else(std::sync::PoisonError::into_inner);
197        let (include_root, allow_includes) = self.include_root()?;
198        let raw = parse(&c_path, include_root.as_ref(), allow_includes).map_err(|message| {
199            ParseError {
200                path: path.to_path_buf(),
201                kind: ParseErrorKind::Parse,
202                message,
203            }
204        })?;
205        Ok(ParseReport {
206            document: raw.document,
207            diagnostics: diagnostics::parse_diagnostics(&raw.diagnostics),
208        })
209    }
210
211    fn include_root(&self) -> Result<(Option<CString>, bool), ParseError> {
212        match &self.options.includes {
213            IncludePolicy::Deny => Ok((None, false)),
214            IncludePolicy::SourceTree => Ok((None, true)),
215            IncludePolicy::Root(root) => CString::new(root.as_os_str().as_bytes())
216                .map(Some)
217                .map(|root| (root, true))
218                .map_err(|_| ParseError {
219                    path: root.clone(),
220                    kind: ParseErrorKind::InvalidPath,
221                    message: "manual include root contains a NUL byte".into(),
222                }),
223        }
224    }
225}
226
227fn has_zstd_magic(source: &[u8]) -> bool {
228    source.starts_with(&[0x28, 0xb5, 0x2f, 0xfd])
229}
230
231fn read_error(path: &Path, error: &io::Error) -> ParseError {
232    ParseError {
233        path: path.to_path_buf(),
234        kind: ParseErrorKind::Read,
235        message: error.to_string(),
236    }
237}
238
239fn decompression_error(path: &Path, error: &io::Error) -> ParseError {
240    ParseError {
241        path: path.to_path_buf(),
242        kind: ParseErrorKind::Decompression,
243        message: format!("could not decompress zstd manual source: {error}"),
244    }
245}