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    #[cfg(windows)]
39    use std::io::Write;
40
41    use super::{
42        Compression, DisplayKind, Document, IncludePolicy, MacroSet, Node, NodeKind,
43        NormalizedListKind, ParseError, ParseOptions, Parser, TableAlignment,
44    };
45
46    #[cfg(windows)]
47    use super::DiagnosticLevel;
48
49    fn source_path(label: &str) -> std::path::PathBuf {
50        std::env::temp_dir().join(format!("mant-{label}-{}.1", process::id()))
51    }
52
53    fn measured_depth(node: &Node) -> usize {
54        1 + node.children.iter().map(measured_depth).max().unwrap_or(0)
55    }
56
57    fn parse_file(path: &std::path::Path, allow_includes: bool) -> Result<Document, ParseError> {
58        Parser::new(ParseOptions {
59            includes: if allow_includes {
60                IncludePolicy::SourceTree
61            } else {
62                IncludePolicy::Deny
63            },
64            compression: Compression::Auto,
65        })
66        .parse_file(path)
67        .map(|report| report.document)
68    }
69
70    fn find_macro<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
71        (node.macro_name.as_deref() == Some(name))
72            .then_some(node)
73            .or_else(|| {
74                node.children
75                    .iter()
76                    .find_map(|child| find_macro(child, name))
77            })
78    }
79
80    fn find_kind(node: &Node, kind: NodeKind) -> Option<&Node> {
81        (node.kind == kind).then_some(node).or_else(|| {
82            node.children
83                .iter()
84                .find_map(|child| find_kind(child, kind))
85        })
86    }
87
88    fn find_node<'a>(node: &'a Node, predicate: &impl Fn(&Node) -> bool) -> Option<&'a Node> {
89        predicate(node).then_some(node).or_else(|| {
90            node.children
91                .iter()
92                .find_map(|child| find_node(child, predicate))
93        })
94    }
95
96    #[test]
97    fn upstream_version_is_pinned() {
98        assert_eq!(super::LIBMANDOC_VERSION, "1.14.6");
99    }
100
101    #[test]
102    fn parser_session_returns_an_owned_man_tree() {
103        let path = source_path("mandoc-session");
104        fs::write(
105            &path,
106            ".TH MANT 1 \"2026-07-19\"\n.SH NAME\nmant \\- manual viewer\n",
107        )
108        .expect("write temporary manual source");
109
110        let document = parse_file(&path, false).expect("parse temporary manual");
111        fs::remove_file(path).expect("remove temporary manual source");
112
113        assert_eq!(document.macro_set, MacroSet::Man);
114        assert_eq!(document.metadata.title.as_deref(), Some("MANT"));
115        assert_eq!(document.metadata.section.as_deref(), Some("1"));
116        assert!(document.metadata.has_body);
117        assert_eq!(document.root.kind, NodeKind::Root);
118        assert!(!document.root.children.is_empty());
119    }
120
121    #[test]
122    fn parser_decompresses_zstd_sources_before_calling_libmandoc() {
123        let path = source_path("zstd-mandoc-session").with_extension("1.zst");
124        let source = b".TH ZSTD-MANT 1 \"2026-07-20\"\n.SH NAME\nzstd-mant \\- compressed manual\n";
125        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
126        fs::write(&path, compressed).expect("write compressed manual source");
127
128        let report = Parser::default()
129            .parse_file(&path)
130            .expect("parse zstd manual");
131        fs::remove_file(path).expect("remove compressed manual source");
132
133        assert!(report.diagnostics.is_empty());
134        let document = report.document;
135        assert_eq!(document.macro_set, MacroSet::Man);
136        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-MANT"));
137        assert_eq!(document.metadata.section.as_deref(), Some("1"));
138        assert!(document.metadata.has_body);
139    }
140
141    #[cfg(windows)]
142    #[test]
143    fn windows_parser_decompresses_gzip_before_calling_libmandoc() {
144        use flate2::{Compression as GzipCompression, write::GzEncoder};
145
146        let path = source_path("gzip-mandoc-session").with_extension("1.gz");
147        let mut encoder = GzEncoder::new(Vec::new(), GzipCompression::fast());
148        encoder
149            .write_all(b".TH GZIP-MANT 1\n.SH NAME\ngzip-mant \\- compressed manual\n")
150            .expect("encode gzip source");
151        fs::write(&path, encoder.finish().expect("finish gzip source")).expect("write gzip source");
152
153        let report = Parser::default()
154            .parse_file(&path)
155            .expect("parse gzip manual");
156        fs::remove_file(path).expect("remove gzip source");
157
158        assert_eq!(report.document.metadata.title.as_deref(), Some("GZIP-MANT"));
159    }
160
161    #[cfg(windows)]
162    #[test]
163    fn windows_parser_accepts_the_date_formats_used_by_libmandoc() {
164        for (date, normalized, normalized_with_style) in [
165            ("2026-07-20", "2026-07-20", false),
166            ("Jul 20, 2026", "July 20, 2026", true),
167            ("July 20, 2026", "July 20, 2026", false),
168            ("$Mdocdate: Jul 20 2026 $", "July 20, 2026", false),
169        ] {
170            let source =
171                format!(".TH WINDOWS-DATE 1 \"{date}\"\n.SH NAME\nwindows-date \\- portable\n");
172            let report = Parser::default()
173                .parse_bytes("windows-date.1", source.as_bytes())
174                .expect("parse a supported manual date");
175
176            if normalized_with_style {
177                assert_eq!(report.diagnostics.len(), 1);
178                assert_eq!(report.diagnostics[0].level, DiagnosticLevel::Style);
179                assert_eq!(
180                    report.diagnostics[0].message,
181                    "normalizing date format to: TH July 20, 2026"
182                );
183            } else {
184                assert!(
185                    report.diagnostics.is_empty(),
186                    "unexpected diagnostics for {date}: {:?}",
187                    report.diagnostics
188                );
189            }
190            assert_eq!(report.document.metadata.date.as_deref(), Some(normalized));
191        }
192    }
193
194    #[cfg(windows)]
195    #[test]
196    fn windows_rejects_c_file_inclusion_but_accepts_memory_parsing() {
197        let report = Parser::default()
198            .parse_bytes("memory.1", b".TH MEMORY 1\n.SH NAME\nmemory \\- portable\n")
199            .expect("parse caller-owned bytes");
200        assert_eq!(report.document.metadata.title.as_deref(), Some("MEMORY"));
201
202        let error = Parser::new(ParseOptions {
203            includes: IncludePolicy::SourceTree,
204            compression: Compression::Plain,
205        })
206        .parse_bytes("memory.1", b".so target.1\n")
207        .expect_err("reject native C file inclusion");
208        assert_eq!(error.kind, super::ParseErrorKind::Unsupported);
209    }
210
211    #[test]
212    fn invalid_zstd_sources_fail_before_reaching_libmandoc() {
213        let path = source_path("invalid-zstd-mandoc-session").with_extension("1.zst");
214        fs::write(&path, b"not a zstd frame").expect("write invalid compressed source");
215
216        let error = parse_file(&path, false).expect_err("invalid zstd source must fail");
217        fs::remove_file(path).expect("remove invalid compressed source");
218
219        assert!(
220            error
221                .message
222                .starts_with("could not decompress zstd manual source:")
223        );
224        assert_eq!(error.kind, super::ParseErrorKind::Decompression);
225        assert!(!error.message.contains("unsupported control character"));
226    }
227
228    #[cfg(unix)]
229    #[test]
230    fn zstd_sources_keep_their_original_include_root() {
231        let root = std::env::temp_dir().join(format!(
232            "mant-zstd-include-mandoc-session-{}",
233            process::id()
234        ));
235        let man1 = root.join("man1");
236        fs::create_dir_all(&man1).expect("create temporary manual tree");
237        let target = man1.join("target.1");
238        fs::write(
239            &target,
240            ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
241        )
242        .expect("write included manual");
243        let alias = man1.join("alias.1.zst");
244        let compressed =
245            zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
246        fs::write(&alias, compressed).expect("write compressed alias");
247
248        let document = parse_file(&alias, true).expect("resolve include from zstd source");
249        fs::remove_dir_all(root).expect("remove temporary manual tree");
250
251        assert_eq!(document.macro_set, MacroSet::Man);
252        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
253        assert!(document.metadata.has_body);
254    }
255
256    #[test]
257    fn parser_preserves_same_line_layout_and_next_line_content_roles() {
258        let path = source_path("line-role-mandoc-session");
259        fs::write(
260            &path,
261            ".TH LINE-ROLE 1\n.SH EXAMPLES\n.TP \\w'man\\ 'u\n.BI man \\ ls\nBody.\n",
262        )
263        .expect("write tagged paragraph source");
264
265        let document = parse_file(&path, false).expect("parse tagged paragraph source");
266        fs::remove_file(path).expect("remove tagged paragraph source");
267
268        let tagged_paragraph = find_macro(&document.root, "TP").expect("TP block");
269        let head = tagged_paragraph
270            .children
271            .iter()
272            .find(|child| child.kind == NodeKind::Head)
273            .expect("TP head");
274        assert_eq!(head.children[0].text.as_deref(), Some("96u"));
275        assert!(!head.children[0].flags.line_start);
276        assert_eq!(head.children[1].macro_name.as_deref(), Some("BI"));
277        assert!(head.children[1].flags.line_start);
278    }
279
280    #[test]
281    fn parser_preserves_mdoc_delimiter_spacing_roles() {
282        let path = source_path("delimiter-role-mandoc-session");
283        fs::write(
284            &path,
285            ".Dd August 4, 2026\n.Dt DELIMITERS 1\n.Os\n.Sh EXAMPLES\n\
286             .Dl name ( ) command\n\
287             .Dl local [ variable | - ] ...\n\
288             .Dl return [ exitstatus ]\n",
289        )
290        .expect("write delimiter-role source");
291
292        let document = parse_file(&path, false).expect("parse delimiter-role source");
293        fs::remove_file(path).expect("remove delimiter-role source");
294
295        let opening_parenthesis = find_node(&document.root, &|node| {
296            node.line == 5 && node.text.as_deref() == Some("(")
297        })
298        .expect("opening parenthesis");
299        let closing_parenthesis = find_node(&document.root, &|node| {
300            node.line == 5 && node.text.as_deref() == Some(")")
301        })
302        .expect("closing parenthesis");
303        let opening_bracket = find_node(&document.root, &|node| {
304            node.line == 7 && node.text.as_deref() == Some("[")
305        })
306        .expect("opening bracket");
307        let trailing_bracket = find_node(&document.root, &|node| {
308            node.line == 7 && node.text.as_deref() == Some("]")
309        })
310        .expect("trailing bracket");
311
312        assert!(opening_parenthesis.flags.delimiter_open);
313        assert!(closing_parenthesis.flags.delimiter_close);
314        assert!(opening_bracket.flags.delimiter_open);
315        assert!(trailing_bracket.flags.delimiter_close);
316    }
317
318    #[test]
319    fn parser_session_reports_file_errors_as_values() {
320        let path = source_path("missing-mandoc-session");
321        let error = parse_file(&path, false).expect_err("missing source must fail");
322
323        assert_eq!(error.path, path);
324        assert!(!error.message.is_empty());
325    }
326
327    #[test]
328    fn concurrent_callers_are_serialized_around_libmandoc_globals() {
329        let path = source_path("concurrent-mandoc-session");
330        fs::write(&path, ".TH THREADS 1\n.SH NAME\nthreads \\- test\n")
331            .expect("write temporary manual source");
332
333        let workers: Vec<_> = (0..4)
334            .map(|_| {
335                let path = path.clone();
336                std::thread::spawn(move || parse_file(&path, false))
337            })
338            .collect();
339        for worker in workers {
340            let document = worker
341                .join()
342                .expect("parser worker must not panic")
343                .expect("concurrent parse must succeed");
344            assert_eq!(document.metadata.title.as_deref(), Some("THREADS"));
345        }
346
347        fs::remove_file(path).expect("remove temporary manual source");
348    }
349
350    #[cfg(unix)]
351    #[test]
352    fn source_relative_includes_do_not_change_process_cwd() {
353        let root =
354            std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
355        fs::create_dir_all(&root).expect("create temporary manual tree");
356        let target = root.join("minimal-mdoc.1");
357        fs::write(
358            &target,
359            ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
360        )
361        .expect("write included source");
362        let alias = root.join("alias-mdoc.1");
363        fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
364        let cwd = std::env::current_dir().expect("current directory before parse");
365
366        let document = parse_file(&alias, true).expect("resolve source-relative include");
367        fs::remove_dir_all(root).expect("remove temporary manual tree");
368
369        assert_eq!(document.macro_set, MacroSet::Mdoc);
370        assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
371        assert_eq!(
372            std::env::current_dir().expect("current directory after parse"),
373            cwd
374        );
375    }
376
377    #[test]
378    fn parser_accepts_owned_bytes_and_detects_zstd_frames() {
379        let source = b".TH BYTES 1\n.SH NAME\nbytes \\- parser input\n";
380        let plain = Parser::default()
381            .parse_bytes("memory.1", source)
382            .expect("parse plain byte input");
383        assert_eq!(plain.document.metadata.title.as_deref(), Some("BYTES"));
384
385        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
386        let zstd = Parser::default()
387            .parse_bytes("memory.1", &compressed)
388            .expect("detect and parse zstd byte input");
389        assert_eq!(zstd.document.metadata.title.as_deref(), Some("BYTES"));
390    }
391
392    #[cfg(unix)]
393    #[test]
394    fn parser_only_expands_includes_when_policy_allows_a_root() {
395        let base = std::env::temp_dir().join(format!(
396            "libmandoc-rs-explicit-include-root-{}",
397            process::id()
398        ));
399        let includes = base.join("includes");
400        fs::create_dir_all(&includes).expect("create explicit include root");
401        fs::write(
402            includes.join("target.1"),
403            ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
404        )
405        .expect("write included source");
406        let alias = base.join("alias.1");
407        fs::write(&alias, ".so target.1\n").expect("write alias source");
408
409        let denied = Parser::default()
410            .parse_file(&alias)
411            .expect("parse alias without include expansion");
412        let expanded = Parser::new(ParseOptions {
413            includes: IncludePolicy::Root(includes),
414            compression: Compression::Auto,
415        })
416        .parse_file(&alias)
417        .expect("resolve alias against explicit root");
418        fs::remove_dir_all(base).expect("remove temporary manual tree");
419
420        assert_ne!(
421            denied.document.metadata.title.as_deref(),
422            Some("EXPLICIT-ROOT")
423        );
424        assert_eq!(
425            expanded.document.metadata.title.as_deref(),
426            Some("EXPLICIT-ROOT")
427        );
428    }
429
430    #[cfg(unix)]
431    #[test]
432    fn explicit_include_root_does_not_fall_back_to_process_cwd() {
433        let identifier = format!("libmandoc-rs-ambient-{}", process::id());
434        let cwd_target = std::env::current_dir()
435            .expect("read test cwd")
436            .join(format!("{identifier}.1"));
437        fs::write(
438            &cwd_target,
439            ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
440        )
441        .expect("write ambient source");
442
443        let base = std::env::temp_dir().join(format!("{identifier}-root"));
444        fs::create_dir_all(&base).expect("create empty include root");
445        let alias = base.join("alias.1");
446        fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");
447
448        let result = Parser::new(ParseOptions {
449            includes: IncludePolicy::Root(base.clone()),
450            compression: Compression::Auto,
451        })
452        .parse_file(&alias);
453        fs::remove_file(cwd_target).expect("remove ambient source");
454        fs::remove_dir_all(base).expect("remove temporary manual tree");
455
456        match result {
457            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
458            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
459        }
460    }
461
462    #[test]
463    fn parser_returns_structured_nonfatal_diagnostics() {
464        let report = Parser::default()
465            .parse_bytes(
466                "diagnostics.1",
467                b".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
468            )
469            .expect("return best-effort document");
470
471        assert!(
472            report
473                .diagnostics
474                .iter()
475                .any(|diagnostic| diagnostic.level == super::DiagnosticLevel::Unsupported)
476        );
477    }
478
479    #[test]
480    fn deeply_nested_input_is_bounded_instead_of_overflowing_the_stack() {
481        // Far more nesting than the copy cap; the parse must return a finite
482        // tree rather than recursing without limit while copying it out.
483        let depth = 5_000;
484        let mut source = String::from(".TH DEEP 1\n.SH BODY\n");
485        for _ in 0..depth {
486            source.push_str(".RS\n");
487        }
488        source.push_str("deep\n");
489
490        let document = Parser::default()
491            .parse_bytes("deep.1", source.as_bytes())
492            .expect("deeply nested source parses")
493            .document;
494
495        // The owned tree stays well under the input nesting, proving the copy
496        // stopped descending at the cap.
497        assert!(
498            measured_depth(&document.root) <= 300,
499            "tree depth must be bounded by the copy cap"
500        );
501    }
502
503    #[test]
504    fn deeply_nested_equation_is_bounded_instead_of_overflowing_the_stack() {
505        // Braces nest eqn boxes, a recursive walk the node-copy cap never
506        // enters: copy_equation descends box->first without limit, so a
507        // pathologically nested equation overflows the stack while flattening
508        // it. Each `sqrt` level emits text, so an unbounded render would grow
509        // the string with the input depth; a bounded one plateaus at the cap.
510        let depth = 5_000;
511        let mut equation = String::new();
512        for _ in 0..depth {
513            equation.push_str("sqrt { ");
514        }
515        equation.push('x');
516        for _ in 0..depth {
517            equation.push_str(" }");
518        }
519        let source = format!(".TH DEEP 1\n.SH BODY\n.EQ\n{equation}\n.EN\n");
520
521        let document = Parser::default()
522            .parse_bytes("deep-eqn.1", source.as_bytes())
523            .expect("deeply nested equation parses")
524            .document;
525
526        let node = find_kind(&document.root, NodeKind::Equation).expect("equation node");
527        let rendered = node.equation.as_deref().expect("equation text");
528        // The render stopped at the cap: the flattened text is far shorter than
529        // the ~30k chars all 5000 `sqrt` levels would emit, proving it did not
530        // recurse through every box (and so could not overflow the stack).
531        assert!(
532            rendered.len() < 2_000,
533            "equation text must be bounded by the copy cap, got {} bytes",
534            rendered.len()
535        );
536    }
537
538    #[cfg(feature = "serde")]
539    #[test]
540    fn serde_feature_round_trips_the_public_parse_report() {
541        let report = Parser::default()
542            .parse_bytes("serde.1", b".TH SERDE 1\n.SH NAME\nserde \\- fixture\n")
543            .expect("parse source for serialization");
544        let encoded = serde_json::to_string(&report).expect("serialize parse report");
545        let decoded: super::ParseReport =
546            serde_json::from_str(&encoded).expect("deserialize parse report");
547
548        assert_eq!(decoded, report);
549    }
550
551    #[test]
552    fn parser_copies_normalized_list_and_display_attributes() {
553        let path = source_path("normalized-mandoc-session");
554        fs::write(
555            &path,
556            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh ITEMS\n\
557             .Bl -tag -compact -offset indent -width 12n\n.It item\nfirst\n.El\n\
558             .Bd -literal -offset indent\ncode line\n.Ed\n",
559        )
560        .expect("write normalized mdoc source");
561
562        let document = parse_file(&path, false).expect("parse normalized mdoc source");
563        fs::remove_file(path).expect("remove normalized mdoc source");
564
565        let list = find_macro(&document.root, "Bl").expect("normalized list node");
566        assert_eq!(list.list_kind, Some(NormalizedListKind::Definition));
567        assert!(list.compact);
568        assert_eq!(list.offset.as_deref(), Some("indent"));
569        assert_eq!(list.width.as_deref(), Some("12n"));
570        let display = find_macro(&document.root, "Bd").expect("normalized display node");
571        assert_eq!(display.display_kind, Some(DisplayKind::Literal));
572        assert_eq!(display.offset.as_deref(), Some("indent"));
573    }
574
575    #[test]
576    fn parser_copies_table_cells_and_equation_text() {
577        let path = source_path("structured-payload-mandoc-session");
578        fs::write(
579            &path,
580            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
581             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
582        )
583        .expect("write table and equation source");
584
585        let document = parse_file(&path, false).expect("parse table and equation source");
586        fs::remove_file(path).expect("remove table and equation source");
587
588        let table = find_kind(&document.root, NodeKind::Table).expect("table row node");
589        assert_eq!(table.table_cells.len(), 2);
590        assert_eq!(table.table_cells[0].text.as_deref(), Some("left"));
591        assert_eq!(table.table_cells[1].alignment, TableAlignment::Right);
592        let equation = find_kind(&document.root, NodeKind::Equation).expect("equation node");
593        assert!(
594            equation
595                .equation
596                .as_deref()
597                .is_some_and(|value| value.contains('x'))
598        );
599    }
600
601    #[test]
602    fn parser_copies_validated_same_document_navigation() {
603        let path = source_path("navigation-mandoc-session");
604        fs::write(
605            &path,
606            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh FIRST\n\
607             See\n\
608             .Sx TARGET\n\
609             for details.\n\
610             .Tg explicit-target\n\
611             .Fl x\n\
612             .Sh TARGET\nTarget text.\n",
613        )
614        .expect("write navigation mdoc source");
615
616        let document = parse_file(&path, false).expect("parse navigation mdoc source");
617        fs::remove_file(path).expect("remove navigation mdoc source");
618
619        assert!(find_macro(&document.root, "Sx").is_some());
620        let explicit_target = find_node(&document.root, &|node| {
621            node.flags.deep_link_target && node.tag.as_deref() == Some("explicit-target")
622        });
623        let explicit_target = explicit_target.expect("Tg must annotate its resolved destination");
624        assert!(explicit_target.flags.permalink);
625    }
626}