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