Skip to main content

libmandoc_rs/
lib.rs

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