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 use super::{
39 Compression, DisplayKind, Document, IncludePolicy, MacroSet, Node, NodeKind,
40 NormalizedListKind, ParseError, ParseOptions, Parser, TableAlignment,
41 };
42
43 fn source_path(label: &str) -> std::path::PathBuf {
44 std::env::temp_dir().join(format!("mant-{label}-{}.1", process::id()))
45 }
46
47 fn measured_depth(node: &Node) -> usize {
48 1 + node.children.iter().map(measured_depth).max().unwrap_or(0)
49 }
50
51 fn parse_file(path: &std::path::Path, allow_includes: bool) -> Result<Document, ParseError> {
52 Parser::new(ParseOptions {
53 includes: if allow_includes {
54 IncludePolicy::SourceTree
55 } else {
56 IncludePolicy::Deny
57 },
58 compression: Compression::Auto,
59 })
60 .parse_file(path)
61 .map(|report| report.document)
62 }
63
64 fn find_macro<'a>(node: &'a Node, name: &str) -> Option<&'a Node> {
65 (node.macro_name.as_deref() == Some(name))
66 .then_some(node)
67 .or_else(|| {
68 node.children
69 .iter()
70 .find_map(|child| find_macro(child, name))
71 })
72 }
73
74 fn find_kind(node: &Node, kind: NodeKind) -> Option<&Node> {
75 (node.kind == kind).then_some(node).or_else(|| {
76 node.children
77 .iter()
78 .find_map(|child| find_kind(child, kind))
79 })
80 }
81
82 fn find_node<'a>(node: &'a Node, predicate: &impl Fn(&Node) -> bool) -> Option<&'a Node> {
83 predicate(node).then_some(node).or_else(|| {
84 node.children
85 .iter()
86 .find_map(|child| find_node(child, predicate))
87 })
88 }
89
90 #[test]
91 fn upstream_version_is_pinned() {
92 assert_eq!(super::LIBMANDOC_VERSION, "1.14.6");
93 }
94
95 #[test]
96 fn parser_session_returns_an_owned_man_tree() {
97 let path = source_path("mandoc-session");
98 fs::write(
99 &path,
100 ".TH MANT 1 \"2026-07-19\"\n.SH NAME\nmant \\- manual viewer\n",
101 )
102 .expect("write temporary manual source");
103
104 let document = parse_file(&path, false).expect("parse temporary manual");
105 fs::remove_file(path).expect("remove temporary manual source");
106
107 assert_eq!(document.macro_set, MacroSet::Man);
108 assert_eq!(document.metadata.title.as_deref(), Some("MANT"));
109 assert_eq!(document.metadata.section.as_deref(), Some("1"));
110 assert!(document.metadata.has_body);
111 assert_eq!(document.root.kind, NodeKind::Root);
112 assert!(!document.root.children.is_empty());
113 }
114
115 #[test]
116 fn parser_decompresses_zstd_sources_before_calling_libmandoc() {
117 let path = source_path("zstd-mandoc-session").with_extension("1.zst");
118 let source = b".TH ZSTD-MANT 1 \"2026-07-20\"\n.SH NAME\nzstd-mant \\- compressed manual\n";
119 let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
120 fs::write(&path, compressed).expect("write compressed manual source");
121
122 let report = Parser::default()
123 .parse_file(&path)
124 .expect("parse zstd manual");
125 fs::remove_file(path).expect("remove compressed manual source");
126
127 assert!(report.diagnostics.is_empty());
128 let document = report.document;
129 assert_eq!(document.macro_set, MacroSet::Man);
130 assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-MANT"));
131 assert_eq!(document.metadata.section.as_deref(), Some("1"));
132 assert!(document.metadata.has_body);
133 }
134
135 #[test]
136 fn invalid_zstd_sources_fail_before_reaching_libmandoc() {
137 let path = source_path("invalid-zstd-mandoc-session").with_extension("1.zst");
138 fs::write(&path, b"not a zstd frame").expect("write invalid compressed source");
139
140 let error = parse_file(&path, false).expect_err("invalid zstd source must fail");
141 fs::remove_file(path).expect("remove invalid compressed source");
142
143 assert!(
144 error
145 .message
146 .starts_with("could not decompress zstd manual source:")
147 );
148 assert_eq!(error.kind, super::ParseErrorKind::Decompression);
149 assert!(!error.message.contains("unsupported control character"));
150 }
151
152 #[test]
153 fn zstd_sources_keep_their_original_include_root() {
154 let root = std::env::temp_dir().join(format!(
155 "mant-zstd-include-mandoc-session-{}",
156 process::id()
157 ));
158 let man1 = root.join("man1");
159 fs::create_dir_all(&man1).expect("create temporary manual tree");
160 let target = man1.join("target.1");
161 fs::write(
162 &target,
163 ".TH ZSTD-INCLUDE 1\n.SH NAME\nzstd-include \\- included manual\n",
164 )
165 .expect("write included manual");
166 let alias = man1.join("alias.1.zst");
167 let compressed =
168 zstd::stream::encode_all(b".so man1/target.1\n".as_slice(), 1).expect("compress alias");
169 fs::write(&alias, compressed).expect("write compressed alias");
170
171 let document = parse_file(&alias, true).expect("resolve include from zstd source");
172 fs::remove_dir_all(root).expect("remove temporary manual tree");
173
174 assert_eq!(document.macro_set, MacroSet::Man);
175 assert_eq!(document.metadata.title.as_deref(), Some("ZSTD-INCLUDE"));
176 assert!(document.metadata.has_body);
177 }
178
179 #[test]
180 fn parser_preserves_same_line_layout_and_next_line_content_roles() {
181 let path = source_path("line-role-mandoc-session");
182 fs::write(
183 &path,
184 ".TH LINE-ROLE 1\n.SH EXAMPLES\n.TP \\w'man\\ 'u\n.BI man \\ ls\nBody.\n",
185 )
186 .expect("write tagged paragraph source");
187
188 let document = parse_file(&path, false).expect("parse tagged paragraph source");
189 fs::remove_file(path).expect("remove tagged paragraph source");
190
191 let tagged_paragraph = find_macro(&document.root, "TP").expect("TP block");
192 let head = tagged_paragraph
193 .children
194 .iter()
195 .find(|child| child.kind == NodeKind::Head)
196 .expect("TP head");
197 assert_eq!(head.children[0].text.as_deref(), Some("96u"));
198 assert!(!head.children[0].flags.line_start);
199 assert_eq!(head.children[1].macro_name.as_deref(), Some("BI"));
200 assert!(head.children[1].flags.line_start);
201 }
202
203 #[test]
204 fn parser_session_reports_file_errors_as_values() {
205 let path = source_path("missing-mandoc-session");
206 let error = parse_file(&path, false).expect_err("missing source must fail");
207
208 assert_eq!(error.path, path);
209 assert!(!error.message.is_empty());
210 }
211
212 #[test]
213 fn concurrent_callers_are_serialized_around_libmandoc_globals() {
214 let path = source_path("concurrent-mandoc-session");
215 fs::write(&path, ".TH THREADS 1\n.SH NAME\nthreads \\- test\n")
216 .expect("write temporary manual source");
217
218 let workers: Vec<_> = (0..4)
219 .map(|_| {
220 let path = path.clone();
221 std::thread::spawn(move || parse_file(&path, false))
222 })
223 .collect();
224 for worker in workers {
225 let document = worker
226 .join()
227 .expect("parser worker must not panic")
228 .expect("concurrent parse must succeed");
229 assert_eq!(document.metadata.title.as_deref(), Some("THREADS"));
230 }
231
232 fs::remove_file(path).expect("remove temporary manual source");
233 }
234
235 #[test]
236 fn source_relative_includes_do_not_change_process_cwd() {
237 let root =
238 std::env::temp_dir().join(format!("libmandoc-rs-relative-include-{}", process::id()));
239 fs::create_dir_all(&root).expect("create temporary manual tree");
240 let target = root.join("minimal-mdoc.1");
241 fs::write(
242 &target,
243 ".Dd July 19, 2026\n.Dt INCLUDE-FIXTURE 1\n.Os\n.Sh NAME\ninclude-fixture\n",
244 )
245 .expect("write included source");
246 let alias = root.join("alias-mdoc.1");
247 fs::write(&alias, ".so minimal-mdoc.1\n").expect("write alias source");
248 let cwd = std::env::current_dir().expect("current directory before parse");
249
250 let document = parse_file(&alias, true).expect("resolve source-relative include");
251 fs::remove_dir_all(root).expect("remove temporary manual tree");
252
253 assert_eq!(document.macro_set, MacroSet::Mdoc);
254 assert_eq!(document.metadata.title.as_deref(), Some("INCLUDE-FIXTURE"));
255 assert_eq!(
256 std::env::current_dir().expect("current directory after parse"),
257 cwd
258 );
259 }
260
261 #[test]
262 fn parser_accepts_owned_bytes_and_detects_zstd_frames() {
263 let source = b".TH BYTES 1\n.SH NAME\nbytes \\- parser input\n";
264 let plain = Parser::default()
265 .parse_bytes("memory.1", source)
266 .expect("parse plain byte input");
267 assert_eq!(plain.document.metadata.title.as_deref(), Some("BYTES"));
268
269 let compressed = zstd::stream::encode_all(source.as_slice(), 1).expect("compress source");
270 let zstd = Parser::default()
271 .parse_bytes("memory.1", &compressed)
272 .expect("detect and parse zstd byte input");
273 assert_eq!(zstd.document.metadata.title.as_deref(), Some("BYTES"));
274 }
275
276 #[test]
277 fn parser_only_expands_includes_when_policy_allows_a_root() {
278 let base = std::env::temp_dir().join(format!(
279 "libmandoc-rs-explicit-include-root-{}",
280 process::id()
281 ));
282 let includes = base.join("includes");
283 fs::create_dir_all(&includes).expect("create explicit include root");
284 fs::write(
285 includes.join("target.1"),
286 ".TH EXPLICIT-ROOT 1\n.SH NAME\nexplicit-root \\- include fixture\n",
287 )
288 .expect("write included source");
289 let alias = base.join("alias.1");
290 fs::write(&alias, ".so target.1\n").expect("write alias source");
291
292 let denied = Parser::default()
293 .parse_file(&alias)
294 .expect("parse alias without include expansion");
295 let expanded = Parser::new(ParseOptions {
296 includes: IncludePolicy::Root(includes),
297 compression: Compression::Auto,
298 })
299 .parse_file(&alias)
300 .expect("resolve alias against explicit root");
301 fs::remove_dir_all(base).expect("remove temporary manual tree");
302
303 assert_ne!(
304 denied.document.metadata.title.as_deref(),
305 Some("EXPLICIT-ROOT")
306 );
307 assert_eq!(
308 expanded.document.metadata.title.as_deref(),
309 Some("EXPLICIT-ROOT")
310 );
311 }
312
313 #[test]
314 fn explicit_include_root_does_not_fall_back_to_process_cwd() {
315 let identifier = format!("libmandoc-rs-ambient-{}", process::id());
316 let cwd_target = std::env::current_dir()
317 .expect("read test cwd")
318 .join(format!("{identifier}.1"));
319 fs::write(
320 &cwd_target,
321 ".TH AMBIENT 1\n.SH NAME\nambient \\- must not be included\n",
322 )
323 .expect("write ambient source");
324
325 let base = std::env::temp_dir().join(format!("{identifier}-root"));
326 fs::create_dir_all(&base).expect("create empty include root");
327 let alias = base.join("alias.1");
328 fs::write(&alias, format!(".so {identifier}.1\n")).expect("write alias source");
329
330 let result = Parser::new(ParseOptions {
331 includes: IncludePolicy::Root(base.clone()),
332 compression: Compression::Auto,
333 })
334 .parse_file(&alias);
335 fs::remove_file(cwd_target).expect("remove ambient source");
336 fs::remove_dir_all(base).expect("remove temporary manual tree");
337
338 match result {
339 Ok(report) => assert_ne!(report.document.metadata.title.as_deref(), Some("AMBIENT")),
340 Err(error) => assert_eq!(error.kind, super::ParseErrorKind::Parse),
341 }
342 }
343
344 #[test]
345 fn parser_returns_structured_nonfatal_diagnostics() {
346 let report = Parser::default()
347 .parse_bytes(
348 "diagnostics.1",
349 b".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
350 )
351 .expect("return best-effort document");
352
353 assert!(
354 report
355 .diagnostics
356 .iter()
357 .any(|diagnostic| diagnostic.level == super::DiagnosticLevel::Unsupported)
358 );
359 }
360
361 #[test]
362 fn deeply_nested_input_is_bounded_instead_of_overflowing_the_stack() {
363 let depth = 5_000;
366 let mut source = String::from(".TH DEEP 1\n.SH BODY\n");
367 for _ in 0..depth {
368 source.push_str(".RS\n");
369 }
370 source.push_str("deep\n");
371
372 let document = Parser::default()
373 .parse_bytes("deep.1", source.as_bytes())
374 .expect("deeply nested source parses")
375 .document;
376
377 assert!(
380 measured_depth(&document.root) <= 300,
381 "tree depth must be bounded by the copy cap"
382 );
383 }
384
385 #[cfg(feature = "serde")]
386 #[test]
387 fn serde_feature_round_trips_the_public_parse_report() {
388 let report = Parser::default()
389 .parse_bytes("serde.1", b".TH SERDE 1\n.SH NAME\nserde \\- fixture\n")
390 .expect("parse source for serialization");
391 let encoded = serde_json::to_string(&report).expect("serialize parse report");
392 let decoded: super::ParseReport =
393 serde_json::from_str(&encoded).expect("deserialize parse report");
394
395 assert_eq!(decoded, report);
396 }
397
398 #[test]
399 fn parser_copies_normalized_list_and_display_attributes() {
400 let path = source_path("normalized-mandoc-session");
401 fs::write(
402 &path,
403 ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh ITEMS\n\
404 .Bl -tag -compact -offset indent -width 12n\n.It item\nfirst\n.El\n\
405 .Bd -literal -offset indent\ncode line\n.Ed\n",
406 )
407 .expect("write normalized mdoc source");
408
409 let document = parse_file(&path, false).expect("parse normalized mdoc source");
410 fs::remove_file(path).expect("remove normalized mdoc source");
411
412 let list = find_macro(&document.root, "Bl").expect("normalized list node");
413 assert_eq!(list.list_kind, Some(NormalizedListKind::Definition));
414 assert!(list.compact);
415 assert_eq!(list.offset.as_deref(), Some("indent"));
416 assert_eq!(list.width.as_deref(), Some("12n"));
417 let display = find_macro(&document.root, "Bd").expect("normalized display node");
418 assert_eq!(display.display_kind, Some(DisplayKind::Literal));
419 assert_eq!(display.offset.as_deref(), Some("indent"));
420 }
421
422 #[test]
423 fn parser_copies_table_cells_and_equation_text() {
424 let path = source_path("structured-payload-mandoc-session");
425 fs::write(
426 &path,
427 ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
428 .SH EQUATION\n.EQ\nx sup 2\n.EN\n",
429 )
430 .expect("write table and equation source");
431
432 let document = parse_file(&path, false).expect("parse table and equation source");
433 fs::remove_file(path).expect("remove table and equation source");
434
435 let table = find_kind(&document.root, NodeKind::Table).expect("table row node");
436 assert_eq!(table.table_cells.len(), 2);
437 assert_eq!(table.table_cells[0].text.as_deref(), Some("left"));
438 assert_eq!(table.table_cells[1].alignment, TableAlignment::Right);
439 let equation = find_kind(&document.root, NodeKind::Equation).expect("equation node");
440 assert!(
441 equation
442 .equation
443 .as_deref()
444 .is_some_and(|value| value.contains('x'))
445 );
446 }
447
448 #[test]
449 fn parser_copies_validated_same_document_navigation() {
450 let path = source_path("navigation-mandoc-session");
451 fs::write(
452 &path,
453 ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh FIRST\n\
454 See\n\
455 .Sx TARGET\n\
456 for details.\n\
457 .Tg explicit-target\n\
458 .Fl x\n\
459 .Sh TARGET\nTarget text.\n",
460 )
461 .expect("write navigation mdoc source");
462
463 let document = parse_file(&path, false).expect("parse navigation mdoc source");
464 fs::remove_file(path).expect("remove navigation mdoc source");
465
466 assert!(find_macro(&document.root, "Sx").is_some());
467 let explicit_target = find_node(&document.root, &|node| {
468 node.flags.deep_link_target && node.tag.as_deref() == Some("explicit-target")
469 });
470 let explicit_target = explicit_target.expect("Tg must annotate its resolved destination");
471 assert!(explicit_target.flags.permalink);
472 }
473}