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