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