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 compression;
9mod diagnostics;
10#[allow(unsafe_code)]
11mod ffi;
12mod parser;
13#[cfg(feature = "render")]
14mod renderer;
15mod source_bundle;
16mod special_character;
17
18pub use ast::{
19    AuthorMode, DisplayKind, Document, MacroSet, Metadata, Node, NodeFlags, NodeKind,
20    NormalizedEnclosure, NormalizedFont, NormalizedListKind, TableAlignment, TableCell,
21};
22pub use compression::MAX_DECOMPRESSED_SOURCE_BYTES;
23pub use diagnostics::{Diagnostic, DiagnosticCode, DiagnosticLevel, SourceLocation};
24pub use parser::{
25    Compression, IncludePolicy, InputFormat, ParseError, ParseErrorKind, ParseOptions, ParseReport,
26    Parser,
27};
28#[cfg(feature = "render")]
29pub use renderer::{
30    DEFAULT_RENDER_OUTPUT_BYTES, DEFAULT_RENDER_WIDTH, MAX_RENDER_OUTPUT_BYTES, MAX_RENDER_WIDTH,
31    MIN_RENDER_WIDTH, RenderError, RenderErrorKind, RenderFormat, RenderReport, Renderer,
32};
33pub use source_bundle::{
34    MAX_SOURCE_BUNDLE_BYTES, MAX_SOURCE_BUNDLE_FILE_BYTES, MAX_SOURCE_BUNDLE_FILES, SourceBundle,
35    SourceBundleError, SourceBundleErrorKind,
36};
37pub use special_character::{SpecialCharacter, special_character};
38
39/// Pinned upstream version compiled by this crate's build script.
40pub const LIBMANDOC_VERSION: &str = "1.14.6";
41
42/// Private output of the FFI boundary before diagnostics become public values.
43struct RawDocument {
44    document: Document,
45    diagnostics: String,
46    node_truncated: bool,
47    equation_truncated: bool,
48}
49
50#[cfg(feature = "render")]
51struct RawRender {
52    output: Vec<u8>,
53    diagnostics: String,
54}
55
56#[cfg(test)]
57mod tests {
58    use std::{
59        fmt::Write as _,
60        fs, process,
61        sync::{Arc, Barrier},
62    };
63
64    #[cfg(windows)]
65    use std::io::Write;
66
67    #[cfg(windows)]
68    use windows_sys::Win32::Foundation::ERROR_PRIVILEGE_NOT_HELD;
69
70    #[cfg(feature = "serde")]
71    use super::Diagnostic;
72
73    use super::{
74        AuthorMode, Compression, DiagnosticCode, DiagnosticLevel, DisplayKind, Document,
75        IncludePolicy, InputFormat, MacroSet, Node, NodeKind, NormalizedFont, NormalizedListKind,
76        ParseError, ParseOptions, Parser, SourceBundle, TableAlignment,
77    };
78
79    fn source_path(label: &str) -> std::path::PathBuf {
80        std::env::temp_dir().join(format!("mant-{label}-{}.1", process::id()))
81    }
82
83    fn measured_depth(node: &Node) -> usize {
84        1 + node.children.iter().map(measured_depth).max().unwrap_or(0)
85    }
86
87    fn parse_file(path: &std::path::Path, allow_includes: bool) -> Result<Document, ParseError> {
88        Parser::new(ParseOptions {
89            includes: if allow_includes {
90                IncludePolicy::SourceTree
91            } else {
92                IncludePolicy::Deny
93            },
94            compression: Compression::Auto,
95        })
96        .parse_file(path)
97        .map(|report| report.document)
98    }
99
100    fn find_macro<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
101        (node.macro_name.as_deref() == Some(name))
102            .then_some(node)
103            .or_else(|| {
104                node.children
105                    .iter()
106                    .find_map(|child| find_macro(child, name))
107            })
108    }
109
110    fn find_kind(node: &Node, kind: NodeKind) -> Option<&Node> {
111        (node.kind == kind).then_some(node).or_else(|| {
112            node.children
113                .iter()
114                .find_map(|child| find_kind(child, kind))
115        })
116    }
117
118    fn find_node<'a>(node: &'a Node, predicate: &impl Fn(&Node) -> bool) -> Option<&'a Node> {
119        predicate(node).then_some(node).or_else(|| {
120            node.children
121                .iter()
122                .find_map(|child| find_node(child, predicate))
123        })
124    }
125
126    fn collect_visible_text<'a>(node: &'a Node, visible: &mut Vec<&'a str>) {
127        if !node.flags.no_print
128            && let Some(text) = node.text.as_deref()
129        {
130            visible.push(text);
131        }
132        for child in &node.children {
133            collect_visible_text(child, visible);
134        }
135    }
136
137    #[test]
138    fn upstream_version_is_pinned() {
139        assert_eq!(super::LIBMANDOC_VERSION, "1.14.6");
140    }
141
142    #[test]
143    fn parser_session_returns_an_owned_man_tree() {
144        let path = source_path("mandoc-session");
145        fs::write(
146            &path,
147            ".TH MANT 1 \"2026-07-19\"\n.SH NAME\nmant \\- manual viewer\n",
148        )
149        .expect("write temporary manual source");
150
151        let document = parse_file(&path, false).expect("parse temporary manual");
152        fs::remove_file(path).expect("remove temporary manual source");
153
154        assert_eq!(document.macro_set, MacroSet::Man);
155        assert_eq!(document.metadata.title.as_deref(), Some("MANT"));
156        assert_eq!(document.metadata.section.as_deref(), Some("1"));
157        assert!(document.metadata.has_body);
158        assert_eq!(document.root.kind, NodeKind::Root);
159        assert!(!document.root.children.is_empty());
160    }
161
162    #[test]
163    fn parser_recognizes_the_modern_man_reference_macro() {
164        let report = Parser::default()
165            .parse_bytes(
166                "modern-reference.1",
167                b".TH MODERN-REFERENCE 1\n.SH NAME\nmodern-reference \\- fixture\n\
168.SH SEE ALSO\n.MR git-add 1 ,\n",
169            )
170            .expect("parse modern man reference");
171
172        assert!(
173            report
174                .diagnostics
175                .iter()
176                .all(|diagnostic| !diagnostic.message.contains("unknown macro")),
177            "MR must be a native parser node: {:?}",
178            report.diagnostics
179        );
180        let reference = find_macro(&report.document.root, "MR").expect("MR node");
181        assert_eq!(reference.kind, NodeKind::Element);
182        assert_eq!(
183            reference
184                .children
185                .iter()
186                .filter_map(|child| child.text.as_deref())
187                .collect::<Vec<_>>(),
188            ["git-add", "1", ","]
189        );
190    }
191
192    #[test]
193    fn parser_retains_mdoc_include_arguments() {
194        let report = Parser::default()
195            .parse_bytes(
196                "include.3",
197                b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
198            )
199            .expect("parse mdoc include");
200
201        let include = find_macro(&report.document.root, "In").expect("In node");
202        assert_eq!(include.kind, NodeKind::Element);
203        assert_eq!(
204            include
205                .children
206                .iter()
207                .filter_map(|child| child.text.as_deref())
208                .collect::<Vec<_>>(),
209            ["fido.h"]
210        );
211    }
212
213    #[test]
214    fn parser_can_pin_bare_mdoc_operating_system_metadata() {
215        let parser = Parser::default()
216            .with_mdoc_operating_system("PinnedOS 1.0")
217            .expect("valid operating-system override");
218        assert_eq!(
219            parser.mdoc_operating_system().map(std::ffi::CStr::to_bytes),
220            Some(b"PinnedOS 1.0".as_slice())
221        );
222
223        let bare = parser
224            .parse_bytes(
225                "bare-os.1",
226                b".Dd August 24, 2026\n.Dt BARE-OS 1\n.Os\n.Sh NAME\n.Nm bare-os\n",
227            )
228            .expect("parse a caller-pinned bare Os macro");
229        assert_eq!(bare.document.metadata.os.as_deref(), Some("PinnedOS 1.0"));
230
231        let authored = parser
232            .parse_bytes(
233                "authored-os.1",
234                b".Dd August 24, 2026\n.Dt AUTHORED-OS 1\n.Os AuthoredOS\n.Sh NAME\n.Nm authored-os\n",
235            )
236            .expect("parse an authored Os value");
237        assert_eq!(authored.document.metadata.os.as_deref(), Some("AuthoredOS"));
238    }
239
240    #[test]
241    fn public_text_normalizes_native_layout_sentinels() {
242        let report = Parser::default()
243            .parse_bytes(
244                "visible-text.1",
245                b".Dd August 24, 2026\n.Dt VISIBLE-TEXT 1\n.Os ManT\n.Sh NAME\n.Nm visible-text\n.Nd well-known read-only thing\n",
246            )
247            .expect("parse hyphenated visible text");
248        let mut visible = Vec::new();
249        collect_visible_text(&report.document.root, &mut visible);
250
251        assert!(visible.join(" ").contains("well-known read-only thing"));
252        assert!(
253            find_node(&report.document.root, &|node| {
254                node.text.as_deref().is_some_and(|text| {
255                    text.chars()
256                        .any(|character| ['\u{1d}', '\u{1e}', '\u{1f}'].contains(&character))
257                })
258            })
259            .is_none(),
260            "public AST text must not expose libmandoc layout sentinels"
261        );
262    }
263
264    #[test]
265    fn parser_expands_the_libbsd_library_name() {
266        let report = Parser::default()
267            .parse_bytes(
268                "libbsd.3bsd",
269                b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
270            )
271            .expect("parse libbsd library declaration");
272        let library = find_macro(&report.document.root, "Lb").expect("Lb node");
273        let visible = library
274            .children
275            .iter()
276            .filter(|child| !child.flags.no_print)
277            .filter_map(|child| child.text.as_deref())
278            .collect::<Vec<_>>();
279
280        assert_eq!(
281            visible,
282            ["Utility functions from BSD systems (libbsd, \\-lbsd)"]
283        );
284        assert!(
285            report
286                .diagnostics
287                .iter()
288                .all(|diagnostic| !diagnostic.message.contains("unknown library"))
289        );
290    }
291
292    #[test]
293    fn parser_expands_current_mdoc_standard_names() {
294        let report = Parser::default()
295            .parse_bytes(
296                "modern-standards.7",
297                b".Dd August 19, 2026\n.Dt MODERN-STANDARDS 7\n.Os\n\
298.Sh STANDARDS\n.St -isoC-2023\n.St -p1003.1-2024\n",
299            )
300            .expect("parse current standards declarations");
301
302        let mut visible = Vec::new();
303        collect_visible_text(&report.document.root, &mut visible);
304
305        assert!(
306            visible
307                .iter()
308                .any(|text| text.contains("ISO/IEC 9899:2024")),
309            "C23 declaration must expand: {visible:?}"
310        );
311        assert!(
312            visible
313                .iter()
314                .any(|text| text.contains("IEEE Std 1003.1-2024")),
315            "POSIX.1-2024 declaration must expand: {visible:?}"
316        );
317    }
318
319    #[test]
320    fn parser_accepts_pandoc_verbatim_font_aliases() {
321        let report = Parser::default()
322            .parse_bytes(
323                "pandoc-fonts.1",
324                b".TH PANDOC-FONTS 1\n.SH NAME\npandoc-fonts \\- fixture\n\
325.SH DESCRIPTION\n\\f[C]code\\f[R] \\f[V]verbatim\\f[R] \\f[VB]bold\\f[R] \\f[VI]italic\\f[R]\n",
326            )
327            .expect("parse Pandoc font aliases");
328
329        assert!(
330            report
331                .diagnostics
332                .iter()
333                .all(|diagnostic| !diagnostic.message.contains("invalid escape sequence")),
334            "supported font aliases must not emit invalid-escape diagnostics: {:?}",
335            report.diagnostics
336        );
337    }
338
339    #[test]
340    fn parser_decompresses_zstd_sources_before_calling_libmandoc() {
341        let path = source_path("zstd-mandoc-session").with_extension("1.zst");
342        let source = b".TH ZSTD-MANT 1 \"2026-07-20\"\n.SH NAME\nzstd-mant \\- compressed manual\n";
343        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
344        fs::write(&path, compressed).expect("write compressed manual source");
345
346        let report = Parser::default()
347            .parse_file(&path)
348            .expect("parse zstd manual");
349        fs::remove_file(path).expect("remove compressed manual source");
350
351        assert!(report.diagnostics.is_empty());
352        let document = report.document;
353        assert_eq!(document.macro_set, MacroSet::Man);
354        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-MANT"));
355        assert_eq!(document.metadata.section.as_deref(), Some("1"));
356        assert!(document.metadata.has_body);
357    }
358
359    #[test]
360    fn parser_preserves_infix_eqn_operators() {
361        let report = Parser::default()
362            .parse_bytes(
363                "equation.3",
364                b".TH EQUATION 3\n.SH DESCRIPTION\n.EQ\nx + {width over 2}\ny sub 1 sup 2\n.EN\n",
365            )
366            .expect("parse infix eqn operators");
367        let equation = find_kind(&report.document.root, NodeKind::Equation)
368            .and_then(|node| node.equation.as_deref())
369            .expect("normalized equation");
370
371        assert!(equation.contains("width / 2"), "{equation}");
372        assert!(equation.contains("y _ 1 ^ 2"), "{equation}");
373    }
374
375    #[test]
376    fn parser_normalizes_the_common_gnu_ldots_equation_macro() {
377        let report = Parser::default()
378            .parse_bytes(
379                "equation-ldots.3",
380                b".TH EQUATION 3\n.SH DESCRIPTION\n.EQ\nx sub 1 ldots x sub n\n.EN\n",
381            )
382            .expect("parse GNU ldots equation macro");
383        let equation = find_kind(&report.document.root, NodeKind::Equation)
384            .and_then(|node| node.equation.as_deref())
385            .expect("normalized equation");
386
387        assert_eq!(equation, "x _ 1 ... x _ n");
388    }
389
390    #[cfg(windows)]
391    #[test]
392    fn windows_parser_decompresses_gzip_before_calling_libmandoc() {
393        use flate2::{Compression as GzipCompression, write::GzEncoder};
394
395        let path = source_path("gzip-mandoc-session").with_extension("1.gz");
396        let mut encoder = GzEncoder::new(Vec::new(), GzipCompression::fast());
397        encoder
398            .write_all(b".TH GZIP-MANT 1\n.SH NAME\ngzip-mant \\- compressed manual\n")
399            .expect("encode gzip source");
400        fs::write(&path, encoder.finish().expect("finish gzip source")).expect("write gzip source");
401
402        let report = Parser::default()
403            .parse_file(&path)
404            .expect("parse gzip manual");
405        fs::remove_file(path).expect("remove gzip source");
406
407        assert_eq!(report.document.metadata.title.as_deref(), Some("GZIP-MANT"));
408    }
409
410    #[cfg(windows)]
411    #[test]
412    fn windows_parser_uses_native_gzip_fallback_for_top_level_files() {
413        use flate2::{Compression as GzipCompression, write::GzEncoder};
414
415        let requested = source_path("gzip-fallback-session");
416        let mut compressed_path = requested.as_os_str().to_os_string();
417        compressed_path.push(".gz");
418        let compressed_path = std::path::PathBuf::from(compressed_path);
419        let mut encoder = GzEncoder::new(Vec::new(), GzipCompression::fast());
420        encoder
421            .write_all(b".TH GZIP-FALLBACK 1\n.SH NAME\ngzip-fallback \\- compressed manual\n")
422            .expect("encode fallback source");
423        fs::write(
424            &compressed_path,
425            encoder.finish().expect("finish gzip source"),
426        )
427        .expect("write fallback source");
428
429        let report = Parser::default()
430            .parse_file(&requested)
431            .expect("parse the implicit .gz fallback");
432        fs::remove_file(compressed_path).expect("remove gzip fallback source");
433
434        assert_eq!(
435            report.document.metadata.title.as_deref(),
436            Some("GZIP-FALLBACK")
437        );
438    }
439
440    #[test]
441    fn parser_accepts_the_date_formats_used_by_libmandoc() {
442        for (date, normalized, normalized_with_style) in [
443            ("2026-07-20", "2026-07-20", false),
444            ("Jul 20, 2026", "July 20, 2026", true),
445            ("July 20, 2026", "July 20, 2026", false),
446            ("$Mdocdate: Jul 20 2026 $", "July 20, 2026", false),
447        ] {
448            let source =
449                format!(".TH WINDOWS-DATE 1 \"{date}\"\n.SH NAME\nwindows-date \\- portable\n");
450            let report = Parser::default()
451                .parse_bytes("windows-date.1", source.as_bytes())
452                .expect("parse a supported manual date");
453
454            if normalized_with_style {
455                assert_eq!(report.diagnostics.len(), 1);
456                assert_eq!(report.diagnostics[0].level, DiagnosticLevel::Style);
457                assert_eq!(
458                    report.diagnostics[0].message,
459                    "normalizing date format to: TH July 20, 2026"
460                );
461            } else {
462                assert!(
463                    report.diagnostics.is_empty(),
464                    "unexpected diagnostics for {date}: {:?}",
465                    report.diagnostics
466                );
467            }
468            assert_eq!(report.document.metadata.date.as_deref(), Some(normalized));
469        }
470    }
471
472    #[test]
473    fn parser_normalizes_dates_consistently_across_supported_targets() {
474        for (date, normalized) in [
475            ("February 30, 2026", "March 2, 2026"),
476            ("Jul  2, 2026", "July 2, 2026"),
477            ("1-1-1", "1-1-1"),
478            ("0000-01-01", "0000-01-01"),
479            ("January 1, 1960", "January 1, 1960"),
480        ] {
481            let source =
482                format!(".TH PORTABLE-DATE 1 \"{date}\"\n.SH NAME\nportable-date \\- portable\n");
483            let report = Parser::default()
484                .parse_bytes("portable-date.1", source.as_bytes())
485                .expect("parse a portable manual date");
486
487            assert_eq!(
488                report.document.metadata.date.as_deref(),
489                Some(normalized),
490                "date input {date}"
491            );
492            assert!(
493                report
494                    .diagnostics
495                    .iter()
496                    .all(|diagnostic| !diagnostic.message.contains("bad date argument")),
497                "date input {date}: {:?}",
498                report.diagnostics
499            );
500        }
501    }
502
503    #[cfg(windows)]
504    #[test]
505    fn windows_rejects_ambient_source_tree_but_accepts_memory_parsing() {
506        let report = Parser::default()
507            .parse_bytes("memory.1", b".TH MEMORY 1\n.SH NAME\nmemory \\- portable\n")
508            .expect("parse caller-owned bytes");
509        assert_eq!(report.document.metadata.title.as_deref(), Some("MEMORY"));
510
511        let error = Parser::new(ParseOptions {
512            includes: IncludePolicy::SourceTree,
513            compression: Compression::Plain,
514        })
515        .parse_bytes("memory.1", b".so target.1\n")
516        .expect_err("reject ambient source-tree inclusion");
517        assert_eq!(error.kind, super::ParseErrorKind::Unsupported);
518        assert_eq!(error.path, std::path::Path::new("memory.1"));
519    }
520
521    #[test]
522    fn invalid_zstd_sources_fail_before_reaching_libmandoc() {
523        let path = source_path("invalid-zstd-mandoc-session").with_extension("1.zst");
524        fs::write(&path, b"not a zstd frame").expect("write invalid compressed source");
525
526        let error = parse_file(&path, false).expect_err("invalid zstd source must fail");
527        fs::remove_file(path).expect("remove invalid compressed source");
528
529        assert!(
530            error
531                .message
532                .starts_with("could not decompress zstd manual source:")
533        );
534        assert_eq!(error.kind, super::ParseErrorKind::Decompression);
535        assert!(!error.message.contains("unsupported control character"));
536    }
537
538    #[test]
539    fn oversized_zstd_sources_fail_without_returning_partial_input() {
540        let source = vec![b'x'; super::MAX_DECOMPRESSED_SOURCE_BYTES + 1];
541        let compressed = zstd::stream::encode_all(source.as_slice(), 0)
542            .expect("compress oversized source fixture");
543        let error = Parser::default()
544            .parse_bytes("oversized.1.zst", &compressed)
545            .expect_err("reject a decoded source above the fixed limit");
546
547        assert_eq!(error.kind, super::ParseErrorKind::Decompression);
548        assert!(
549            error.message.contains(&format!(
550                "{}-byte limit",
551                super::MAX_DECOMPRESSED_SOURCE_BYTES
552            )),
553            "unexpected decompression error: {error}"
554        );
555    }
556
557    #[cfg(unix)]
558    #[test]
559    fn zstd_sources_keep_their_original_include_root() {
560        let root = std::env::temp_dir().join(format!(
561            "mant-zstd-include-mandoc-session-{}",
562            process::id()
563        ));
564        let man1 = root.join("man1");
565        fs::create_dir_all(&man1).expect("create temporary manual tree");
566        let target = man1.join("target.1");
567        fs::write(
568            &target,
569            ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
570        )
571        .expect("write included manual");
572        let alias = man1.join("alias.1.zst");
573        let compressed =
574            zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
575        fs::write(&alias, compressed).expect("write compressed alias");
576
577        let document = parse_file(&alias, true).expect("resolve include from zstd source");
578        fs::remove_dir_all(root).expect("remove temporary manual tree");
579
580        assert_eq!(document.macro_set, MacroSet::Man);
581        assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
582        assert!(document.metadata.has_body);
583    }
584
585    #[test]
586    fn parser_preserves_same_line_layout_and_next_line_content_roles() {
587        let path = source_path("line-role-mandoc-session");
588        fs::write(
589            &path,
590            ".TH LINE-ROLE 1\n.SH EXAMPLES\n.TP \\w'man\\ 'u\n.BI man \\ ls\nBody.\n",
591        )
592        .expect("write tagged paragraph source");
593
594        let document = parse_file(&path, false).expect("parse tagged paragraph source");
595        fs::remove_file(path).expect("remove tagged paragraph source");
596
597        let tagged_paragraph = find_macro(&document.root, "TP").expect("TP block");
598        let head = tagged_paragraph
599            .children
600            .iter()
601            .find(|child| child.kind == NodeKind::Head)
602            .expect("TP head");
603        assert_eq!(head.children[0].text.as_deref(), Some("96u"));
604        assert!(!head.children[0].flags.line_start);
605        assert_eq!(head.children[1].macro_name.as_deref(), Some("BI"));
606        assert!(head.children[1].flags.line_start);
607    }
608
609    #[test]
610    fn parser_preserves_mdoc_delimiter_spacing_roles() {
611        let path = source_path("delimiter-role-mandoc-session");
612        fs::write(
613            &path,
614            ".Dd August 4, 2026\n.Dt DELIMITERS 1\n.Os\n.Sh EXAMPLES\n\
615             .Dl name ( ) command\n\
616             .Dl local [ variable | - ] ...\n\
617             .Dl return [ exitstatus ]\n",
618        )
619        .expect("write delimiter-role source");
620
621        let document = parse_file(&path, false).expect("parse delimiter-role source");
622        fs::remove_file(path).expect("remove delimiter-role source");
623
624        let opening_parenthesis = find_node(&document.root, &|node| {
625            node.line == 5 && node.text.as_deref() == Some("(")
626        })
627        .expect("opening parenthesis");
628        let closing_parenthesis = find_node(&document.root, &|node| {
629            node.line == 5 && node.text.as_deref() == Some(")")
630        })
631        .expect("closing parenthesis");
632        let opening_bracket = find_node(&document.root, &|node| {
633            node.line == 7 && node.text.as_deref() == Some("[")
634        })
635        .expect("opening bracket");
636        let trailing_bracket = find_node(&document.root, &|node| {
637            node.line == 7 && node.text.as_deref() == Some("]")
638        })
639        .expect("trailing bracket");
640
641        assert!(opening_parenthesis.flags.delimiter_open);
642        assert!(closing_parenthesis.flags.delimiter_close);
643        assert!(opening_bracket.flags.delimiter_open);
644        assert!(trailing_bracket.flags.delimiter_close);
645    }
646
647    #[test]
648    fn parser_preserves_mdoc_synopsis_presentation_roles() {
649        let path = source_path("synopsis-role-mandoc-session");
650        fs::write(
651            &path,
652            ".Dd August 19, 2026\n.Dt SYNOPSIS-ROLE 3\n.Os\n\
653             .Sh SYNOPSIS\n.Fn synopsis_call \"int value\"\n\
654             .Fo explicit_call\n.Fa \"int value\"\n.Fc\n\
655             .Sh DESCRIPTION\n.Fn prose_call \"int value\"\n",
656        )
657        .expect("write synopsis-role source");
658
659        let document = parse_file(&path, false).expect("parse synopsis-role source");
660        fs::remove_file(path).expect("remove synopsis-role source");
661
662        let synopsis_function = find_node(&document.root, &|node| {
663            node.macro_name.as_deref() == Some("Fn") && node.line == 5
664        })
665        .expect("synopsis Fn");
666        let explicit_function = find_node(&document.root, &|node| {
667            node.macro_name.as_deref() == Some("Fo") && node.kind == NodeKind::Body
668        })
669        .expect("synopsis Fo body");
670        let prose_function = find_node(&document.root, &|node| {
671            node.macro_name.as_deref() == Some("Fn") && node.line == 10
672        })
673        .expect("prose Fn");
674
675        assert!(synopsis_function.flags.synopsis_pretty);
676        assert!(explicit_function.flags.synopsis_pretty);
677        assert!(!prose_function.flags.synopsis_pretty);
678    }
679
680    #[test]
681    fn parser_marks_tbl_text_block_cells() {
682        let path = source_path("tbl-text-block");
683        fs::write(
684            &path,
685            ".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",
686        )
687        .expect("write tbl text block source");
688        let document = parse_file(&path, false).expect("parse tbl text block source");
689        fs::remove_file(path).expect("remove tbl text block source");
690        let row = find_node(&document.root, &|node| {
691            node.kind == NodeKind::Table && node.table_cells.iter().any(|cell| cell.text_block)
692        })
693        .expect("tbl row containing a text block");
694        assert_eq!(row.table_cells.len(), 2);
695        assert_eq!(row.table_cells[0].text.as_deref(), Some(""));
696        assert!(row.table_cells[0].text_block);
697        assert!(!row.table_cells[1].text_block);
698    }
699
700    #[test]
701    fn parser_marks_both_tbl_vertical_continuation_forms() {
702        let document = Parser::default()
703            .parse_bytes(
704                "tbl-vertical-continuations.1",
705                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",
706            )
707            .expect("parse tbl vertical continuations")
708            .document;
709
710        let explicit = find_node(&document.root, &|node| {
711            node.kind == NodeKind::Table && node.line == 6
712        })
713        .expect("explicit continuation row");
714        assert!(explicit.table_cells[0].vertical_continuation);
715
716        let layout = find_node(&document.root, &|node| {
717            node.kind == NodeKind::Table && node.line == 12
718        })
719        .expect("layout continuation row");
720        assert!(layout.table_cells[0].vertical_continuation);
721    }
722
723    #[test]
724    fn parser_session_reports_file_errors_as_values() {
725        let path = source_path("missing-mandoc-session");
726        let error = parse_file(&path, false).expect_err("missing source must fail");
727
728        assert_eq!(error.path, path);
729        assert!(!error.message.is_empty());
730    }
731
732    #[test]
733    fn parser_replaces_repeated_input_traps_without_losing_following_content() {
734        let mut source = String::from(".TH TRAPS 1\n.SH BODY\n");
735        for index in 0..1_024 {
736            writeln!(&mut source, ".it 100000 trap-{index}").expect("write test trap");
737        }
738        source.push_str(".SH TAIL\nretained tail marker\n");
739        let report = Parser::default()
740            .parse_bytes("traps.1", source.as_bytes())
741            .expect("replacing input traps must retain a finite parse");
742        let mut visible = Vec::new();
743        collect_visible_text(&report.document.root, &mut visible);
744        assert!(visible.join(" ").contains("retained tail marker"));
745    }
746
747    #[test]
748    fn parser_sessions_reset_unfinished_roff_requests() {
749        let parser = Parser::default();
750        for round in 0..32 {
751            parser
752                .parse_bytes(
753                    format!("unfinished-trap-{round}.1"),
754                    b".TH UNFINISHED-TRAP 1\n.it 2 br\n",
755                )
756                .expect("parse page ending with an armed input trap");
757            parser
758                .parse_bytes(
759                    format!("unfinished-center-{round}.1"),
760                    b".TH UNFINISHED-CENTER 1\n.ce 2\nonly-one-line\n",
761                )
762                .expect("parse page ending with an active centering request");
763            let next = parser
764                .parse_bytes(
765                    format!("clean-session-{round}.1"),
766                    b".TH CLEAN-SESSION 1\n.SH NAME\nclean-session \\- independent state\n",
767                )
768                .expect("subsequent parser session must remain independent");
769            assert_eq!(
770                next.document.metadata.title.as_deref(),
771                Some("CLEAN-SESSION")
772            );
773        }
774    }
775
776    #[test]
777    fn concurrent_callers_keep_thread_local_parser_state_isolated() {
778        const WORKERS: usize = 8;
779        const ROUNDS: usize = 16;
780
781        let start = Arc::new(Barrier::new(WORKERS));
782        let workers: Vec<_> = (0..WORKERS)
783            .map(|worker| {
784                let start = Arc::clone(&start);
785                std::thread::spawn(move || {
786                    start.wait();
787                    for round in 0..ROUNDS {
788                        let title = format!("TLS-{worker}-{round}");
789                        let source = format!(
790                            ".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"
791                        );
792                        let report = Parser::default()
793                            .parse_bytes(format!("tls-{worker}-{round}.1"), source.as_bytes())
794                            .expect("concurrent memory parse must succeed");
795                        assert_eq!(report.document.metadata.title.as_deref(), Some(title.as_str()));
796                        let name = format!("tls-{worker}-{round}");
797                        assert_eq!(report.document.metadata.name.as_deref(), Some(name.as_str()));
798                    }
799                })
800            })
801            .collect();
802        for worker in workers {
803            worker.join().expect("parser worker must not panic");
804        }
805    }
806
807    #[test]
808    fn explicit_input_format_overrides_detection_without_changing_parse_options() {
809        let options = ParseOptions::default();
810        let man = Parser::new(options.clone()).with_input_format(InputFormat::Man);
811        let mdoc = Parser::new(options.clone()).with_input_format(InputFormat::Mdoc);
812
813        assert_eq!(man.options(), &options);
814        assert_eq!(mdoc.options(), &options);
815        assert_eq!(man.input_format(), InputFormat::Man);
816        assert_eq!(mdoc.input_format(), InputFormat::Mdoc);
817        assert_eq!(
818            man.parse_bytes("forced-man.1", b"plain input\n")
819                .expect("force man parser")
820                .document
821                .macro_set,
822            MacroSet::Man
823        );
824        assert_eq!(
825            mdoc.parse_bytes("forced-mdoc.1", b"plain input\n")
826                .expect("force mdoc parser")
827                .document
828                .macro_set,
829            MacroSet::Mdoc
830        );
831    }
832
833    #[test]
834    fn source_bundle_normalizes_current_directory_and_resolves_same_directory_includes() {
835        let mut bundle = SourceBundle::new();
836        bundle
837            .insert("man1/alias.1", b".so man1/redirect.1\n".to_vec())
838            .expect("insert root source");
839        bundle
840            .insert("man1/redirect.1", b".so ./target.1\n".to_vec())
841            .expect("insert redirect source");
842        bundle
843            .insert(
844                "man1/target.1",
845                b".TH BUNDLE-TARGET 1\n.SH NAME\nbundle-target \\- virtual source\n".to_vec(),
846            )
847            .expect("insert target source");
848
849        let report = Parser::default()
850            .parse_bundle("man1/alias.1", &bundle)
851            .expect("parse virtual source tree");
852        assert_eq!(
853            report.document.metadata.title.as_deref(),
854            Some("BUNDLE-TARGET")
855        );
856    }
857
858    #[test]
859    fn source_bundle_missing_include_is_diagnostic_not_a_host_lookup() {
860        let missing = format!("mant-bundle-missing-{}.1", process::id());
861        let mut bundle = SourceBundle::new();
862        bundle
863            .insert(
864                "man1/root.1",
865                format!(".TH BUNDLE-ROOT 1\n.SH NAME\nbundle-root \\- isolated\n.so {missing}\n")
866                    .into_bytes(),
867            )
868            .expect("insert isolated root");
869
870        let report = Parser::default()
871            .parse_bundle("man1/root.1", &bundle)
872            .expect("missing include degrades to a diagnostic");
873        assert_eq!(
874            report.document.metadata.title.as_deref(),
875            Some("BUNDLE-ROOT")
876        );
877        assert!(
878            report
879                .diagnostics
880                .iter()
881                .any(|diagnostic| diagnostic.message.contains(&missing)),
882            "missing bundle source must be reported: {:?}",
883            report.diagnostics
884        );
885    }
886
887    #[test]
888    fn concurrent_source_bundles_keep_virtual_trees_isolated() {
889        const WORKERS: usize = 8;
890        let start = Arc::new(Barrier::new(WORKERS));
891        let workers: Vec<_> = (0..WORKERS)
892            .map(|worker| {
893                let start = Arc::clone(&start);
894                std::thread::spawn(move || {
895                    let title = format!("BUNDLE-{worker}");
896                    let mut bundle = SourceBundle::new();
897                    bundle
898                        .insert("man1/alias.1", b".so target.1\n".to_vec())
899                        .expect("insert alias");
900                    bundle
901                        .insert(
902                            "man1/target.1",
903                            format!(".TH {title} 1\n.SH NAME\nbundle-{worker} \\- isolated\n")
904                                .into_bytes(),
905                        )
906                        .expect("insert worker target");
907                    start.wait();
908                    for _ in 0..16 {
909                        let report = Parser::default()
910                            .parse_bundle("man1/alias.1", &bundle)
911                            .expect("parse concurrent bundle");
912                        assert_eq!(
913                            report.document.metadata.title.as_deref(),
914                            Some(title.as_str())
915                        );
916                    }
917                })
918            })
919            .collect();
920        for worker in workers {
921            worker.join().expect("bundle worker must not panic");
922        }
923    }
924
925    #[cfg(unix)]
926    #[test]
927    fn concurrent_source_tree_includes_keep_each_root_isolated() {
928        const WORKERS: usize = 8;
929
930        let root = std::env::temp_dir().join(format!(
931            "libmandoc-rs-thread-local-includes-{}",
932            process::id()
933        ));
934        let aliases: Vec<_> = (0..WORKERS)
935            .map(|worker| {
936                let tree = root.join(format!("tree-{worker}")).join("man1");
937                fs::create_dir_all(&tree).expect("create isolated manual tree");
938                fs::write(
939                    tree.join("target.1"),
940                    format!(
941                        ".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"
942                    ),
943                )
944                .expect("write included manual source");
945                let alias = tree.join("alias.1");
946                fs::write(&alias, ".so target.1\n").expect("write manual redirect");
947                alias
948            })
949            .collect();
950
951        let start = Arc::new(Barrier::new(WORKERS));
952        let workers: Vec<_> = aliases
953            .into_iter()
954            .enumerate()
955            .map(|(worker, alias)| {
956                let start = Arc::clone(&start);
957                std::thread::spawn(move || {
958                    start.wait();
959                    let document = parse_file(&alias, true)
960                        .expect("concurrent source-tree include must succeed");
961                    assert_eq!(
962                        document.metadata.title.as_deref(),
963                        Some(format!("TLS-INCLUDE-{worker}").as_str())
964                    );
965                })
966            })
967            .collect();
968        for worker in workers {
969            worker.join().expect("include worker must not panic");
970        }
971        fs::remove_dir_all(root).expect("remove isolated manual trees");
972    }
973
974    #[cfg(unix)]
975    #[test]
976    fn source_relative_includes_do_not_change_process_cwd() {
977        let root =
978            std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
979        fs::create_dir_all(&root).expect("create temporary manual tree");
980        let target = root.join("minimal-mdoc.1");
981        fs::write(
982            &target,
983            ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
984        )
985        .expect("write included source");
986        let alias = root.join("alias-mdoc.1");
987        fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
988        let cwd = std::env::current_dir().expect("current directory before parse");
989
990        let document = parse_file(&alias, true).expect("resolve source-relative include");
991        fs::remove_dir_all(root).expect("remove temporary manual tree");
992
993        assert_eq!(document.macro_set, MacroSet::Mdoc);
994        assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
995        assert_eq!(
996            std::env::current_dir().expect("current directory after parse"),
997            cwd
998        );
999    }
1000
1001    #[test]
1002    fn parser_accepts_owned_bytes_and_detects_zstd_frames() {
1003        let source = b".TH BYTES 1\n.SH NAME\nbytes \\- parser input\n";
1004        let plain = Parser::default()
1005            .parse_bytes("memory.1", source)
1006            .expect("parse plain byte input");
1007        assert_eq!(plain.document.metadata.title.as_deref(), Some("BYTES"));
1008
1009        let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
1010        let zstd = Parser::default()
1011            .parse_bytes("memory.1", &compressed)
1012            .expect("detect and parse zstd byte input");
1013        assert_eq!(zstd.document.metadata.title.as_deref(), Some("BYTES"));
1014    }
1015
1016    #[test]
1017    fn parser_only_expands_includes_when_policy_allows_a_root() {
1018        let base = std::env::temp_dir().join(format!(
1019            "libmandoc-rs-explicit-include-root-{}",
1020            process::id()
1021        ));
1022        let includes = base.join("includes");
1023        fs::create_dir_all(&includes).expect("create explicit include root");
1024        fs::write(
1025            includes.join("target.1"),
1026            ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
1027        )
1028        .expect("write included source");
1029        let alias = base.join("alias.1");
1030        fs::write(&alias, ".so target.1\n").expect("write alias source");
1031
1032        let denied = Parser::default()
1033            .parse_file(&alias)
1034            .expect("parse alias without include expansion");
1035        let expanded = Parser::new(ParseOptions {
1036            includes: IncludePolicy::Root(includes),
1037            compression: Compression::Auto,
1038        })
1039        .parse_file(&alias)
1040        .expect("resolve alias against explicit root");
1041        fs::remove_dir_all(base).expect("remove temporary manual tree");
1042
1043        assert_ne!(
1044            denied.document.metadata.title.as_deref(),
1045            Some("EXPLICIT-ROOT")
1046        );
1047        assert_eq!(
1048            expanded.document.metadata.title.as_deref(),
1049            Some("EXPLICIT-ROOT")
1050        );
1051    }
1052
1053    #[test]
1054    fn explicit_root_resolves_compressed_includes_beside_the_source() {
1055        use std::io::Write;
1056
1057        use flate2::{Compression as GzipCompression, write::GzEncoder};
1058
1059        let root = std::env::temp_dir().join(format!(
1060            "libmandoc-rs-compressed-relative-include-{}",
1061            process::id()
1062        ));
1063        let man1 = root.join("man1");
1064        fs::create_dir_all(&man1).expect("create explicit manual section");
1065        let mut target = GzEncoder::new(Vec::new(), GzipCompression::fast());
1066        target
1067            .write_all(b".SH INCLUDED\ncompressed relative content\n")
1068            .expect("compress included source");
1069        fs::write(
1070            man1.join("target.1.gz"),
1071            target.finish().expect("finish included source"),
1072        )
1073        .expect("write compressed included source");
1074        let mut explicit = GzEncoder::new(Vec::new(), GzipCompression::fast());
1075        explicit
1076            .write_all(b".SH EXPLICIT\nexplicit compressed content\n")
1077            .expect("compress explicitly named include");
1078        fs::write(
1079            man1.join("explicit.1.gz"),
1080            explicit.finish().expect("finish explicit include"),
1081        )
1082        .expect("write explicitly named compressed include");
1083        let source = man1.join("source.1.gz");
1084        let mut source_bytes = GzEncoder::new(Vec::new(), GzipCompression::fast());
1085        source_bytes
1086            .write_all(
1087                b".TH SOURCE 1\n.SH NAME\nsource \\- include fixture\n.so target.1\n.so explicit.1.gz\n",
1088            )
1089            .expect("compress source manual");
1090        fs::write(
1091            &source,
1092            source_bytes.finish().expect("finish source manual"),
1093        )
1094        .expect("write source manual");
1095
1096        let report = Parser::new(ParseOptions {
1097            includes: IncludePolicy::Root(root.clone()),
1098            compression: Compression::Auto,
1099        })
1100        .parse_file(&source)
1101        .expect("resolve compressed include beside source");
1102        fs::remove_dir_all(root).expect("remove temporary manual tree");
1103
1104        let mut visible = Vec::new();
1105        collect_visible_text(&report.document.root, &mut visible);
1106        assert!(visible.contains(&"compressed relative content"));
1107        assert!(visible.contains(&"explicit compressed content"));
1108        assert!(
1109            report
1110                .diagnostics
1111                .iter()
1112                .all(|diagnostic| { !diagnostic.message.contains(".so request failed") })
1113        );
1114    }
1115
1116    #[test]
1117    fn explicit_include_root_does_not_fall_back_to_process_cwd() {
1118        let identifier = format!("libmandoc-rs-ambient-{}", process::id());
1119        let cwd_target = std::env::current_dir()
1120            .expect("read test cwd")
1121            .join(format!("{identifier}.1"));
1122        fs::write(
1123            &cwd_target,
1124            ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
1125        )
1126        .expect("write ambient source");
1127
1128        let base = std::env::temp_dir().join(format!("{identifier}-root"));
1129        fs::create_dir_all(&base).expect("create empty include root");
1130        let alias = base.join("alias.1");
1131        fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");
1132
1133        let result = Parser::new(ParseOptions {
1134            includes: IncludePolicy::Root(base.clone()),
1135            compression: Compression::Auto,
1136        })
1137        .parse_file(&alias);
1138        fs::remove_file(cwd_target).expect("remove ambient source");
1139        fs::remove_dir_all(base).expect("remove temporary manual tree");
1140
1141        match result {
1142            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
1143            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
1144        }
1145    }
1146
1147    #[cfg(windows)]
1148    #[test]
1149    fn windows_relative_source_paths_resolve_beside_a_relative_root() {
1150        let root = std::path::PathBuf::from("target").join(format!(
1151            "libmandoc-rs-relative-windows-root-{}",
1152            process::id()
1153        ));
1154        let section = root.join("man1");
1155        fs::create_dir_all(&section).expect("create relative Windows root");
1156        fs::write(
1157            section.join("target.1"),
1158            ".TH RELATIVE-WINDOWS-ROOT 1\n.SH NAME\nrelative-root \\- included\n",
1159        )
1160        .expect("write relative Windows include target");
1161        let alias = section.join("alias.1");
1162        fs::write(&alias, ".so target.1\n").expect("write relative Windows alias");
1163
1164        let report = Parser::new(ParseOptions {
1165            includes: IncludePolicy::Root(root.clone()),
1166            compression: Compression::Plain,
1167        })
1168        .parse_file(&alias)
1169        .expect("resolve beside a relative source below a relative root");
1170        fs::remove_dir_all(root).expect("remove relative Windows root");
1171
1172        assert_eq!(
1173            report.document.metadata.title.as_deref(),
1174            Some("RELATIVE-WINDOWS-ROOT")
1175        );
1176        assert!(
1177            report
1178                .diagnostics
1179                .iter()
1180                .all(|diagnostic| !diagnostic.message.contains(".so request failed"))
1181        );
1182    }
1183
1184    #[cfg(windows)]
1185    #[test]
1186    fn windows_source_paths_resolve_beside_a_differently_cased_root() {
1187        let root =
1188            std::env::temp_dir().join(format!("libmandoc-rs-cased-windows-root-{}", process::id()));
1189        let section = root.join("man1");
1190        fs::create_dir_all(&section).expect("create cased Windows root");
1191        fs::write(
1192            section.join("target.1"),
1193            ".TH CASED-WINDOWS-ROOT 1\n.SH NAME\ncased-root \\- included\n",
1194        )
1195        .expect("write cased include target");
1196        let alias = section.join("alias.1");
1197        fs::write(&alias, ".so ./target.1\n").expect("write cased alias");
1198        let differently_cased_root = std::path::PathBuf::from(
1199            root.to_string_lossy()
1200                .chars()
1201                .map(|character| {
1202                    if character.is_ascii_lowercase() {
1203                        character.to_ascii_uppercase()
1204                    } else {
1205                        character.to_ascii_lowercase()
1206                    }
1207                })
1208                .collect::<String>(),
1209        );
1210
1211        let report = Parser::new(ParseOptions {
1212            includes: IncludePolicy::Root(differently_cased_root),
1213            compression: Compression::Plain,
1214        })
1215        .parse_file(&alias)
1216        .expect("resolve beside a source through a differently cased root");
1217        fs::remove_dir_all(root).expect("remove cased Windows root");
1218
1219        assert_eq!(
1220            report.document.metadata.title.as_deref(),
1221            Some("CASED-WINDOWS-ROOT"),
1222            "diagnostics: {:#?}",
1223            report.diagnostics
1224        );
1225    }
1226
1227    #[cfg(unix)]
1228    #[test]
1229    fn explicit_include_root_rejects_linked_target_files() {
1230        use std::os::unix::fs::symlink;
1231
1232        let base = std::env::temp_dir().join(format!(
1233            "libmandoc-rs-linked-include-target-{}",
1234            process::id()
1235        ));
1236        let includes = base.join("includes");
1237        fs::create_dir_all(&includes).expect("create explicit include root");
1238        let outside = base.join("outside.1");
1239        fs::write(
1240            &outside,
1241            ".TH OUTSIDE 1\n.SH NAME\noutside \\- must not be included\n",
1242        )
1243        .expect("write outside target");
1244        symlink(&outside, includes.join("target.1")).expect("link target outside root");
1245        let alias = base.join("alias.1");
1246        fs::write(&alias, ".so target.1\n").expect("write alias source");
1247
1248        let result = Parser::new(ParseOptions {
1249            includes: IncludePolicy::Root(includes),
1250            compression: Compression::Auto,
1251        })
1252        .parse_file(&alias);
1253        fs::remove_dir_all(base).expect("remove temporary manual tree");
1254
1255        match result {
1256            Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("OUTSIDE")),
1257            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
1258        }
1259    }
1260
1261    #[cfg(unix)]
1262    #[test]
1263    fn explicit_include_root_rejects_linked_intermediate_directories() {
1264        use std::os::unix::fs::symlink;
1265
1266        let base = std::env::temp_dir().join(format!(
1267            "libmandoc-rs-linked-include-directory-{}",
1268            process::id()
1269        ));
1270        let includes = base.join("includes");
1271        let outside = base.join("outside");
1272        fs::create_dir_all(&includes).expect("create explicit include root");
1273        fs::create_dir_all(&outside).expect("create outside directory");
1274        fs::write(
1275            outside.join("target.1"),
1276            ".TH OUTSIDE-DIR 1\n.SH NAME\noutside-dir \\- must not be included\n",
1277        )
1278        .expect("write outside target");
1279        fs::write(outside.join("alias.1"), ".so target.1\n").expect("write alias source");
1280        symlink(&outside, includes.join("linked")).expect("link directory outside root");
1281        let alias = includes.join("linked/alias.1");
1282
1283        let result = Parser::new(ParseOptions {
1284            includes: IncludePolicy::Root(includes),
1285            compression: Compression::Auto,
1286        })
1287        .parse_file(&alias);
1288        fs::remove_dir_all(base).expect("remove temporary manual tree");
1289
1290        match result {
1291            Ok(report) => assert_ne!(
1292                report.document.metadata.title.as_deref(),
1293                Some("OUTSIDE-DIR")
1294            ),
1295            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
1296        }
1297    }
1298
1299    #[cfg(windows)]
1300    #[test]
1301    fn explicit_include_root_rejects_windows_reparse_targets() {
1302        use std::os::windows::fs::symlink_file;
1303
1304        let base = std::env::temp_dir().join(format!(
1305            "libmandoc-rs-windows-linked-include-target-{}",
1306            process::id()
1307        ));
1308        let includes = base.join("includes");
1309        fs::create_dir_all(&includes).expect("create explicit include root");
1310        let target = includes.join("real.1");
1311        fs::write(
1312            &target,
1313            ".TH REPARSE-TARGET 1\n.SH NAME\nreparse-target \\- must not be included\n",
1314        )
1315        .expect("write in-root target");
1316        if let Err(error) = symlink_file(&target, includes.join("target.1")) {
1317            let privilege_not_held = error
1318                .raw_os_error()
1319                .and_then(|code| u32::try_from(code).ok())
1320                == Some(ERROR_PRIVILEGE_NOT_HELD);
1321            if error.kind() == std::io::ErrorKind::PermissionDenied || privilege_not_held {
1322                fs::remove_dir_all(base).expect("remove skipped reparse fixture");
1323                return;
1324            }
1325            panic!("create Windows file link: {error}");
1326        }
1327        let alias = base.join("alias.1");
1328        fs::write(&alias, ".so target.1\n").expect("write alias source");
1329
1330        let result = Parser::new(ParseOptions {
1331            includes: IncludePolicy::Root(includes),
1332            compression: Compression::Auto,
1333        })
1334        .parse_file(&alias);
1335        fs::remove_dir_all(base).expect("remove temporary manual tree");
1336
1337        match result {
1338            Ok(report) => {
1339                assert_ne!(
1340                    report.document.metadata.title.as_deref(),
1341                    Some("REPARSE-TARGET")
1342                );
1343                assert!(
1344                    report
1345                        .diagnostics
1346                        .iter()
1347                        .any(|diagnostic| diagnostic.message.contains(".so request failed"))
1348                );
1349            }
1350            Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
1351        }
1352    }
1353
1354    #[cfg(windows)]
1355    #[test]
1356    fn explicit_include_root_rejects_windows_path_namespaces() {
1357        let root = std::env::temp_dir().join(format!(
1358            "libmandoc-rs-windows-path-namespace-{}",
1359            process::id()
1360        ));
1361        fs::create_dir_all(&root).expect("create explicit include root");
1362        for target in [
1363            "C:/outside.1",
1364            "target.1:stream",
1365            r"\\server\share\outside.1",
1366        ] {
1367            let report = Parser::new(ParseOptions {
1368                includes: IncludePolicy::Root(root.clone()),
1369                compression: Compression::Plain,
1370            })
1371            .parse_bytes("alias.1", format!(".so {target}\n").as_bytes())
1372            .expect("return a finite document for a denied include");
1373            assert!(
1374                report
1375                    .diagnostics
1376                    .iter()
1377                    .any(|diagnostic| diagnostic.message.contains(".so request failed")),
1378                "denied Windows namespace must remain observable: {target}"
1379            );
1380        }
1381        fs::remove_dir_all(root).expect("remove explicit include root");
1382    }
1383
1384    #[cfg(windows)]
1385    #[test]
1386    fn windows_explicit_root_supports_unicode_paths_and_concurrent_sessions() {
1387        const WORKERS: usize = 8;
1388
1389        let base =
1390            std::env::temp_dir().join(format!("libmandoc-rs-windows-root-日本-{}", process::id()));
1391        let roots = (0..WORKERS)
1392            .map(|worker| {
1393                let root = base.join(format!("文档-{worker}"));
1394                let section = root.join("章节");
1395                fs::create_dir_all(&section).expect("create Unicode include root");
1396                fs::write(
1397                    section.join("target.1"),
1398                    format!(".TH WINDOWS-ROOT-{worker} 1\n.SH NAME\nroot-{worker} \\- isolated\n"),
1399                )
1400                .expect("write isolated include target");
1401                (root, section.join("alias.1"))
1402            })
1403            .collect::<Vec<_>>();
1404        let start = Arc::new(Barrier::new(WORKERS));
1405        let workers = roots
1406            .into_iter()
1407            .enumerate()
1408            .map(|(worker, (root, alias))| {
1409                let start = Arc::clone(&start);
1410                std::thread::spawn(move || {
1411                    start.wait();
1412                    for _ in 0..100 {
1413                        let report = Parser::new(ParseOptions {
1414                            includes: IncludePolicy::Root(root.clone()),
1415                            compression: Compression::Plain,
1416                        })
1417                        .parse_bytes(&alias, b".so target.1\n")
1418                        .expect("resolve isolated Windows root");
1419                        assert_eq!(
1420                            report.document.metadata.title.as_deref(),
1421                            Some(format!("WINDOWS-ROOT-{worker}").as_str())
1422                        );
1423                    }
1424                })
1425            })
1426            .collect::<Vec<_>>();
1427        for worker in workers {
1428            worker.join().expect("root resolver worker must not panic");
1429        }
1430        fs::remove_dir_all(base).expect("remove concurrent Windows roots");
1431    }
1432
1433    #[test]
1434    fn parser_returns_structured_nonfatal_diagnostics() {
1435        let report = Parser::default()
1436            .parse_bytes(
1437                "diagnostics.1",
1438                b".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
1439            )
1440            .expect("return best-effort document");
1441
1442        assert!(
1443            report
1444                .diagnostics
1445                .iter()
1446                .any(|diagnostic| diagnostic.level == super::DiagnosticLevel::Unsupported)
1447        );
1448    }
1449
1450    #[test]
1451    fn coding_declarations_never_disable_available_byte_decoding() {
1452        for declaration in ["latin-1", "ISO-8859-9"] {
1453            let mut source =
1454                format!(".\\\" -*- coding: {declaration} -*-\n.TH CD 1\n.SH BODY\nText: ")
1455                    .into_bytes();
1456            source.extend_from_slice(b"e\xf0itmen ba\xfelat\xfdr.\n");
1457            let report = Parser::default()
1458                .parse_bytes("coding.1", &source)
1459                .expect("unsupported coding declaration retains a best-effort parse");
1460            let mut visible = Vec::new();
1461            collect_visible_text(&report.document.root, &mut visible);
1462            let visible = visible.join(" ");
1463            assert!(
1464                visible.contains("e\\[u00F0]itmen ba\\[u00FE]lat\\[u00FD]r."),
1465                "{declaration}: {visible}"
1466            );
1467            assert!(!visible.contains('?'), "{declaration}: {visible}");
1468        }
1469    }
1470
1471    #[test]
1472    fn parser_decodes_truncated_utf8_tails_without_reading_past_memory_input() {
1473        for byte in [0xc2, 0xe2, 0xf0] {
1474            let mut source = b".TH TRUNCATED 1\n.SH BODY\n".to_vec();
1475            source.push(byte);
1476            let source = source.into_boxed_slice();
1477            let report = Parser::default()
1478                .parse_bytes("truncated.1", &source)
1479                .expect("truncated UTF-8 tail must retain a best-effort parse");
1480            let mut visible = Vec::new();
1481            collect_visible_text(&report.document.root, &mut visible);
1482            assert!(
1483                visible.join(" ").contains(&format!("\\[u{byte:04X}]")),
1484                "byte {byte:#x} was not preserved as Latin-1: {visible:?}"
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn infinite_while_loop_is_bounded_with_a_diagnostic() {
1491        let report = Parser::default()
1492            .parse_bytes(
1493                "loop.1",
1494                b".TH LOOP 1\n.SH BODY\n.while 1 \\{\\\nloop\n.\\}\n.SH AFTER\nretained\n",
1495            )
1496            .expect("return the finite prefix of a looping manual");
1497        let mut visible = Vec::new();
1498        collect_visible_text(&report.document.root, &mut visible);
1499
1500        assert!(
1501            report
1502                .diagnostics
1503                .iter()
1504                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1505            "loop budget must remain observable: {:?}",
1506            report.diagnostics
1507        );
1508        assert!(
1509            visible.contains(&"retained"),
1510            "parsing must continue after the bounded loop"
1511        );
1512        assert!(
1513            visible.iter().filter(|value| **value == "loop").count() <= 10_000,
1514            "the loop body must not exceed the documented budget"
1515        );
1516    }
1517
1518    #[test]
1519    fn aggregate_while_replays_are_bounded_across_statements() {
1520        let mut source =
1521            String::from(".TH AGGREGATE 1\n.SH BODY\n.de M\n.while 1 \\{\\\nreplayed\n.\\}\n..\n");
1522        for _ in 0..3 {
1523            source.push_str(".M\n");
1524        }
1525        source.push_str(".SH AFTER\nretained aggregate tail\n");
1526
1527        let report = Parser::default()
1528            .parse_bytes("aggregate.1", source.as_bytes())
1529            .expect("return the finite prefix across multiple loops");
1530        let mut visible = Vec::new();
1531        collect_visible_text(&report.document.root, &mut visible);
1532
1533        let replayed = visible.iter().filter(|value| **value == "replayed").count();
1534        assert!(
1535            replayed <= 10_003,
1536            "three loop statements must share one replay budget: {replayed}"
1537        );
1538        assert!(visible.contains(&"retained aggregate tail"));
1539        assert!(
1540            report
1541                .diagnostics
1542                .iter()
1543                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1544            "aggregate exhaustion must remain observable: {:?}",
1545            report.diagnostics
1546        );
1547    }
1548
1549    #[test]
1550    fn recursive_user_macro_retains_content_after_the_cycle() {
1551        let report = Parser::default()
1552            .parse_bytes(
1553                "recursive.7",
1554                b".TH RECUR 7\n.SH NAME\nrecur \\- x\n.de R\n.  R\n..\n.R\n.SH DESC\ntail marker ZZTAIL\n",
1555            )
1556            .expect("return the complete document around recursive macro input");
1557        let mut visible = Vec::new();
1558        collect_visible_text(&report.document.root, &mut visible);
1559
1560        assert!(
1561            report
1562                .diagnostics
1563                .iter()
1564                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1565            "recursion limit must remain observable: {:?}",
1566            report.diagnostics
1567        );
1568        let visible = visible.join(" ");
1569        assert!(visible.contains("recur"), "{visible}");
1570        assert!(visible.contains("tail marker ZZTAIL"), "{visible}");
1571    }
1572
1573    #[test]
1574    fn deeply_nested_callable_mdoc_macros_are_bounded_in_the_native_parser() {
1575        let mut source = String::from(
1576            ".Dd August 24, 2026\n.Dt DEEP-MDOC 1\n.Os\n.Sh NAME\n.Nm deep-mdoc\n.Nd bounded callable macros\n.Sh BODY\n.Op ",
1577        );
1578        for _ in 0..50_000 {
1579            source.push_str("Op ");
1580        }
1581        source.push_str("nested tail marker\n.Sh AFTER\nretained document tail\n");
1582
1583        let report = Parser::default()
1584            .parse_bytes("deep-mdoc.1", source.as_bytes())
1585            .expect("return a finite document for deeply nested callable macros");
1586        let mut visible = Vec::new();
1587        collect_visible_text(&report.document.root, &mut visible);
1588        let visible = visible.join(" ");
1589
1590        assert!(
1591            report
1592                .diagnostics
1593                .iter()
1594                .any(|diagnostic| diagnostic.message.contains("infinite loop")),
1595            "macro depth exhaustion must remain observable: {:?}",
1596            report.diagnostics
1597        );
1598        assert!(visible.contains("nested tail marker"), "{visible}");
1599        assert!(visible.contains("retained document tail"), "{visible}");
1600    }
1601
1602    #[test]
1603    fn deeply_nested_input_is_bounded_instead_of_overflowing_the_stack() {
1604        // Far more nesting than the copy cap; the parse must return a finite
1605        // tree rather than recursing without limit while copying it out.
1606        let depth = 5_000;
1607        let mut source = String::from(".TH DEEP 1\n.SH BODY\n");
1608        for _ in 0..depth {
1609            source.push_str(".RS\n");
1610        }
1611        source.push_str("deep\n");
1612
1613        let report = Parser::default()
1614            .parse_bytes("deep.1", source.as_bytes())
1615            .expect("deeply nested source parses");
1616
1617        // The owned tree stays well under the input nesting, proving the copy
1618        // stopped descending at the cap.
1619        assert!(
1620            measured_depth(&report.document.root) <= 300,
1621            "tree depth must be bounded by the copy cap"
1622        );
1623        assert!(
1624            report
1625                .diagnostics
1626                .iter()
1627                .any(|diagnostic| diagnostic.code() == Some(DiagnosticCode::SyntaxTreeDepthLimit)),
1628            "node truncation must remain observable: {:?}",
1629            report.diagnostics
1630        );
1631    }
1632
1633    #[test]
1634    fn deeply_nested_equation_is_bounded_instead_of_overflowing_the_stack() {
1635        // Braces nest eqn boxes, a recursive walk the node-copy cap never
1636        // enters: copy_equation descends box->first without limit, so a
1637        // pathologically nested equation overflows the stack while flattening
1638        // it. Each `sqrt` level emits text, so an unbounded render would grow
1639        // the string with the input depth; a bounded one plateaus at the cap.
1640        let depth = 5_000;
1641        let mut equation = String::new();
1642        for _ in 0..depth {
1643            equation.push_str("sqrt { ");
1644        }
1645        equation.push('x');
1646        for _ in 0..depth {
1647            equation.push_str(" }");
1648        }
1649        let source = format!(".TH DEEP 1\n.SH BODY\n.EQ\n{equation}\n.EN\n");
1650
1651        let report = Parser::default()
1652            .parse_bytes("deep-eqn.1", source.as_bytes())
1653            .expect("deeply nested equation parses");
1654
1655        let node = find_kind(&report.document.root, NodeKind::Equation).expect("equation node");
1656        let rendered = node.equation.as_deref().expect("equation text");
1657        // The render stopped at the cap: the flattened text is far shorter than
1658        // the ~30k chars all 5000 `sqrt` levels would emit, proving it did not
1659        // recurse through every box (and so could not overflow the stack).
1660        assert!(
1661            rendered.len() < 2_000,
1662            "equation text must be bounded by the copy cap, got {} bytes",
1663            rendered.len()
1664        );
1665        assert!(
1666            report
1667                .diagnostics
1668                .iter()
1669                .any(|diagnostic| diagnostic.code() == Some(DiagnosticCode::EquationTreeDepthLimit)),
1670            "equation truncation must remain observable: {:?}",
1671            report.diagnostics
1672        );
1673    }
1674
1675    #[cfg(feature = "serde")]
1676    #[test]
1677    fn serde_feature_round_trips_the_public_parse_report() {
1678        let report = Parser::default()
1679            .parse_bytes("serde.1", b".TH SERDE 1\n.SH NAME\nserde \\- fixture\n")
1680            .expect("parse source for serialization");
1681        let encoded = serde_json::to_string(&report).expect("serialize parse report");
1682        let decoded: super::ParseReport =
1683            serde_json::from_str(&encoded).expect("deserialize parse report");
1684
1685        assert_eq!(decoded, report);
1686    }
1687
1688    #[cfg(feature = "serde")]
1689    #[test]
1690    fn serde_diagnostics_keep_the_patch_compatible_field_shape() {
1691        let diagnostic = Diagnostic {
1692            level: DiagnosticLevel::Warning,
1693            message: crate::diagnostics::SYNTAX_TREE_DEPTH_MESSAGE.to_owned(),
1694            location: None,
1695        };
1696        let encoded = serde_json::to_value(&diagnostic).expect("serialize diagnostic");
1697
1698        assert_eq!(
1699            diagnostic.code(),
1700            Some(DiagnosticCode::SyntaxTreeDepthLimit)
1701        );
1702        assert!(encoded.get("code").is_none());
1703        assert_eq!(
1704            serde_json::from_value::<Diagnostic>(encoded).expect("deserialize diagnostic"),
1705            diagnostic
1706        );
1707    }
1708
1709    #[test]
1710    fn parser_copies_normalized_list_and_display_attributes() {
1711        let path = source_path("normalized-mandoc-session");
1712        fs::write(
1713            &path,
1714            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh ITEMS\n\
1715             .Bl -tag -compact -offset indent -width 12n\n.It item\nfirst\n.El\n\
1716             .Bd -literal -offset indent\ncode line\n.Ed\n",
1717        )
1718        .expect("write normalized mdoc source");
1719
1720        let document = parse_file(&path, false).expect("parse normalized mdoc source");
1721        fs::remove_file(path).expect("remove normalized mdoc source");
1722
1723        let list = find_macro(&document.root, "Bl").expect("normalized list node");
1724        assert_eq!(list.list_kind, Some(NormalizedListKind::Definition));
1725        assert!(list.compact);
1726        assert_eq!(list.offset.as_deref(), Some("indent"));
1727        assert_eq!(list.width.as_deref(), Some("12n"));
1728        let display = find_macro(&document.root, "Bd").expect("normalized display node");
1729        assert_eq!(display.display_kind, Some(DisplayKind::Literal));
1730        assert_eq!(display.offset.as_deref(), Some("indent"));
1731    }
1732
1733    #[test]
1734    fn parser_retains_column_list_cells() {
1735        let report = Parser::default()
1736            .parse_bytes(
1737                "columns.3",
1738                b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
1739.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
1740            )
1741            .expect("parse mdoc column list");
1742        let item = find_macro(&report.document.root, "It").expect("column item");
1743        let bodies = item
1744            .children
1745            .iter()
1746            .filter(|child| child.kind == NodeKind::Body)
1747            .collect::<Vec<_>>();
1748
1749        assert_eq!(bodies.len(), 3);
1750        assert_eq!(
1751            bodies
1752                .iter()
1753                .map(|body| {
1754                    body.children
1755                        .iter()
1756                        .flat_map(|child| child.children.iter())
1757                        .chain(body.children.iter())
1758                        .filter_map(|child| child.text.as_deref())
1759                        .collect::<Vec<_>>()
1760                })
1761                .collect::<Vec<_>>(),
1762            [
1763                vec!["CLSET_TIMEOUT"],
1764                vec!["struct timeval *"],
1765                vec!["set total timeout"],
1766            ]
1767        );
1768    }
1769
1770    #[test]
1771    fn parser_copies_normalized_font_and_author_modes() {
1772        let report = Parser::default()
1773            .parse_bytes(
1774                "normalized-modes.1",
1775                b".Dd July 19, 2026\n.Dt NORMALIZED-MODES 1\n.Os\n.Sh AUTHORS\n\
1776.An -split\n.An Alice Example\n.An -nosplit\n.An Bob Example\n\
1777.Sh DESCRIPTION\n.Bf -literal\nliteral text\n.Ef\n",
1778            )
1779            .expect("parse normalized mdoc modes");
1780
1781        let split = find_node(&report.document.root, &|node| {
1782            node.macro_name.as_deref() == Some("An") && node.author_mode == Some(AuthorMode::Split)
1783        });
1784        let no_split = find_node(&report.document.root, &|node| {
1785            node.macro_name.as_deref() == Some("An")
1786                && node.author_mode == Some(AuthorMode::NoSplit)
1787        });
1788        let font = find_macro(&report.document.root, "Bf").expect("Bf node");
1789
1790        assert!(split.is_some());
1791        assert!(no_split.is_some());
1792        assert_eq!(font.font, Some(NormalizedFont::Literal));
1793    }
1794
1795    #[test]
1796    fn parser_resolves_stateful_mdoc_enclosures_onto_each_use() {
1797        let report = Parser::default()
1798            .parse_bytes(
1799                "normalized-enclosure.1",
1800                b".Dd August 17, 2026\n.Dt ENCLOSURE 1\n.Os\n.Sh DESCRIPTION\n\
1801.Es << >>\n.En value\n",
1802            )
1803            .expect("parse stateful mdoc enclosure");
1804
1805        let enclosure = find_macro(&report.document.root, "En")
1806            .and_then(|node| node.enclosure.as_ref())
1807            .expect("resolved En delimiters");
1808        assert_eq!(enclosure.opening, "<<");
1809        assert_eq!(enclosure.closing.as_deref(), Some(">>"));
1810    }
1811
1812    #[test]
1813    fn parser_copies_table_cells_and_equation_text() {
1814        let path = source_path("structured-payload-mandoc-session");
1815        fs::write(
1816            &path,
1817            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
1818             .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
1819        )
1820        .expect("write table and equation source");
1821
1822        let document = parse_file(&path, false).expect("parse table and equation source");
1823        fs::remove_file(path).expect("remove table and equation source");
1824
1825        let table = find_kind(&document.root, NodeKind::Table).expect("table row node");
1826        assert_eq!(table.table_cells.len(), 2);
1827        assert_eq!(table.table_cells[0].text.as_deref(), Some("left"));
1828        assert_eq!(table.table_cells[1].alignment, TableAlignment::Right);
1829        let equation = find_kind(&document.root, NodeKind::Equation).expect("equation node");
1830        assert!(
1831            equation
1832                .equation
1833                .as_deref()
1834                .is_some_and(|value| value.contains('x'))
1835        );
1836    }
1837
1838    #[test]
1839    fn parser_copies_validated_same_document_navigation() {
1840        let path = source_path("navigation-mandoc-session");
1841        fs::write(
1842            &path,
1843            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh FIRST\n\
1844             See\n\
1845             .Sx TARGET\n\
1846             for details.\n\
1847             .Tg explicit-target\n\
1848             .Fl x\n\
1849             .Sh TARGET\nTarget text.\n",
1850        )
1851        .expect("write navigation mdoc source");
1852
1853        let document = parse_file(&path, false).expect("parse navigation mdoc source");
1854        fs::remove_file(path).expect("remove navigation mdoc source");
1855
1856        assert!(find_macro(&document.root, "Sx").is_some());
1857        let explicit_target = find_node(&document.root, &|node| {
1858            node.flags.deep_link_target && node.tag.as_deref() == Some("explicit-target")
1859        });
1860        let explicit_target = explicit_target.expect("Tg must annotate its resolved destination");
1861        assert!(explicit_target.flags.permalink);
1862    }
1863
1864    #[test]
1865    fn parser_normalizes_internal_sentinels_in_validated_tags() {
1866        let report = Parser::default()
1867            .parse_bytes(
1868                "tag-sentinel.1",
1869                b".TH TAG-SENTINEL 1\n.SH OPTIONS\n.TP\n\\fB\\-\\-new-window\\fR\nOpen a window.\n",
1870            )
1871            .expect("parse tagged paragraph");
1872        let tagged = find_node(&report.document.root, &|node| {
1873            node.tag
1874                .as_deref()
1875                .is_some_and(|tag| tag.contains("new-window"))
1876        });
1877
1878        assert!(
1879            tagged.is_some(),
1880            "normalized TP tag must remain addressable"
1881        );
1882        assert!(
1883            find_node(&report.document.root, &|node| {
1884                node.tag.as_deref().is_some_and(|tag| {
1885                    tag.chars()
1886                        .any(|character| ['\u{1d}', '\u{1e}', '\u{1f}'].contains(&character))
1887                })
1888            })
1889            .is_none()
1890        );
1891    }
1892}