Skip to main content

libmandoc_rs/
lib.rs

1//! Safe ownership boundary around the pinned libmandoc parser.
2//!
3//! The C shim completes and copies a parse before returning. Rust therefore
4//! never observes libmandoc's private `roff_node` layout, and the global C
5//! parser state is serialized inside this crate.
6
7#[cfg(test)]
8mod build_config;
9
10mod ast;
11mod diagnostics;
12#[allow(unsafe_code)]
13mod ffi;
14mod parser;
15
16pub use ast::{
17    DisplayKind, Document, MacroSet, Metadata, Node, NodeFlags, NodeKind, NormalizedListKind,
18    TableAlignment, TableCell,
19};
20pub use diagnostics::{Diagnostic, DiagnosticLevel, SourceLocation};
21pub use parser::{
22    Compression, IncludePolicy, ParseError, ParseErrorKind, ParseOptions, ParseReport, Parser,
23};
24
25/// Pinned upstream version compiled by this crate's build script.
26pub const LIBMANDOC_VERSION: &str = "1.14.6";
27
28/// Private output of the FFI boundary before diagnostics become public values.
29struct RawDocument {
30    document: Document,
31    diagnostics: String,
32}
33
34#[cfg(test)]
35mod tests {
36    use std::{fs, process};
37
38    use super::{
39        Compression, DisplayKind, Document, IncludePolicy, MacroSet, Node, NodeKind,
40        NormalizedListKind, ParseError, ParseOptions, Parser, TableAlignment,
41    };
42
43    fn source_path(label: &str) -> std::path::PathBuf {
44        std::env::temp_dir().join(format!("mant-{label}-{}.1", process::id()))
45    }
46
47    fn measured_depth(node: &Node) -> usize {
48        1 + node.children.iter().map(measured_depth).max().unwrap_or(0)
49    }
50
51    fn parse_file(path: &std::path::Path, allow_includes: bool) -> Result<Document, ParseError> {
52        Parser::new(ParseOptions {
53            includes: if allow_includes {
54                IncludePolicy::SourceTree
55            } else {
56                IncludePolicy::Deny
57            },
58            compression: Compression::Auto,
59        })
60        .parse_file(path)
61        .map(|report| report.document)
62    }
63
64    fn find_macro<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
65        (node.macro_name.as_deref() == Some(name))
66            .then_some(node)
67            .or_else(|| {
68                node.children
69                    .iter()
70                    .find_map(|child| find_macro(child, name))
71            })
72    }
73
74    fn find_kind(node: &Node, kind: NodeKind) -> Option<&Node> {
75        (node.kind == kind).then_some(node).or_else(|| {
76            node.children
77                .iter()
78                .find_map(|child| find_kind(child, kind))
79        })
80    }
81
82    fn find_node<'a>(node: &'a Node, predicate: &impl Fn(&Node) -> bool) -> Option<&'a Node> {
83        predicate(node).then_some(node).or_else(|| {
84            node.children
85                .iter()
86                .find_map(|child| find_node(child, predicate))
87        })
88    }
89
90    #[test]
91    fn upstream_version_is_pinned() {
92        assert_eq!(super::LIBMANDOC_VERSION, "1.14.6");
93    }
94
95    #[test]
96    fn parser_session_returns_an_owned_man_tree() {
97        let path = source_path("mandoc-session");
98        fs::write(
99            &path,
100            ".TH MANT 1 \"2026-07-19\"\n.SH NAME\nmant \\- manual viewer\n",
101        )
102        .expect("write temporary manual source");
103
104        let document = parse_file(&path, false).expect("parse temporary manual");
105        fs::remove_file(path).expect("remove temporary manual source");
106
107        assert_eq!(document.macro_set, MacroSet::Man);
108        assert_eq!(document.metadata.title.as_deref(), Some("MANT"));
109        assert_eq!(document.metadata.section.as_deref(), Some("1"));
110        assert!(document.metadata.has_body);
111        assert_eq!(document.root.kind, NodeKind::Root);
112        assert!(!document.root.children.is_empty());
113    }
114
115    #[test]
116    fn parser_decompresses_zstd_sources_before_calling_libmandoc() {
117        let path = source_path("zstd-mandoc-session").with_extension("1.zst");
118        let source = b".TH ZSTD-MANT 1 \"2026-07-20\"\n.SH NAME\nzstd-mant \\- compressed manual\n";
119        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
120        fs::write(&path, compressed).expect("write compressed manual source");
121
122        let report = Parser::default()
123            .parse_file(&path)
124            .expect("parse zstd manual");
125        fs::remove_file(path).expect("remove compressed manual source");
126
127        assert!(report.diagnostics.is_empty());
128        let document = report.document;
129        assert_eq!(document.macro_set, MacroSet::Man);
130        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-MANT"));
131        assert_eq!(document.metadata.section.as_deref(), Some("1"));
132        assert!(document.metadata.has_body);
133    }
134
135    #[test]
136    fn invalid_zstd_sources_fail_before_reaching_libmandoc() {
137        let path = source_path("invalid-zstd-mandoc-session").with_extension("1.zst");
138        fs::write(&path, b"not a zstd frame").expect("write invalid compressed source");
139
140        let error = parse_file(&path, false).expect_err("invalid zstd source must fail");
141        fs::remove_file(path).expect("remove invalid compressed source");
142
143        assert!(
144            error
145                .message
146                .starts_with("could not decompress zstd manual source:")
147        );
148        assert_eq!(error.kind, super::ParseErrorKind::Decompression);
149        assert!(!error.message.contains("unsupported control character"));
150    }
151
152    #[test]
153    fn zstd_sources_keep_their_original_include_root() {
154        let root = std::env::temp_dir().join(format!(
155            "mant-zstd-include-mandoc-session-{}",
156            process::id()
157        ));
158        let man1 = root.join("man1");
159        fs::create_dir_all(&man1).expect("create temporary manual tree");
160        let target = man1.join("target.1");
161        fs::write(
162            &target,
163            ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
164        )
165        .expect("write included manual");
166        let alias = man1.join("alias.1.zst");
167        let compressed =
168            zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
169        fs::write(&alias, compressed).expect("write compressed alias");
170
171        let document = parse_file(&alias, true).expect("resolve include from zstd source");
172        fs::remove_dir_all(root).expect("remove temporary manual tree");
173
174        assert_eq!(document.macro_set, MacroSet::Man);
175        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
176        assert!(document.metadata.has_body);
177    }
178
179    #[test]
180    fn parser_preserves_same_line_layout_and_next_line_content_roles() {
181        let path = source_path("line-role-mandoc-session");
182        fs::write(
183            &path,
184            ".TH LINE-ROLE 1\n.SH EXAMPLES\n.TP \\w'man\\ 'u\n.BI man \\ ls\nBody.\n",
185        )
186        .expect("write tagged paragraph source");
187
188        let document = parse_file(&path, false).expect("parse tagged paragraph source");
189        fs::remove_file(path).expect("remove tagged paragraph source");
190
191        let tagged_paragraph = find_macro(&document.root, "TP").expect("TP block");
192        let head = tagged_paragraph
193            .children
194            .iter()
195            .find(|child| child.kind == NodeKind::Head)
196            .expect("TP head");
197        assert_eq!(head.children[0].text.as_deref(), Some("96u"));
198        assert!(!head.children[0].flags.line_start);
199        assert_eq!(head.children[1].macro_name.as_deref(), Some("BI"));
200        assert!(head.children[1].flags.line_start);
201    }
202
203    #[test]
204    fn parser_preserves_mdoc_delimiter_spacing_roles() {
205        let path = source_path("delimiter-role-mandoc-session");
206        fs::write(
207            &path,
208            ".Dd August 4, 2026\n.Dt DELIMITERS 1\n.Os\n.Sh EXAMPLES\n\
209             .Dl name ( ) command\n\
210             .Dl local [ variable | - ] ...\n\
211             .Dl return [ exitstatus ]\n",
212        )
213        .expect("write delimiter-role source");
214
215        let document = parse_file(&path, false).expect("parse delimiter-role source");
216        fs::remove_file(path).expect("remove delimiter-role source");
217
218        let opening_parenthesis = find_node(&document.root, &|node| {
219            node.line == 5 && node.text.as_deref() == Some("(")
220        })
221        .expect("opening parenthesis");
222        let closing_parenthesis = find_node(&document.root, &|node| {
223            node.line == 5 && node.text.as_deref() == Some(")")
224        })
225        .expect("closing parenthesis");
226        let opening_bracket = find_node(&document.root, &|node| {
227            node.line == 7 && node.text.as_deref() == Some("[")
228        })
229        .expect("opening bracket");
230        let trailing_bracket = find_node(&document.root, &|node| {
231            node.line == 7 && node.text.as_deref() == Some("]")
232        })
233        .expect("trailing bracket");
234
235        assert!(opening_parenthesis.flags.delimiter_open);
236        assert!(closing_parenthesis.flags.delimiter_close);
237        assert!(opening_bracket.flags.delimiter_open);
238        assert!(trailing_bracket.flags.delimiter_close);
239    }
240
241    #[test]
242    fn parser_session_reports_file_errors_as_values() {
243        let path = source_path("missing-mandoc-session");
244        let error = parse_file(&path, false).expect_err("missing source must fail");
245
246        assert_eq!(error.path, path);
247        assert!(!error.message.is_empty());
248    }
249
250    #[test]
251    fn concurrent_callers_are_serialized_around_libmandoc_globals() {
252        let path = source_path("concurrent-mandoc-session");
253        fs::write(&path, ".TH THREADS 1\n.SH NAME\nthreads \\- test\n")
254            .expect("write temporary manual source");
255
256        let workers: Vec<_> = (0..4)
257            .map(|_| {
258                let path = path.clone();
259                std::thread::spawn(move || parse_file(&path, false))
260            })
261            .collect();
262        for worker in workers {
263            let document = worker
264                .join()
265                .expect("parser worker must not panic")
266                .expect("concurrent parse must succeed");
267            assert_eq!(document.metadata.title.as_deref(), Some("THREADS"));
268        }
269
270        fs::remove_file(path).expect("remove temporary manual source");
271    }
272
273    #[test]
274    fn source_relative_includes_do_not_change_process_cwd() {
275        let root =
276            std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
277        fs::create_dir_all(&root).expect("create temporary manual tree");
278        let target = root.join("minimal-mdoc.1");
279        fs::write(
280            &target,
281            ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
282        )
283        .expect("write included source");
284        let alias = root.join("alias-mdoc.1");
285        fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
286        let cwd = std::env::current_dir().expect("current directory before parse");
287
288        let document = parse_file(&alias, true).expect("resolve source-relative include");
289        fs::remove_dir_all(root).expect("remove temporary manual tree");
290
291        assert_eq!(document.macro_set, MacroSet::Mdoc);
292        assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
293        assert_eq!(
294            std::env::current_dir().expect("current directory after parse"),
295            cwd
296        );
297    }
298
299    #[test]
300    fn parser_accepts_owned_bytes_and_detects_zstd_frames() {
301        let source = b".TH BYTES 1\n.SH NAME\nbytes \\- parser input\n";
302        let plain = Parser::default()
303            .parse_bytes("memory.1", source)
304            .expect("parse plain byte input");
305        assert_eq!(plain.document.metadata.title.as_deref(), Some("BYTES"));
306
307        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
308        let zstd = Parser::default()
309            .parse_bytes("memory.1", &compressed)
310            .expect("detect and parse zstd byte input");
311        assert_eq!(zstd.document.metadata.title.as_deref(), Some("BYTES"));
312    }
313
314    #[test]
315    fn parser_only_expands_includes_when_policy_allows_a_root() {
316        let base = std::env::temp_dir().join(format!(
317            "libmandoc-rs-explicit-include-root-{}",
318            process::id()
319        ));
320        let includes = base.join("includes");
321        fs::create_dir_all(&includes).expect("create explicit include root");
322        fs::write(
323            includes.join("target.1"),
324            ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
325        )
326        .expect("write included source");
327        let alias = base.join("alias.1");
328        fs::write(&alias, ".so target.1\n").expect("write alias source");
329
330        let denied = Parser::default()
331            .parse_file(&alias)
332            .expect("parse alias without include expansion");
333        let expanded = Parser::new(ParseOptions {
334            includes: IncludePolicy::Root(includes),
335            compression: Compression::Auto,
336        })
337        .parse_file(&alias)
338        .expect("resolve alias against explicit root");
339        fs::remove_dir_all(base).expect("remove temporary manual tree");
340
341        assert_ne!(
342            denied.document.metadata.title.as_deref(),
343            Some("EXPLICIT-ROOT")
344        );
345        assert_eq!(
346            expanded.document.metadata.title.as_deref(),
347            Some("EXPLICIT-ROOT")
348        );
349    }
350
351    #[test]
352    fn explicit_include_root_does_not_fall_back_to_process_cwd() {
353        let identifier = format!("libmandoc-rs-ambient-{}", process::id());
354        let cwd_target = std::env::current_dir()
355            .expect("read test cwd")
356            .join(format!("{identifier}.1"));
357        fs::write(
358            &cwd_target,
359            ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
360        )
361        .expect("write ambient source");
362
363        let base = std::env::temp_dir().join(format!("{identifier}-root"));
364        fs::create_dir_all(&base).expect("create empty include root");
365        let alias = base.join("alias.1");
366        fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");
367
368        let result = Parser::new(ParseOptions {
369            includes: IncludePolicy::Root(base.clone()),
370            compression: Compression::Auto,
371        })
372        .parse_file(&alias);
373        fs::remove_file(cwd_target).expect("remove ambient source");
374        fs::remove_dir_all(base).expect("remove temporary manual tree");
375
376        match result {
377            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
378            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
379        }
380    }
381
382    #[test]
383    fn parser_returns_structured_nonfatal_diagnostics() {
384        let report = Parser::default()
385            .parse_bytes(
386                "diagnostics.1",
387                b".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
388            )
389            .expect("return best-effort document");
390
391        assert!(
392            report
393                .diagnostics
394                .iter()
395                .any(|diagnostic| diagnostic.level == super::DiagnosticLevel::Unsupported)
396        );
397    }
398
399    #[test]
400    fn deeply_nested_input_is_bounded_instead_of_overflowing_the_stack() {
401        // Far more nesting than the copy cap; the parse must return a finite
402        // tree rather than recursing without limit while copying it out.
403        let depth = 5_000;
404        let mut source = String::from(".TH DEEP 1\n.SH BODY\n");
405        for _ in 0..depth {
406            source.push_str(".RS\n");
407        }
408        source.push_str("deep\n");
409
410        let document = Parser::default()
411            .parse_bytes("deep.1", source.as_bytes())
412            .expect("deeply nested source parses")
413            .document;
414
415        // The owned tree stays well under the input nesting, proving the copy
416        // stopped descending at the cap.
417        assert!(
418            measured_depth(&document.root) <= 300,
419            "tree depth must be bounded by the copy cap"
420        );
421    }
422
423    #[cfg(feature = "serde")]
424    #[test]
425    fn serde_feature_round_trips_the_public_parse_report() {
426        let report = Parser::default()
427            .parse_bytes("serde.1", b".TH SERDE 1\n.SH NAME\nserde \\- fixture\n")
428            .expect("parse source for serialization");
429        let encoded = serde_json::to_string(&report).expect("serialize parse report");
430        let decoded: super::ParseReport =
431            serde_json::from_str(&encoded).expect("deserialize parse report");
432
433        assert_eq!(decoded, report);
434    }
435
436    #[test]
437    fn parser_copies_normalized_list_and_display_attributes() {
438        let path = source_path("normalized-mandoc-session");
439        fs::write(
440            &path,
441            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh ITEMS\n\
442             .Bl -tag -compact -offset indent -width 12n\n.It item\nfirst\n.El\n\
443             .Bd -literal -offset indent\ncode line\n.Ed\n",
444        )
445        .expect("write normalized mdoc source");
446
447        let document = parse_file(&path, false).expect("parse normalized mdoc source");
448        fs::remove_file(path).expect("remove normalized mdoc source");
449
450        let list = find_macro(&document.root, "Bl").expect("normalized list node");
451        assert_eq!(list.list_kind, Some(NormalizedListKind::Definition));
452        assert!(list.compact);
453        assert_eq!(list.offset.as_deref(), Some("indent"));
454        assert_eq!(list.width.as_deref(), Some("12n"));
455        let display = find_macro(&document.root, "Bd").expect("normalized display node");
456        assert_eq!(display.display_kind, Some(DisplayKind::Literal));
457        assert_eq!(display.offset.as_deref(), Some("indent"));
458    }
459
460    #[test]
461    fn parser_copies_table_cells_and_equation_text() {
462        let path = source_path("structured-payload-mandoc-session");
463        fs::write(
464            &path,
465            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
466             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
467        )
468        .expect("write table and equation source");
469
470        let document = parse_file(&path, false).expect("parse table and equation source");
471        fs::remove_file(path).expect("remove table and equation source");
472
473        let table = find_kind(&document.root, NodeKind::Table).expect("table row node");
474        assert_eq!(table.table_cells.len(), 2);
475        assert_eq!(table.table_cells[0].text.as_deref(), Some("left"));
476        assert_eq!(table.table_cells[1].alignment, TableAlignment::Right);
477        let equation = find_kind(&document.root, NodeKind::Equation).expect("equation node");
478        assert!(
479            equation
480                .equation
481                .as_deref()
482                .is_some_and(|value| value.contains('x'))
483        );
484    }
485
486    #[test]
487    fn parser_copies_validated_same_document_navigation() {
488        let path = source_path("navigation-mandoc-session");
489        fs::write(
490            &path,
491            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh FIRST\n\
492             See\n\
493             .Sx TARGET\n\
494             for details.\n\
495             .Tg explicit-target\n\
496             .Fl x\n\
497             .Sh TARGET\nTarget text.\n",
498        )
499        .expect("write navigation mdoc source");
500
501        let document = parse_file(&path, false).expect("parse navigation mdoc source");
502        fs::remove_file(path).expect("remove navigation mdoc source");
503
504        assert!(find_macro(&document.root, "Sx").is_some());
505        let explicit_target = find_node(&document.root, &|node| {
506            node.flags.deep_link_target && node.tag.as_deref() == Some("explicit-target")
507        });
508        let explicit_target = explicit_target.expect("Tg must annotate its resolved destination");
509        assert!(explicit_target.flags.permalink);
510    }
511}