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
39pub const LIBMANDOC_VERSION: &str = "1.14.6";
41
42struct 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(§ion).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(§ion).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(§ion).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 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 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 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 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}