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;
12mod special_character;
13
14pub use ast::{
15    AuthorMode, DisplayKind, Document, MacroSet, Metadata, Node, NodeFlags, NodeKind,
16    NormalizedEnclosure, NormalizedFont, NormalizedListKind, TableAlignment, TableCell,
17};
18pub use diagnostics::{Diagnostic, DiagnosticLevel, SourceLocation};
19pub use parser::{
20    Compression, IncludePolicy, ParseError, ParseErrorKind, ParseOptions, ParseReport, Parser,
21};
22pub use special_character::{SpecialCharacter, special_character};
23
24/// Pinned upstream version compiled by this crate's build script.
25pub const LIBMANDOC_VERSION: &str = "1.14.6";
26
27/// Private output of the FFI boundary before diagnostics become public values.
28struct RawDocument {
29    document: Document,
30    diagnostics: String,
31}
32
33#[cfg(test)]
34mod tests {
35    use std::{
36        fmt::Write as _,
37        fs, process,
38        sync::{Arc, Barrier},
39    };
40
41    #[cfg(windows)]
42    use std::io::Write;
43
44    use super::{
45        AuthorMode, Compression, DiagnosticLevel, DisplayKind, Document, IncludePolicy, MacroSet,
46        Node, NodeKind, NormalizedFont, NormalizedListKind, ParseError, ParseOptions, Parser,
47        TableAlignment,
48    };
49
50    fn source_path(label: &str) -> std::path::PathBuf {
51        std::env::temp_dir().join(format!("mant-{label}-{}.1", process::id()))
52    }
53
54    fn measured_depth(node: &Node) -> usize {
55        1 + node.children.iter().map(measured_depth).max().unwrap_or(0)
56    }
57
58    fn parse_file(path: &std::path::Path, allow_includes: bool) -> Result<Document, ParseError> {
59        Parser::new(ParseOptions {
60            includes: if allow_includes {
61                IncludePolicy::SourceTree
62            } else {
63                IncludePolicy::Deny
64            },
65            compression: Compression::Auto,
66        })
67        .parse_file(path)
68        .map(|report| report.document)
69    }
70
71    fn find_macro<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
72        (node.macro_name.as_deref() == Some(name))
73            .then_some(node)
74            .or_else(|| {
75                node.children
76                    .iter()
77                    .find_map(|child| find_macro(child, name))
78            })
79    }
80
81    fn find_kind(node: &Node, kind: NodeKind) -> Option<&Node> {
82        (node.kind == kind).then_some(node).or_else(|| {
83            node.children
84                .iter()
85                .find_map(|child| find_kind(child, kind))
86        })
87    }
88
89    fn find_node<'a>(node: &'a Node, predicate: &impl Fn(&Node) -> bool) -> Option<&'a Node> {
90        predicate(node).then_some(node).or_else(|| {
91            node.children
92                .iter()
93                .find_map(|child| find_node(child, predicate))
94        })
95    }
96
97    fn collect_visible_text<'a>(node: &'a Node, visible: &mut Vec<&'a str>) {
98        if !node.flags.no_print
99            && let Some(text) = node.text.as_deref()
100        {
101            visible.push(text);
102        }
103        for child in &node.children {
104            collect_visible_text(child, visible);
105        }
106    }
107
108    #[test]
109    fn upstream_version_is_pinned() {
110        assert_eq!(super::LIBMANDOC_VERSION, "1.14.6");
111    }
112
113    #[test]
114    fn parser_session_returns_an_owned_man_tree() {
115        let path = source_path("mandoc-session");
116        fs::write(
117            &path,
118            ".TH MANT 1 \"2026-07-19\"\n.SH NAME\nmant \\- manual viewer\n",
119        )
120        .expect("write temporary manual source");
121
122        let document = parse_file(&path, false).expect("parse temporary manual");
123        fs::remove_file(path).expect("remove temporary manual source");
124
125        assert_eq!(document.macro_set, MacroSet::Man);
126        assert_eq!(document.metadata.title.as_deref(), Some("MANT"));
127        assert_eq!(document.metadata.section.as_deref(), Some("1"));
128        assert!(document.metadata.has_body);
129        assert_eq!(document.root.kind, NodeKind::Root);
130        assert!(!document.root.children.is_empty());
131    }
132
133    #[test]
134    fn parser_recognizes_the_modern_man_reference_macro() {
135        let report = Parser::default()
136            .parse_bytes(
137                "modern-reference.1",
138                b".TH MODERN-REFERENCE 1\n.SH NAME\nmodern-reference \\- fixture\n\
139.SH SEE ALSO\n.MR git-add 1 ,\n",
140            )
141            .expect("parse modern man reference");
142
143        assert!(
144            report
145                .diagnostics
146                .iter()
147                .all(|diagnostic| !diagnostic.message.contains("unknown macro")),
148            "MR must be a native parser node: {:?}",
149            report.diagnostics
150        );
151        let reference = find_macro(&report.document.root, "MR").expect("MR node");
152        assert_eq!(reference.kind, NodeKind::Element);
153        assert_eq!(
154            reference
155                .children
156                .iter()
157                .filter_map(|child| child.text.as_deref())
158                .collect::<Vec<_>>(),
159            ["git-add", "1", ","]
160        );
161    }
162
163    #[test]
164    fn parser_retains_mdoc_include_arguments() {
165        let report = Parser::default()
166            .parse_bytes(
167                "include.3",
168                b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
169            )
170            .expect("parse mdoc include");
171
172        let include = find_macro(&report.document.root, "In").expect("In node");
173        assert_eq!(include.kind, NodeKind::Element);
174        assert_eq!(
175            include
176                .children
177                .iter()
178                .filter_map(|child| child.text.as_deref())
179                .collect::<Vec<_>>(),
180            ["fido.h"]
181        );
182    }
183
184    #[test]
185    fn parser_expands_the_libbsd_library_name() {
186        let report = Parser::default()
187            .parse_bytes(
188                "libbsd.3bsd",
189                b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
190            )
191            .expect("parse libbsd library declaration");
192        let library = find_macro(&report.document.root, "Lb").expect("Lb node");
193        let visible = library
194            .children
195            .iter()
196            .filter(|child| !child.flags.no_print)
197            .filter_map(|child| child.text.as_deref())
198            .collect::<Vec<_>>();
199
200        assert_eq!(
201            visible,
202            ["Utility functions from BSD systems (libbsd, \\-lbsd)"]
203        );
204        assert!(
205            report
206                .diagnostics
207                .iter()
208                .all(|diagnostic| !diagnostic.message.contains("unknown library"))
209        );
210    }
211
212    #[test]
213    fn parser_expands_current_mdoc_standard_names() {
214        let report = Parser::default()
215            .parse_bytes(
216                "modern-standards.7",
217                b".Dd August 19, 2026\n.Dt MODERN-STANDARDS 7\n.Os\n\
218.Sh STANDARDS\n.St -isoC-2023\n.St -p1003.1-2024\n",
219            )
220            .expect("parse current standards declarations");
221
222        let mut visible = Vec::new();
223        collect_visible_text(&report.document.root, &mut visible);
224
225        assert!(
226            visible
227                .iter()
228                .any(|text| text.contains("ISO/IEC 9899:2024")),
229            "C23 declaration must expand: {visible:?}"
230        );
231        assert!(
232            visible
233                .iter()
234                .any(|text| text.contains("IEEE Std 1003.1-2024")),
235            "POSIX.1-2024 declaration must expand: {visible:?}"
236        );
237    }
238
239    #[test]
240    fn parser_accepts_pandoc_verbatim_font_aliases() {
241        let report = Parser::default()
242            .parse_bytes(
243                "pandoc-fonts.1",
244                b".TH PANDOC-FONTS 1\n.SH NAME\npandoc-fonts \\- fixture\n\
245.SH DESCRIPTION\n\\f[C]code\\f[R] \\f[V]verbatim\\f[R] \\f[VB]bold\\f[R] \\f[VI]italic\\f[R]\n",
246            )
247            .expect("parse Pandoc font aliases");
248
249        assert!(
250            report
251                .diagnostics
252                .iter()
253                .all(|diagnostic| !diagnostic.message.contains("invalid escape sequence")),
254            "supported font aliases must not emit invalid-escape diagnostics: {:?}",
255            report.diagnostics
256        );
257    }
258
259    #[test]
260    fn parser_decompresses_zstd_sources_before_calling_libmandoc() {
261        let path = source_path("zstd-mandoc-session").with_extension("1.zst");
262        let source = b".TH ZSTD-MANT 1 \"2026-07-20\"\n.SH NAME\nzstd-mant \\- compressed manual\n";
263        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
264        fs::write(&path, compressed).expect("write compressed manual source");
265
266        let report = Parser::default()
267            .parse_file(&path)
268            .expect("parse zstd manual");
269        fs::remove_file(path).expect("remove compressed manual source");
270
271        assert!(report.diagnostics.is_empty());
272        let document = report.document;
273        assert_eq!(document.macro_set, MacroSet::Man);
274        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-MANT"));
275        assert_eq!(document.metadata.section.as_deref(), Some("1"));
276        assert!(document.metadata.has_body);
277    }
278
279    #[test]
280    fn parser_preserves_infix_eqn_operators() {
281        let report = Parser::default()
282            .parse_bytes(
283                "equation.3",
284                b".TH EQUATION 3\n.SH DESCRIPTION\n.EQ\nx + {width over 2}\ny sub 1 sup 2\n.EN\n",
285            )
286            .expect("parse infix eqn operators");
287        let equation = find_kind(&report.document.root, NodeKind::Equation)
288            .and_then(|node| node.equation.as_deref())
289            .expect("normalized equation");
290
291        assert!(equation.contains("width / 2"), "{equation}");
292        assert!(equation.contains("y _ 1 ^ 2"), "{equation}");
293    }
294
295    #[test]
296    fn parser_normalizes_the_common_gnu_ldots_equation_macro() {
297        let report = Parser::default()
298            .parse_bytes(
299                "equation-ldots.3",
300                b".TH EQUATION 3\n.SH DESCRIPTION\n.EQ\nx sub 1 ldots x sub n\n.EN\n",
301            )
302            .expect("parse GNU ldots equation macro");
303        let equation = find_kind(&report.document.root, NodeKind::Equation)
304            .and_then(|node| node.equation.as_deref())
305            .expect("normalized equation");
306
307        assert_eq!(equation, "x _ 1 ... x _ n");
308    }
309
310    #[cfg(windows)]
311    #[test]
312    fn windows_parser_decompresses_gzip_before_calling_libmandoc() {
313        use flate2::{Compression as GzipCompression, write::GzEncoder};
314
315        let path = source_path("gzip-mandoc-session").with_extension("1.gz");
316        let mut encoder = GzEncoder::new(Vec::new(), GzipCompression::fast());
317        encoder
318            .write_all(b".TH GZIP-MANT 1\n.SH NAME\ngzip-mant \\- compressed manual\n")
319            .expect("encode gzip source");
320        fs::write(&path, encoder.finish().expect("finish gzip source")).expect("write gzip source");
321
322        let report = Parser::default()
323            .parse_file(&path)
324            .expect("parse gzip manual");
325        fs::remove_file(path).expect("remove gzip source");
326
327        assert_eq!(report.document.metadata.title.as_deref(), Some("GZIP-MANT"));
328    }
329
330    #[test]
331    fn parser_accepts_the_date_formats_used_by_libmandoc() {
332        for (date, normalized, normalized_with_style) in [
333            ("2026-07-20", "2026-07-20", false),
334            ("Jul 20, 2026", "July 20, 2026", true),
335            ("July 20, 2026", "July 20, 2026", false),
336            ("$Mdocdate: Jul 20 2026 $", "July 20, 2026", false),
337        ] {
338            let source =
339                format!(".TH WINDOWS-DATE 1 \"{date}\"\n.SH NAME\nwindows-date \\- portable\n");
340            let report = Parser::default()
341                .parse_bytes("windows-date.1", source.as_bytes())
342                .expect("parse a supported manual date");
343
344            if normalized_with_style {
345                assert_eq!(report.diagnostics.len(), 1);
346                assert_eq!(report.diagnostics[0].level, DiagnosticLevel::Style);
347                assert_eq!(
348                    report.diagnostics[0].message,
349                    "normalizing date format to: TH July 20, 2026"
350                );
351            } else {
352                assert!(
353                    report.diagnostics.is_empty(),
354                    "unexpected diagnostics for {date}: {:?}",
355                    report.diagnostics
356                );
357            }
358            assert_eq!(report.document.metadata.date.as_deref(), Some(normalized));
359        }
360    }
361
362    #[cfg(windows)]
363    #[test]
364    fn windows_rejects_c_file_inclusion_but_accepts_memory_parsing() {
365        let report = Parser::default()
366            .parse_bytes("memory.1", b".TH MEMORY 1\n.SH NAME\nmemory \\- portable\n")
367            .expect("parse caller-owned bytes");
368        assert_eq!(report.document.metadata.title.as_deref(), Some("MEMORY"));
369
370        let error = Parser::new(ParseOptions {
371            includes: IncludePolicy::SourceTree,
372            compression: Compression::Plain,
373        })
374        .parse_bytes("memory.1", b".so target.1\n")
375        .expect_err("reject native C file inclusion");
376        assert_eq!(error.kind, super::ParseErrorKind::Unsupported);
377    }
378
379    #[test]
380    fn invalid_zstd_sources_fail_before_reaching_libmandoc() {
381        let path = source_path("invalid-zstd-mandoc-session").with_extension("1.zst");
382        fs::write(&path, b"not a zstd frame").expect("write invalid compressed source");
383
384        let error = parse_file(&path, false).expect_err("invalid zstd source must fail");
385        fs::remove_file(path).expect("remove invalid compressed source");
386
387        assert!(
388            error
389                .message
390                .starts_with("could not decompress zstd manual source:")
391        );
392        assert_eq!(error.kind, super::ParseErrorKind::Decompression);
393        assert!(!error.message.contains("unsupported control character"));
394    }
395
396    #[cfg(unix)]
397    #[test]
398    fn zstd_sources_keep_their_original_include_root() {
399        let root = std::env::temp_dir().join(format!(
400            "mant-zstd-include-mandoc-session-{}",
401            process::id()
402        ));
403        let man1 = root.join("man1");
404        fs::create_dir_all(&man1).expect("create temporary manual tree");
405        let target = man1.join("target.1");
406        fs::write(
407            &target,
408            ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
409        )
410        .expect("write included manual");
411        let alias = man1.join("alias.1.zst");
412        let compressed =
413            zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
414        fs::write(&alias, compressed).expect("write compressed alias");
415
416        let document = parse_file(&alias, true).expect("resolve include from zstd source");
417        fs::remove_dir_all(root).expect("remove temporary manual tree");
418
419        assert_eq!(document.macro_set, MacroSet::Man);
420        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
421        assert!(document.metadata.has_body);
422    }
423
424    #[test]
425    fn parser_preserves_same_line_layout_and_next_line_content_roles() {
426        let path = source_path("line-role-mandoc-session");
427        fs::write(
428            &path,
429            ".TH LINE-ROLE 1\n.SH EXAMPLES\n.TP \\w'man\\ 'u\n.BI man \\ ls\nBody.\n",
430        )
431        .expect("write tagged paragraph source");
432
433        let document = parse_file(&path, false).expect("parse tagged paragraph source");
434        fs::remove_file(path).expect("remove tagged paragraph source");
435
436        let tagged_paragraph = find_macro(&document.root, "TP").expect("TP block");
437        let head = tagged_paragraph
438            .children
439            .iter()
440            .find(|child| child.kind == NodeKind::Head)
441            .expect("TP head");
442        assert_eq!(head.children[0].text.as_deref(), Some("96u"));
443        assert!(!head.children[0].flags.line_start);
444        assert_eq!(head.children[1].macro_name.as_deref(), Some("BI"));
445        assert!(head.children[1].flags.line_start);
446    }
447
448    #[test]
449    fn parser_preserves_mdoc_delimiter_spacing_roles() {
450        let path = source_path("delimiter-role-mandoc-session");
451        fs::write(
452            &path,
453            ".Dd August 4, 2026\n.Dt DELIMITERS 1\n.Os\n.Sh EXAMPLES\n\
454             .Dl name ( ) command\n\
455             .Dl local [ variable | - ] ...\n\
456             .Dl return [ exitstatus ]\n",
457        )
458        .expect("write delimiter-role source");
459
460        let document = parse_file(&path, false).expect("parse delimiter-role source");
461        fs::remove_file(path).expect("remove delimiter-role source");
462
463        let opening_parenthesis = find_node(&document.root, &|node| {
464            node.line == 5 && node.text.as_deref() == Some("(")
465        })
466        .expect("opening parenthesis");
467        let closing_parenthesis = find_node(&document.root, &|node| {
468            node.line == 5 && node.text.as_deref() == Some(")")
469        })
470        .expect("closing parenthesis");
471        let opening_bracket = find_node(&document.root, &|node| {
472            node.line == 7 && node.text.as_deref() == Some("[")
473        })
474        .expect("opening bracket");
475        let trailing_bracket = find_node(&document.root, &|node| {
476            node.line == 7 && node.text.as_deref() == Some("]")
477        })
478        .expect("trailing bracket");
479
480        assert!(opening_parenthesis.flags.delimiter_open);
481        assert!(closing_parenthesis.flags.delimiter_close);
482        assert!(opening_bracket.flags.delimiter_open);
483        assert!(trailing_bracket.flags.delimiter_close);
484    }
485
486    #[test]
487    fn parser_preserves_mdoc_synopsis_presentation_roles() {
488        let path = source_path("synopsis-role-mandoc-session");
489        fs::write(
490            &path,
491            ".Dd August 19, 2026\n.Dt SYNOPSIS-ROLE 3\n.Os\n\
492             .Sh SYNOPSIS\n.Fn synopsis_call \"int value\"\n\
493             .Fo explicit_call\n.Fa \"int value\"\n.Fc\n\
494             .Sh DESCRIPTION\n.Fn prose_call \"int value\"\n",
495        )
496        .expect("write synopsis-role source");
497
498        let document = parse_file(&path, false).expect("parse synopsis-role source");
499        fs::remove_file(path).expect("remove synopsis-role source");
500
501        let synopsis_function = find_node(&document.root, &|node| {
502            node.macro_name.as_deref() == Some("Fn") && node.line == 5
503        })
504        .expect("synopsis Fn");
505        let explicit_function = find_node(&document.root, &|node| {
506            node.macro_name.as_deref() == Some("Fo") && node.kind == NodeKind::Body
507        })
508        .expect("synopsis Fo body");
509        let prose_function = find_node(&document.root, &|node| {
510            node.macro_name.as_deref() == Some("Fn") && node.line == 10
511        })
512        .expect("prose Fn");
513
514        assert!(synopsis_function.flags.synopsis_pretty);
515        assert!(explicit_function.flags.synopsis_pretty);
516        assert!(!prose_function.flags.synopsis_pretty);
517    }
518
519    #[test]
520    fn parser_marks_tbl_text_block_cells() {
521        let path = source_path("tbl-text-block");
522        fs::write(
523            &path,
524            ".Dd August 19, 2026\n.Dt TBL-TEXT-BLOCK 3\n.Os\n.Sh NAME\n.Nm demo\n.Nd demo\n.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\nT{\n.Nm\nT}\tMT-Safe\n.TE\n",
525        )
526        .expect("write tbl text block source");
527        let document = parse_file(&path, false).expect("parse tbl text block source");
528        fs::remove_file(path).expect("remove tbl text block source");
529        let row = find_node(&document.root, &|node| {
530            node.kind == NodeKind::Table && node.table_cells.iter().any(|cell| cell.text_block)
531        })
532        .expect("tbl row containing a text block");
533        assert_eq!(row.table_cells.len(), 2);
534        assert_eq!(row.table_cells[0].text.as_deref(), Some(""));
535        assert!(row.table_cells[0].text_block);
536        assert!(!row.table_cells[1].text_block);
537    }
538
539    #[test]
540    fn parser_marks_both_tbl_vertical_continuation_forms() {
541        let document = Parser::default()
542            .parse_bytes(
543                "tbl-vertical-continuations.1",
544                b".TH TBL-VERTICAL-CONTINUATIONS 1\n.SH TABLES\n.TS\nl l.\nfirst\tvalue\n\\^\tcontinued\n.TE\n.TS\nl l,\n^ l.\nfirst\tvalue\n\tcontinued\n.TE\n",
545            )
546            .expect("parse tbl vertical continuations")
547            .document;
548
549        let explicit = find_node(&document.root, &|node| {
550            node.kind == NodeKind::Table && node.line == 6
551        })
552        .expect("explicit continuation row");
553        assert!(explicit.table_cells[0].vertical_continuation);
554
555        let layout = find_node(&document.root, &|node| {
556            node.kind == NodeKind::Table && node.line == 12
557        })
558        .expect("layout continuation row");
559        assert!(layout.table_cells[0].vertical_continuation);
560    }
561
562    #[test]
563    fn parser_session_reports_file_errors_as_values() {
564        let path = source_path("missing-mandoc-session");
565        let error = parse_file(&path, false).expect_err("missing source must fail");
566
567        assert_eq!(error.path, path);
568        assert!(!error.message.is_empty());
569    }
570
571    #[test]
572    fn parser_replaces_repeated_input_traps_without_losing_following_content() {
573        let mut source = String::from(".TH TRAPS 1\n.SH BODY\n");
574        for index in 0..1_024 {
575            writeln!(&mut source, ".it 100000 trap-{index}").expect("write test trap");
576        }
577        source.push_str(".SH TAIL\nretained tail marker\n");
578        let report = Parser::default()
579            .parse_bytes("traps.1", source.as_bytes())
580            .expect("replacing input traps must retain a finite parse");
581        let mut visible = Vec::new();
582        collect_visible_text(&report.document.root, &mut visible);
583        assert!(visible.join(" ").contains("retained tail marker"));
584    }
585
586    #[test]
587    fn parser_sessions_reset_unfinished_roff_requests() {
588        let parser = Parser::default();
589        for round in 0..32 {
590            parser
591                .parse_bytes(
592                    format!("unfinished-trap-{round}.1"),
593                    b".TH UNFINISHED-TRAP 1\n.it 2 br\n",
594                )
595                .expect("parse page ending with an armed input trap");
596            parser
597                .parse_bytes(
598                    format!("unfinished-center-{round}.1"),
599                    b".TH UNFINISHED-CENTER 1\n.ce 2\nonly-one-line\n",
600                )
601                .expect("parse page ending with an active centering request");
602            let next = parser
603                .parse_bytes(
604                    format!("clean-session-{round}.1"),
605                    b".TH CLEAN-SESSION 1\n.SH NAME\nclean-session \\- independent state\n",
606                )
607                .expect("subsequent parser session must remain independent");
608            assert_eq!(
609                next.document.metadata.title.as_deref(),
610                Some("CLEAN-SESSION")
611            );
612        }
613    }
614
615    #[test]
616    fn concurrent_callers_keep_thread_local_parser_state_isolated() {
617        const WORKERS: usize = 8;
618        const ROUNDS: usize = 16;
619
620        let start = Arc::new(Barrier::new(WORKERS));
621        let workers: Vec<_> = (0..WORKERS)
622            .map(|worker| {
623                let start = Arc::clone(&start);
624                std::thread::spawn(move || {
625                    start.wait();
626                    for round in 0..ROUNDS {
627                        let title = format!("TLS-{worker}-{round}");
628                        let source = format!(
629                            ".Dd August 19, 2026\n.Dt {title} 1\n.Os\n.Sh NAME\n.Nm tls-{worker}-{round}\n.Nd concurrent \\(em parser state\n.Sh SEE ALSO\n.Xr pthread_create 3\n"
630                        );
631                        let report = Parser::default()
632                            .parse_bytes(format!("tls-{worker}-{round}.1"), source.as_bytes())
633                            .expect("concurrent memory parse must succeed");
634                        assert_eq!(report.document.metadata.title.as_deref(), Some(title.as_str()));
635                        let name = format!("tls-{worker}-{round}");
636                        assert_eq!(report.document.metadata.name.as_deref(), Some(name.as_str()));
637                    }
638                })
639            })
640            .collect();
641        for worker in workers {
642            worker.join().expect("parser worker must not panic");
643        }
644    }
645
646    #[cfg(unix)]
647    #[test]
648    fn concurrent_source_tree_includes_keep_each_root_isolated() {
649        const WORKERS: usize = 8;
650
651        let root = std::env::temp_dir().join(format!(
652            "libmandoc-rs-thread-local-includes-{}",
653            process::id()
654        ));
655        let aliases: Vec<_> = (0..WORKERS)
656            .map(|worker| {
657                let tree = root.join(format!("tree-{worker}")).join("man1");
658                fs::create_dir_all(&tree).expect("create isolated manual tree");
659                fs::write(
660                    tree.join("target.1"),
661                    format!(
662                        ".Dd August 19, 2026\n.Dt TLS-INCLUDE-{worker} 1\n.Os\n.Sh NAME\n.Nm tls-include-{worker}\n.Nd isolated include tree\n"
663                    ),
664                )
665                .expect("write included manual source");
666                let alias = tree.join("alias.1");
667                fs::write(&alias, ".so target.1\n").expect("write manual redirect");
668                alias
669            })
670            .collect();
671
672        let start = Arc::new(Barrier::new(WORKERS));
673        let workers: Vec<_> = aliases
674            .into_iter()
675            .enumerate()
676            .map(|(worker, alias)| {
677                let start = Arc::clone(&start);
678                std::thread::spawn(move || {
679                    start.wait();
680                    let document = parse_file(&alias, true)
681                        .expect("concurrent source-tree include must succeed");
682                    assert_eq!(
683                        document.metadata.title.as_deref(),
684                        Some(format!("TLS-INCLUDE-{worker}").as_str())
685                    );
686                })
687            })
688            .collect();
689        for worker in workers {
690            worker.join().expect("include worker must not panic");
691        }
692        fs::remove_dir_all(root).expect("remove isolated manual trees");
693    }
694
695    #[cfg(unix)]
696    #[test]
697    fn source_relative_includes_do_not_change_process_cwd() {
698        let root =
699            std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
700        fs::create_dir_all(&root).expect("create temporary manual tree");
701        let target = root.join("minimal-mdoc.1");
702        fs::write(
703            &target,
704            ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
705        )
706        .expect("write included source");
707        let alias = root.join("alias-mdoc.1");
708        fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
709        let cwd = std::env::current_dir().expect("current directory before parse");
710
711        let document = parse_file(&alias, true).expect("resolve source-relative include");
712        fs::remove_dir_all(root).expect("remove temporary manual tree");
713
714        assert_eq!(document.macro_set, MacroSet::Mdoc);
715        assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
716        assert_eq!(
717            std::env::current_dir().expect("current directory after parse"),
718            cwd
719        );
720    }
721
722    #[test]
723    fn parser_accepts_owned_bytes_and_detects_zstd_frames() {
724        let source = b".TH BYTES 1\n.SH NAME\nbytes \\- parser input\n";
725        let plain = Parser::default()
726            .parse_bytes("memory.1", source)
727            .expect("parse plain byte input");
728        assert_eq!(plain.document.metadata.title.as_deref(), Some("BYTES"));
729
730        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
731        let zstd = Parser::default()
732            .parse_bytes("memory.1", &compressed)
733            .expect("detect and parse zstd byte input");
734        assert_eq!(zstd.document.metadata.title.as_deref(), Some("BYTES"));
735    }
736
737    #[cfg(unix)]
738    #[test]
739    fn parser_only_expands_includes_when_policy_allows_a_root() {
740        let base = std::env::temp_dir().join(format!(
741            "libmandoc-rs-explicit-include-root-{}",
742            process::id()
743        ));
744        let includes = base.join("includes");
745        fs::create_dir_all(&includes).expect("create explicit include root");
746        fs::write(
747            includes.join("target.1"),
748            ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
749        )
750        .expect("write included source");
751        let alias = base.join("alias.1");
752        fs::write(&alias, ".so target.1\n").expect("write alias source");
753
754        let denied = Parser::default()
755            .parse_file(&alias)
756            .expect("parse alias without include expansion");
757        let expanded = Parser::new(ParseOptions {
758            includes: IncludePolicy::Root(includes),
759            compression: Compression::Auto,
760        })
761        .parse_file(&alias)
762        .expect("resolve alias against explicit root");
763        fs::remove_dir_all(base).expect("remove temporary manual tree");
764
765        assert_ne!(
766            denied.document.metadata.title.as_deref(),
767            Some("EXPLICIT-ROOT")
768        );
769        assert_eq!(
770            expanded.document.metadata.title.as_deref(),
771            Some("EXPLICIT-ROOT")
772        );
773    }
774
775    #[cfg(unix)]
776    #[test]
777    fn explicit_root_resolves_compressed_includes_beside_the_source() {
778        use std::io::Write;
779
780        use flate2::{Compression as GzipCompression, write::GzEncoder};
781
782        let root = std::env::temp_dir().join(format!(
783            "libmandoc-rs-compressed-relative-include-{}",
784            process::id()
785        ));
786        let man1 = root.join("man1");
787        fs::create_dir_all(&man1).expect("create explicit manual section");
788        let mut target = GzEncoder::new(Vec::new(), GzipCompression::fast());
789        target
790            .write_all(b".SH INCLUDED\ncompressed relative content\n")
791            .expect("compress included source");
792        fs::write(
793            man1.join("target.1.gz"),
794            target.finish().expect("finish included source"),
795        )
796        .expect("write compressed included source");
797        let source = man1.join("source.1.gz");
798        let mut source_bytes = GzEncoder::new(Vec::new(), GzipCompression::fast());
799        source_bytes
800            .write_all(b".TH SOURCE 1\n.SH NAME\nsource \\- include fixture\n.so target.1\n")
801            .expect("compress source manual");
802        fs::write(
803            &source,
804            source_bytes.finish().expect("finish source manual"),
805        )
806        .expect("write source manual");
807
808        let report = Parser::new(ParseOptions {
809            includes: IncludePolicy::Root(root.clone()),
810            compression: Compression::Auto,
811        })
812        .parse_file(&source)
813        .expect("resolve compressed include beside source");
814        fs::remove_dir_all(root).expect("remove temporary manual tree");
815
816        let mut visible = Vec::new();
817        collect_visible_text(&report.document.root, &mut visible);
818        assert!(visible.contains(&"compressed relative content"));
819        assert!(
820            report
821                .diagnostics
822                .iter()
823                .all(|diagnostic| { !diagnostic.message.contains(".so request failed") })
824        );
825    }
826
827    #[cfg(unix)]
828    #[test]
829    fn explicit_include_root_does_not_fall_back_to_process_cwd() {
830        let identifier = format!("libmandoc-rs-ambient-{}", process::id());
831        let cwd_target = std::env::current_dir()
832            .expect("read test cwd")
833            .join(format!("{identifier}.1"));
834        fs::write(
835            &cwd_target,
836            ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
837        )
838        .expect("write ambient source");
839
840        let base = std::env::temp_dir().join(format!("{identifier}-root"));
841        fs::create_dir_all(&base).expect("create empty include root");
842        let alias = base.join("alias.1");
843        fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");
844
845        let result = Parser::new(ParseOptions {
846            includes: IncludePolicy::Root(base.clone()),
847            compression: Compression::Auto,
848        })
849        .parse_file(&alias);
850        fs::remove_file(cwd_target).expect("remove ambient source");
851        fs::remove_dir_all(base).expect("remove temporary manual tree");
852
853        match result {
854            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
855            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
856        }
857    }
858
859    #[cfg(unix)]
860    #[test]
861    fn explicit_include_root_rejects_linked_target_files() {
862        use std::os::unix::fs::symlink;
863
864        let base = std::env::temp_dir().join(format!(
865            "libmandoc-rs-linked-include-target-{}",
866            process::id()
867        ));
868        let includes = base.join("includes");
869        fs::create_dir_all(&includes).expect("create explicit include root");
870        let outside = base.join("outside.1");
871        fs::write(
872            &outside,
873            ".TH OUTSIDE 1\n.SH NAME\noutside \\- must not be included\n",
874        )
875        .expect("write outside target");
876        symlink(&outside, includes.join("target.1")).expect("link target outside root");
877        let alias = base.join("alias.1");
878        fs::write(&alias, ".so target.1\n").expect("write alias source");
879
880        let result = Parser::new(ParseOptions {
881            includes: IncludePolicy::Root(includes),
882            compression: Compression::Auto,
883        })
884        .parse_file(&alias);
885        fs::remove_dir_all(base).expect("remove temporary manual tree");
886
887        match result {
888            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("OUTSIDE")),
889            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
890        }
891    }
892
893    #[cfg(unix)]
894    #[test]
895    fn explicit_include_root_rejects_linked_intermediate_directories() {
896        use std::os::unix::fs::symlink;
897
898        let base = std::env::temp_dir().join(format!(
899            "libmandoc-rs-linked-include-directory-{}",
900            process::id()
901        ));
902        let includes = base.join("includes");
903        let outside = base.join("outside");
904        fs::create_dir_all(&includes).expect("create explicit include root");
905        fs::create_dir_all(&outside).expect("create outside directory");
906        fs::write(
907            outside.join("target.1"),
908            ".TH OUTSIDE-DIR 1\n.SH NAME\noutside-dir \\- must not be included\n",
909        )
910        .expect("write outside target");
911        fs::write(outside.join("alias.1"), ".so target.1\n").expect("write alias source");
912        symlink(&outside, includes.join("linked")).expect("link directory outside root");
913        let alias = includes.join("linked/alias.1");
914
915        let result = Parser::new(ParseOptions {
916            includes: IncludePolicy::Root(includes),
917            compression: Compression::Auto,
918        })
919        .parse_file(&alias);
920        fs::remove_dir_all(base).expect("remove temporary manual tree");
921
922        match result {
923            Ok(report) => assert_ne!(
924                report.document.metadata.title.as_deref(),
925                Some("OUTSIDE-DIR")
926            ),
927            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
928        }
929    }
930
931    #[test]
932    fn parser_returns_structured_nonfatal_diagnostics() {
933        let report = Parser::default()
934            .parse_bytes(
935                "diagnostics.1",
936                b".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
937            )
938            .expect("return best-effort document");
939
940        assert!(
941            report
942                .diagnostics
943                .iter()
944                .any(|diagnostic| diagnostic.level == super::DiagnosticLevel::Unsupported)
945        );
946    }
947
948    #[test]
949    fn coding_declarations_never_disable_available_byte_decoding() {
950        for declaration in ["latin-1", "ISO-8859-9"] {
951            let mut source =
952                format!(".\\\" -*- coding: {declaration} -*-\n.TH CD 1\n.SH BODY\nText: ")
953                    .into_bytes();
954            source.extend_from_slice(b"e\xf0itmen ba\xfelat\xfdr.\n");
955            let report = Parser::default()
956                .parse_bytes("coding.1", &source)
957                .expect("unsupported coding declaration retains a best-effort parse");
958            let mut visible = Vec::new();
959            collect_visible_text(&report.document.root, &mut visible);
960            let visible = visible.join(" ");
961            assert!(
962                visible.contains("e\\[u00F0]itmen ba\\[u00FE]lat\\[u00FD]r."),
963                "{declaration}: {visible}"
964            );
965            assert!(!visible.contains('?'), "{declaration}: {visible}");
966        }
967    }
968
969    #[test]
970    fn parser_decodes_truncated_utf8_tails_without_reading_past_memory_input() {
971        for byte in [0xc2, 0xe2, 0xf0] {
972            let mut source = b".TH TRUNCATED 1\n.SH BODY\n".to_vec();
973            source.push(byte);
974            let source = source.into_boxed_slice();
975            let report = Parser::default()
976                .parse_bytes("truncated.1", &source)
977                .expect("truncated UTF-8 tail must retain a best-effort parse");
978            let mut visible = Vec::new();
979            collect_visible_text(&report.document.root, &mut visible);
980            assert!(
981                visible.join(" ").contains(&format!("\\[u{byte:04X}]")),
982                "byte {byte:#x} was not preserved as Latin-1: {visible:?}"
983            );
984        }
985    }
986
987    #[test]
988    fn infinite_while_loop_is_bounded_with_a_diagnostic() {
989        let report = Parser::default()
990            .parse_bytes(
991                "loop.1",
992                b".TH LOOP 1\n.SH BODY\n.while 1 \\{\\\nloop\n.\\}\n.SH AFTER\nretained\n",
993            )
994            .expect("return the finite prefix of a looping manual");
995        let mut visible = Vec::new();
996        collect_visible_text(&report.document.root, &mut visible);
997
998        assert!(
999            report
1000                .diagnostics
1001                .iter()
1002                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1003            "loop budget must remain observable: {:?}",
1004            report.diagnostics
1005        );
1006        assert!(
1007            visible.contains(&"retained"),
1008            "parsing must continue after the bounded loop"
1009        );
1010        assert!(
1011            visible.iter().filter(|value| **value == "loop").count() <= 10_000,
1012            "the loop body must not exceed the documented budget"
1013        );
1014    }
1015
1016    #[test]
1017    fn recursive_user_macro_retains_content_after_the_cycle() {
1018        let report = Parser::default()
1019            .parse_bytes(
1020                "recursive.7",
1021                b".TH RECUR 7\n.SH NAME\nrecur \\- x\n.de R\n.  R\n..\n.R\n.SH DESC\ntail marker ZZTAIL\n",
1022            )
1023            .expect("return the complete document around recursive macro input");
1024        let mut visible = Vec::new();
1025        collect_visible_text(&report.document.root, &mut visible);
1026
1027        assert!(
1028            report
1029                .diagnostics
1030                .iter()
1031                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1032            "recursion limit must remain observable: {:?}",
1033            report.diagnostics
1034        );
1035        let visible = visible.join(" ");
1036        assert!(visible.contains("recur"), "{visible}");
1037        assert!(visible.contains("tail marker ZZTAIL"), "{visible}");
1038    }
1039
1040    #[test]
1041    fn deeply_nested_input_is_bounded_instead_of_overflowing_the_stack() {
1042        // Far more nesting than the copy cap; the parse must return a finite
1043        // tree rather than recursing without limit while copying it out.
1044        let depth = 5_000;
1045        let mut source = String::from(".TH DEEP 1\n.SH BODY\n");
1046        for _ in 0..depth {
1047            source.push_str(".RS\n");
1048        }
1049        source.push_str("deep\n");
1050
1051        let document = Parser::default()
1052            .parse_bytes("deep.1", source.as_bytes())
1053            .expect("deeply nested source parses")
1054            .document;
1055
1056        // The owned tree stays well under the input nesting, proving the copy
1057        // stopped descending at the cap.
1058        assert!(
1059            measured_depth(&document.root) <= 300,
1060            "tree depth must be bounded by the copy cap"
1061        );
1062    }
1063
1064    #[test]
1065    fn deeply_nested_equation_is_bounded_instead_of_overflowing_the_stack() {
1066        // Braces nest eqn boxes, a recursive walk the node-copy cap never
1067        // enters: copy_equation descends box->first without limit, so a
1068        // pathologically nested equation overflows the stack while flattening
1069        // it. Each `sqrt` level emits text, so an unbounded render would grow
1070        // the string with the input depth; a bounded one plateaus at the cap.
1071        let depth = 5_000;
1072        let mut equation = String::new();
1073        for _ in 0..depth {
1074            equation.push_str("sqrt { ");
1075        }
1076        equation.push('x');
1077        for _ in 0..depth {
1078            equation.push_str(" }");
1079        }
1080        let source = format!(".TH DEEP 1\n.SH BODY\n.EQ\n{equation}\n.EN\n");
1081
1082        let document = Parser::default()
1083            .parse_bytes("deep-eqn.1", source.as_bytes())
1084            .expect("deeply nested equation parses")
1085            .document;
1086
1087        let node = find_kind(&document.root, NodeKind::Equation).expect("equation node");
1088        let rendered = node.equation.as_deref().expect("equation text");
1089        // The render stopped at the cap: the flattened text is far shorter than
1090        // the ~30k chars all 5000 `sqrt` levels would emit, proving it did not
1091        // recurse through every box (and so could not overflow the stack).
1092        assert!(
1093            rendered.len() < 2_000,
1094            "equation text must be bounded by the copy cap, got {} bytes",
1095            rendered.len()
1096        );
1097    }
1098
1099    #[cfg(feature = "serde")]
1100    #[test]
1101    fn serde_feature_round_trips_the_public_parse_report() {
1102        let report = Parser::default()
1103            .parse_bytes("serde.1", b".TH SERDE 1\n.SH NAME\nserde \\- fixture\n")
1104            .expect("parse source for serialization");
1105        let encoded = serde_json::to_string(&report).expect("serialize parse report");
1106        let decoded: super::ParseReport =
1107            serde_json::from_str(&encoded).expect("deserialize parse report");
1108
1109        assert_eq!(decoded, report);
1110    }
1111
1112    #[test]
1113    fn parser_copies_normalized_list_and_display_attributes() {
1114        let path = source_path("normalized-mandoc-session");
1115        fs::write(
1116            &path,
1117            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh ITEMS\n\
1118             .Bl -tag -compact -offset indent -width 12n\n.It item\nfirst\n.El\n\
1119             .Bd -literal -offset indent\ncode line\n.Ed\n",
1120        )
1121        .expect("write normalized mdoc source");
1122
1123        let document = parse_file(&path, false).expect("parse normalized mdoc source");
1124        fs::remove_file(path).expect("remove normalized mdoc source");
1125
1126        let list = find_macro(&document.root, "Bl").expect("normalized list node");
1127        assert_eq!(list.list_kind, Some(NormalizedListKind::Definition));
1128        assert!(list.compact);
1129        assert_eq!(list.offset.as_deref(), Some("indent"));
1130        assert_eq!(list.width.as_deref(), Some("12n"));
1131        let display = find_macro(&document.root, "Bd").expect("normalized display node");
1132        assert_eq!(display.display_kind, Some(DisplayKind::Literal));
1133        assert_eq!(display.offset.as_deref(), Some("indent"));
1134    }
1135
1136    #[test]
1137    fn parser_retains_column_list_cells() {
1138        let report = Parser::default()
1139            .parse_bytes(
1140                "columns.3",
1141                b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
1142.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
1143            )
1144            .expect("parse mdoc column list");
1145        let item = find_macro(&report.document.root, "It").expect("column item");
1146        let bodies = item
1147            .children
1148            .iter()
1149            .filter(|child| child.kind == NodeKind::Body)
1150            .collect::<Vec<_>>();
1151
1152        assert_eq!(bodies.len(), 3);
1153        assert_eq!(
1154            bodies
1155                .iter()
1156                .map(|body| {
1157                    body.children
1158                        .iter()
1159                        .flat_map(|child| child.children.iter())
1160                        .chain(body.children.iter())
1161                        .filter_map(|child| child.text.as_deref())
1162                        .collect::<Vec<_>>()
1163                })
1164                .collect::<Vec<_>>(),
1165            [
1166                vec!["CLSET_TIMEOUT"],
1167                vec!["struct timeval *"],
1168                vec!["set total timeout"],
1169            ]
1170        );
1171    }
1172
1173    #[test]
1174    fn parser_copies_normalized_font_and_author_modes() {
1175        let report = Parser::default()
1176            .parse_bytes(
1177                "normalized-modes.1",
1178                b".Dd July 19, 2026\n.Dt NORMALIZED-MODES 1\n.Os\n.Sh AUTHORS\n\
1179.An -split\n.An Alice Example\n.An -nosplit\n.An Bob Example\n\
1180.Sh DESCRIPTION\n.Bf -literal\nliteral text\n.Ef\n",
1181            )
1182            .expect("parse normalized mdoc modes");
1183
1184        let split = find_node(&report.document.root, &|node| {
1185            node.macro_name.as_deref() == Some("An") && node.author_mode == Some(AuthorMode::Split)
1186        });
1187        let no_split = find_node(&report.document.root, &|node| {
1188            node.macro_name.as_deref() == Some("An")
1189                && node.author_mode == Some(AuthorMode::NoSplit)
1190        });
1191        let font = find_macro(&report.document.root, "Bf").expect("Bf node");
1192
1193        assert!(split.is_some());
1194        assert!(no_split.is_some());
1195        assert_eq!(font.font, Some(NormalizedFont::Literal));
1196    }
1197
1198    #[test]
1199    fn parser_resolves_stateful_mdoc_enclosures_onto_each_use() {
1200        let report = Parser::default()
1201            .parse_bytes(
1202                "normalized-enclosure.1",
1203                b".Dd August 17, 2026\n.Dt ENCLOSURE 1\n.Os\n.Sh DESCRIPTION\n\
1204.Es << >>\n.En value\n",
1205            )
1206            .expect("parse stateful mdoc enclosure");
1207
1208        let enclosure = find_macro(&report.document.root, "En")
1209            .and_then(|node| node.enclosure.as_ref())
1210            .expect("resolved En delimiters");
1211        assert_eq!(enclosure.opening, "<<");
1212        assert_eq!(enclosure.closing.as_deref(), Some(">>"));
1213    }
1214
1215    #[test]
1216    fn parser_copies_table_cells_and_equation_text() {
1217        let path = source_path("structured-payload-mandoc-session");
1218        fs::write(
1219            &path,
1220            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
1221             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
1222        )
1223        .expect("write table and equation source");
1224
1225        let document = parse_file(&path, false).expect("parse table and equation source");
1226        fs::remove_file(path).expect("remove table and equation source");
1227
1228        let table = find_kind(&document.root, NodeKind::Table).expect("table row node");
1229        assert_eq!(table.table_cells.len(), 2);
1230        assert_eq!(table.table_cells[0].text.as_deref(), Some("left"));
1231        assert_eq!(table.table_cells[1].alignment, TableAlignment::Right);
1232        let equation = find_kind(&document.root, NodeKind::Equation).expect("equation node");
1233        assert!(
1234            equation
1235                .equation
1236                .as_deref()
1237                .is_some_and(|value| value.contains('x'))
1238        );
1239    }
1240
1241    #[test]
1242    fn parser_copies_validated_same_document_navigation() {
1243        let path = source_path("navigation-mandoc-session");
1244        fs::write(
1245            &path,
1246            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh FIRST\n\
1247             See\n\
1248             .Sx TARGET\n\
1249             for details.\n\
1250             .Tg explicit-target\n\
1251             .Fl x\n\
1252             .Sh TARGET\nTarget text.\n",
1253        )
1254        .expect("write navigation mdoc source");
1255
1256        let document = parse_file(&path, false).expect("parse navigation mdoc source");
1257        fs::remove_file(path).expect("remove navigation mdoc source");
1258
1259        assert!(find_macro(&document.root, "Sx").is_some());
1260        let explicit_target = find_node(&document.root, &|node| {
1261            node.flags.deep_link_target && node.tag.as_deref() == Some("explicit-target")
1262        });
1263        let explicit_target = explicit_target.expect("Tg must annotate its resolved destination");
1264        assert!(explicit_target.flags.permalink);
1265    }
1266}