Skip to main content

mant_engine/mandoc/
mod.rs

1//! Lowers the owned libmandoc syntax tree into `ManT`'s stable document model.
2
3mod blocks;
4mod diagnostics;
5mod error;
6pub(crate) mod inline;
7mod layout;
8mod navigation;
9mod reference;
10mod roff_escape;
11mod source;
12mod source_lines;
13
14use std::{cell::RefCell, collections::BTreeMap, path::Path};
15
16use libmandoc_rs::{
17    Compression, Document as MandocDocument, IncludePolicy, MacroSet, Node, ParseOptions,
18    ParseReport, Parser,
19};
20use mant_ir::{
21    Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, ParserInfo, SourceFormat,
22    SourceSpan, validate_document,
23};
24
25use self::{
26    roff_escape::visible_text,
27    source::{load_manual_source, redirect_target, resolve_manual_redirects},
28    source_lines::SourceLineIndex,
29};
30use crate::ManualPage;
31use crate::text_safety::mask_terminal_control_bytes;
32
33pub use error::{ManualError, ManualErrorKind};
34pub use source::MAX_MANUAL_BYTES;
35
36const MAX_INLINE_EQUATION_NORMALIZATIONS: usize = 256;
37
38/// Parse and normalize one standalone man or mdoc source file.
39///
40/// This safe convenience entry point does not expand `.so` redirects because
41/// no caller-approved manual hierarchy accompanies a bare path. `ManT`'s indexed
42/// query path uses [`parse_manual_page`] instead.
43///
44/// # Errors
45///
46/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
47pub fn parse_manual_source(path: &Path) -> Result<Document, ManualError> {
48    let loaded = load_manual_source(path)?;
49    reject_standalone_redirect(path, &loaded.source)?;
50    parse_plain_manual(path, &loaded.source, None)
51}
52
53/// Parse one already bounded, uncompressed standalone roff input.
54///
55/// This is the standard-input counterpart of [`parse_manual_source`]. It does
56/// not expand `.so` redirects and never reads another file.
57///
58/// # Errors
59///
60/// Returns [`ManualError`] when libmandoc rejects the input.
61pub fn parse_manual_bytes(path: &Path, source: &[u8]) -> Result<Document, ManualError> {
62    reject_standalone_redirect(path, source)?;
63    parse_plain_manual(path, source, None)
64}
65
66fn reject_standalone_redirect(path: &Path, source: &[u8]) -> Result<(), ManualError> {
67    if redirect_target(path, source)?.is_some() {
68        return Err(ManualError::redirect(
69            path,
70            "standalone .so redirects require MANPATH discovery and cannot be followed by --input",
71        ));
72    }
73    Ok(())
74}
75
76/// Parse an indexed manual, resolving `.so` redirects against its discovered
77/// manual hierarchy without falling back to the process working directory.
78///
79/// # Errors
80///
81/// Returns [`ManualError`] when the source cannot be opened, decoded, or parsed.
82pub fn parse_manual_page(page: &ManualPage) -> Result<Document, ManualError> {
83    let resolved = resolve_manual_redirects(page)?;
84    parse_plain_manual(
85        &page.path,
86        &resolved.source,
87        resolved.alias_target.as_deref(),
88    )
89}
90
91fn parse_plain_manual(
92    path: &Path,
93    source: &[u8],
94    alias_target: Option<&str>,
95) -> Result<Document, ManualError> {
96    let (source, masked_controls) = mask_terminal_control_bytes(source);
97    let report = Parser::new(ParseOptions {
98        includes: IncludePolicy::Deny,
99        compression: Compression::Plain,
100    })
101    .parse_bytes(path, source.as_ref())
102    .map_err(ManualError::from)?;
103    let source_text = String::from_utf8_lossy(source.as_ref());
104    let mut document = lower_mandoc_document_with_source(path, &report, Some(&source_text));
105    if masked_controls > 0 {
106        document.diagnostics.insert(
107            0,
108            Diagnostic {
109                level: DiagnosticLevel::Warning,
110                code: Some("manual.control-characters".to_owned()),
111                message: format!("masked {masked_controls} terminal-unsafe control character(s)"),
112                source: None,
113            },
114        );
115    }
116    if let Some(alias_target) = alias_target {
117        document.meta.alias_target = Some(alias_target.to_owned());
118    }
119    Ok(document)
120}
121
122/// Convert a completed low-level parse into the stable document contract.
123#[must_use]
124pub fn lower_mandoc_document(path: &Path, report: &ParseReport) -> Document {
125    lower_mandoc_document_with_source(path, report, None)
126}
127
128fn lower_mandoc_document_with_source(
129    path: &Path,
130    report: &ParseReport,
131    source: Option<&str>,
132) -> Document {
133    let parsed: &MandocDocument = &report.document;
134    let mut context = LoweringContext::new(parsed.metadata.name.as_deref(), source);
135    let mut diagnostics = diagnostics::lower_diagnostics(&report.diagnostics);
136    let mut sections = blocks::lower_sections(&parsed.root, &mut context);
137    let mut root_blocks = blocks::lower_root_blocks(&parsed.root, &context);
138    diagnostics.extend(context.take_diagnostics());
139    let explicit_targets = navigation::explicit_targets(&parsed.root);
140    let mut retained_targets = explicit_targets.clone();
141    retained_targets.extend(crate::definitions::identify_definitions(
142        &mut root_blocks,
143        &mut sections,
144        &explicit_targets,
145    ));
146    navigation::resolve_navigation(&mut sections, &retained_targets, &mut diagnostics);
147    let mut document = Document {
148        parser: Some(ParserInfo {
149            name: "libmandoc".to_owned(),
150            version: libmandoc_rs::LIBMANDOC_VERSION.to_owned(),
151        }),
152        source: DocumentSource {
153            format: match parsed.macro_set {
154                MacroSet::Mdoc => SourceFormat::Mdoc,
155                MacroSet::Man | MacroSet::None => SourceFormat::Man,
156            },
157            path: Some(path.to_string_lossy().into_owned()),
158        },
159        meta: DocumentMeta {
160            title: normalize_metadata(parsed.metadata.title.as_deref()),
161            manual_section: normalize_metadata(parsed.metadata.section.as_deref()),
162            date: normalize_metadata(parsed.metadata.date.as_deref()),
163            volume: normalize_metadata(parsed.metadata.volume.as_deref()),
164            os: normalize_metadata(parsed.metadata.os.as_deref()),
165            arch: normalize_metadata(parsed.metadata.arch.as_deref()),
166            names: normalize_metadata(parsed.metadata.name.as_deref())
167                .into_iter()
168                .collect(),
169            alias_target: parsed.metadata.alias_target.clone(),
170        },
171        diagnostics,
172        blocks: root_blocks,
173        sections,
174    };
175    document.diagnostics.extend(validate_document(&document));
176    document
177}
178
179/// Metadata strings come from roff macro arguments rather than visible text
180/// nodes, so libmandoc can legitimately retain zero-width escapes such as
181/// `\&`. Normalize them through the same inline decoder used for document
182/// content before exposing the renderer-neutral contract.
183fn normalize_metadata(value: Option<&str>) -> Option<String> {
184    value.map(visible_text)
185}
186
187struct LoweringContext<'a> {
188    default_name: Option<&'a str>,
189    source_lines: Option<SourceLineIndex<'a>>,
190    equation_delimiters: Vec<EquationDelimiterChange>,
191    normalized_equations: RefCell<BTreeMap<String, String>>,
192    next_section_id: usize,
193    diagnostics: RefCell<Vec<Diagnostic>>,
194}
195
196#[derive(Clone, Copy, Debug)]
197struct EquationDelimiterChange {
198    line: u32,
199    delimiters: Option<(char, char)>,
200}
201
202#[derive(Clone, Copy, Debug)]
203enum EquationDelimiterDirective {
204    Enable(char, char),
205    Disable,
206}
207
208impl EquationDelimiterDirective {
209    const fn delimiters(self) -> Option<(char, char)> {
210        match self {
211            Self::Enable(opening, closing) => Some((opening, closing)),
212            Self::Disable => None,
213        }
214    }
215}
216
217#[derive(Debug)]
218struct TableTextBlock {
219    source: String,
220    start_line: u32,
221    end_line: u32,
222}
223
224impl TableTextBlock {
225    const fn contains_line(&self, line: u32) -> bool {
226        line >= self.start_line && line <= self.end_line
227    }
228}
229
230impl<'a> LoweringContext<'a> {
231    fn new(default_name: Option<&'a str>, source: Option<&'a str>) -> Self {
232        Self {
233            default_name,
234            source_lines: source.map(SourceLineIndex::new),
235            equation_delimiters: source.map_or_else(Vec::new, equation_delimiter_changes),
236            normalized_equations: RefCell::new(BTreeMap::new()),
237            next_section_id: 1,
238            diagnostics: RefCell::new(Vec::new()),
239        }
240    }
241
242    fn equation_delimiters_at(&self, line: u32) -> Option<(char, char)> {
243        self.equation_delimiters
244            .iter()
245            .rev()
246            .find(|change| change.line <= line)
247            .and_then(|change| change.delimiters)
248    }
249
250    /// Normalize an eqn fragment through the same pinned parser used for
251    /// display equations. tbl retains delimiter-wrapped cell text as an
252    /// opaque string, so reparsing only that bounded fragment is the sole way
253    /// to avoid a second, incomplete eqn grammar in the lowering layer.
254    fn normalize_equation(&self, source: &str, line: u32) -> String {
255        {
256            let normalized = self.normalized_equations.borrow();
257            if let Some(value) = normalized.get(source) {
258                return value.clone();
259            }
260            if normalized.len() >= MAX_INLINE_EQUATION_NORMALIZATIONS {
261                drop(normalized);
262                self.warn_inline_equation_budget(line);
263                return visible_text(source);
264            }
265        }
266        let synthetic = format!(".TH MANT-EQN 7\n.EQ\n{source}\n.EN\n");
267        let normalized = Parser::default()
268            .parse_bytes(Path::new("mant-inline-eqn.7"), synthetic.as_bytes())
269            .ok()
270            .and_then(|report| first_equation(&report.document.root).map(visible_text))
271            .filter(|value| !value.trim().is_empty())
272            .unwrap_or_else(|| visible_text(source));
273        self.normalized_equations
274            .borrow_mut()
275            .insert(source.to_owned(), normalized.clone());
276        normalized
277    }
278
279    fn table_text_blocks(&self, line: u32, maximum: usize) -> Vec<TableTextBlock> {
280        // Ordinary tbl rows must not scan forward for `T{` markers.  Besides
281        // wasting work, that used to let a commented-out multiline-cell
282        // marker claim later real rows as its embedded semantic children.
283        if maximum == 0 {
284            return Vec::new();
285        }
286        let Some(source_lines) = self.source_lines.as_ref() else {
287            return Vec::new();
288        };
289        let mut blocks = Vec::new();
290        let mut current = None::<(String, u32)>;
291        for (line_number, line) in source_lines.lines_from(line) {
292            let trimmed = line.trim_start();
293            // `.\\"` comments are not tbl control lines, even when their
294            // prose contains a disabled `T{` or `T}` marker.  Ignore them
295            // both while looking for a block and inside an active block,
296            // matching roff's non-printing comment semantics.
297            if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
298                continue;
299            }
300            if let Some((content, start_line)) = current.as_mut() {
301                if let Some(remainder) = trimmed.strip_prefix("T}") {
302                    blocks.push(TableTextBlock {
303                        source: std::mem::take(content),
304                        start_line: *start_line,
305                        end_line: line_number.saturating_sub(1),
306                    });
307                    current = None;
308                    if blocks.len() == maximum {
309                        break;
310                    }
311                    // tbl serializes adjacent multiline cells as `T}\tT{`.
312                    // Closing the first cell must not hide the next opening
313                    // marker carried by the same physical source line.
314                    if remainder.trim_end().ends_with("T{") {
315                        current = Some((String::new(), line_number.saturating_add(1)));
316                    }
317                } else {
318                    if !content.is_empty() {
319                        content.push('\n');
320                    }
321                    content.push_str(line);
322                }
323            } else if trimmed.trim_end().ends_with("T{") {
324                current = Some((String::new(), line_number.saturating_add(1)));
325            }
326        }
327        blocks
328    }
329
330    fn tab_separated_table_cells(&self, line: u32) -> Option<Vec<&'a str>> {
331        let source_line = self.source_lines.as_ref()?.line(line)?;
332        source_line
333            .contains('\t')
334            .then(|| source_line.split('\t').collect())
335    }
336
337    /// Return explicitly requested blank rows between two visible no-fill
338    /// source lines.
339    ///
340    /// Some structural AST forms omit blank physical input rows.  That is
341    /// normally the right tree representation, but no-fill displays make the
342    /// rows observable.  Source spans let lowering restore raw blank runs and
343    /// `.sp` requests, without mistaking comments or hidden state changes for
344    /// vertical content.  Consecutive empty input lines are one visual
345    /// separator in roff no-fill output, so they must not accumulate.
346    pub(super) fn no_fill_blank_rows_between(
347        &self,
348        previous_line: Option<u32>,
349        current_line: Option<u32>,
350    ) -> u16 {
351        let Some((previous, current)) = previous_line.zip(current_line) else {
352            return 0;
353        };
354        if current <= previous.saturating_add(1) {
355            return 0;
356        }
357        let Some(source_lines) = self.source_lines.as_ref() else {
358            return 0;
359        };
360        source_lines
361            .lines_between(previous, current)
362            .map(no_fill_vertical_rows)
363            .max()
364            .unwrap_or(0)
365    }
366
367    fn section_id(&mut self, title: &str) -> String {
368        let sequence = self.next_section_id;
369        self.next_section_id += 1;
370        let slug: String = title
371            .chars()
372            .flat_map(char::to_lowercase)
373            .map(|character| {
374                if character.is_alphanumeric() {
375                    character
376                } else {
377                    '-'
378                }
379            })
380            .collect::<String>()
381            .split('-')
382            .filter(|part| !part.is_empty())
383            .collect::<Vec<_>>()
384            .join("-");
385        if slug.is_empty() {
386            format!("section-{sequence}")
387        } else {
388            format!("{slug}-{sequence}")
389        }
390    }
391
392    fn warn_unhandled_structural_parts(&self, node: &Node) {
393        let macro_name = node.macro_name.as_deref().unwrap_or("unknown");
394        self.diagnostics.borrow_mut().push(Diagnostic {
395            level: DiagnosticLevel::Warning,
396            code: Some("manual.unhandled-structural-parts".to_owned()),
397            message: format!(
398                "structural macro '{macro_name}' contains parts without a complete lowering policy"
399            ),
400            source: source_span(node),
401        });
402    }
403
404    fn warn_unhandled_table_text_block(&self, node: &Node) {
405        self.diagnostics.borrow_mut().push(Diagnostic {
406            level: DiagnosticLevel::Warning,
407            code: Some("manual.unhandled-table-text-block".to_owned()),
408            message: "tbl text block contains semantic roff that could not be retained".to_owned(),
409            source: source_span(node),
410        });
411    }
412
413    fn warn_unhandled_table_text_block_line(&self, line: u32) {
414        self.diagnostics.borrow_mut().push(Diagnostic {
415            level: DiagnosticLevel::Warning,
416            code: Some("manual.unhandled-table-text-block".to_owned()),
417            message: "tbl text block contains semantic roff that could not be retained".to_owned(),
418            source: Some(SourceSpan {
419                byte_range: None,
420                line,
421                column: 1,
422                end_line: None,
423                end_column: None,
424            }),
425        });
426    }
427
428    fn warn_unexpanded_table_cell(&self, line: u32) {
429        let mut diagnostics = self.diagnostics.borrow_mut();
430        if diagnostics
431            .iter()
432            .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell"))
433        {
434            return;
435        }
436        diagnostics.push(Diagnostic {
437            level: DiagnosticLevel::Unsupported,
438            code: Some("manual.unexpanded-table-cell".to_owned()),
439            message: "one or more tbl cells contain formatter strings that could not be expanded; their source spellings were preserved".to_owned(),
440            source: Some(SourceSpan {
441                byte_range: None,
442                line,
443                column: 1,
444                end_line: None,
445                end_column: None,
446            }),
447        });
448    }
449
450    fn warn_inline_equation_budget(&self, line: u32) {
451        let mut diagnostics = self.diagnostics.borrow_mut();
452        if diagnostics
453            .iter()
454            .any(|diagnostic| diagnostic.code.as_deref() == Some("manual.inline-equation-budget"))
455        {
456            return;
457        }
458        diagnostics.push(Diagnostic {
459            level: DiagnosticLevel::Unsupported,
460            code: Some("manual.inline-equation-budget".to_owned()),
461            message: format!(
462                "more than {MAX_INLINE_EQUATION_NORMALIZATIONS} distinct inline table equations; later source spellings were retained without normalization"
463            ),
464            source: Some(SourceSpan {
465                byte_range: None,
466                line,
467                column: 1,
468                end_line: None,
469                end_column: None,
470            }),
471        });
472    }
473
474    fn take_diagnostics(&self) -> Vec<Diagnostic> {
475        self.diagnostics.take()
476    }
477}
478
479fn first_equation(node: &Node) -> Option<&str> {
480    node.equation
481        .as_deref()
482        .or_else(|| node.children.iter().find_map(first_equation))
483}
484
485/// Track active inline eqn delimiters at each source line.
486///
487/// eqn configures delimiters inside an `.EQ`/`.EN` block; they take effect on
488/// following prose and tbl cells. Keeping the change points makes lookup
489/// logarithm-free and deterministic without replaying the whole source for
490/// every table cell.
491fn equation_delimiter_changes(source: &str) -> Vec<EquationDelimiterChange> {
492    let mut changes = Vec::new();
493    let mut in_equation = false;
494    let mut pending = None;
495    for (index, source_line) in source.lines().enumerate() {
496        let line = u32::try_from(index + 1).unwrap_or(u32::MAX);
497        let trimmed = source_line.trim();
498        if trimmed.starts_with(".\\\"") || trimmed.starts_with("'\\\"") {
499            continue;
500        }
501        if let Some(rest) = trimmed
502            .strip_prefix(".EQ")
503            .or_else(|| trimmed.strip_prefix("'EQ"))
504            .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace))
505        {
506            in_equation = true;
507            pending = parse_equation_delimiters(rest.trim()).or(pending);
508            continue;
509        }
510        if in_equation {
511            if trimmed == ".EN" || trimmed == "'EN" {
512                if let Some(delimiters) = pending.take() {
513                    changes.push(EquationDelimiterChange {
514                        line: line.saturating_add(1),
515                        delimiters: delimiters.delimiters(),
516                    });
517                }
518                in_equation = false;
519            } else if let Some(delimiters) = parse_equation_delimiters(trimmed) {
520                pending = Some(delimiters);
521            }
522        }
523    }
524    changes
525}
526
527fn parse_equation_delimiters(value: &str) -> Option<EquationDelimiterDirective> {
528    let value = value.strip_prefix("delim")?.trim_start();
529    if value == "off" {
530        return Some(EquationDelimiterDirective::Disable);
531    }
532    let mut delimiters = value.chars();
533    let opening = delimiters.next()?;
534    let closing = delimiters.next()?;
535    Some(EquationDelimiterDirective::Enable(opening, closing))
536}
537
538/// Return the largest explicit vertical separation requested by one source
539/// line.  A raw blank line is a single separator; `no_fill_blank_rows_between`
540/// deliberately takes the maximum across adjacent source lines because groff
541/// collapses a run of blank input lines in a no-fill display.
542fn no_fill_vertical_rows(line: &str) -> u16 {
543    let trimmed = line.trim();
544    if trimmed.is_empty() || roff_zero_width_blank_line(trimmed) {
545        return 1;
546    }
547    let Some(request) = line.trim_start().strip_prefix(['.', '\'']) else {
548        return 0;
549    };
550    let (name, arguments) = request
551        .split_once(char::is_whitespace)
552        .unwrap_or((request, ""));
553    if name != "sp" {
554        return 0;
555    }
556    let Some(argument) = arguments.split_whitespace().next() else {
557        return 1;
558    };
559    argument.trim_end_matches('v').parse::<u16>().unwrap_or(1)
560}
561
562/// Whether a no-fill input row contains only roff's zero-width guard escape.
563///
564/// POD-generated manuals use `\&` instead of an empty physical input line.
565/// libmandoc correctly lowers that escape to no glyph, but the row itself is
566/// still observable inside a verbatim display. Keep this deliberately narrow:
567/// font switches and other state-only input do not independently request a
568/// visual row, while `\c` explicitly suppresses the line boundary.
569fn roff_zero_width_blank_line(line: &str) -> bool {
570    let mut remainder = line;
571    let mut found = false;
572    while let Some(rest) = remainder.strip_prefix(r"\&") {
573        found = true;
574        remainder = rest.trim();
575    }
576    found && remainder.is_empty()
577}
578
579fn source_span(node: &Node) -> Option<SourceSpan> {
580    (node.line > 0).then_some(SourceSpan {
581        byte_range: None,
582        line: node.line,
583        column: node.column.max(1),
584        end_line: None,
585        end_column: None,
586    })
587}
588
589/// Return the first libmandoc structural part of one kind.
590///
591/// Most semantic macros own at most one head, body, and tail. Callers whose
592/// grammar permits repeated parts must use [`part_child_groups`] instead so
593/// the multiplicity remains explicit at the lowering boundary.
594fn first_part_children(node: &Node, kind: libmandoc_rs::NodeKind) -> &[Node] {
595    node.children
596        .iter()
597        .find(|child| child.kind == kind)
598        .map_or(&[], |child| child.children.as_slice())
599}
600
601/// Iterate every libmandoc structural part of one kind in source order.
602fn part_child_groups(node: &Node, kind: libmandoc_rs::NodeKind) -> impl Iterator<Item = &[Node]> {
603    node.children
604        .iter()
605        .filter(move |child| child.kind == kind)
606        .map(|child| child.children.as_slice())
607}
608
609#[cfg(test)]
610mod tests {
611    use std::{fmt::Write as _, fs, process};
612
613    use mant_ir::{Block, DiagnosticLevel, Inline, SourceFormat};
614
615    use super::{
616        MAX_INLINE_EQUATION_NORMALIZATIONS, Parser, lower_mandoc_document, parse_manual_bytes,
617        parse_manual_source,
618    };
619
620    fn temporary_source(label: &str, source: &str) -> std::path::PathBuf {
621        let path = std::env::temp_dir().join(format!("mant-lower-{label}-{}.1", process::id()));
622        fs::write(&path, source).expect("write temporary roff fixture");
623        path
624    }
625
626    fn find_macro_mut<'a>(
627        node: &'a mut libmandoc_rs::Node,
628        name: &str,
629    ) -> Option<&'a mut libmandoc_rs::Node> {
630        if node.macro_name.as_deref() == Some(name) {
631            return Some(node);
632        }
633        node.children
634            .iter_mut()
635            .find_map(|child| find_macro_mut(child, name))
636    }
637
638    fn replace_first_text(node: &mut libmandoc_rs::Node, value: &str) -> bool {
639        if let Some(text) = node.text.as_mut() {
640            *text = value.to_owned();
641            return true;
642        }
643        node.children
644            .iter_mut()
645            .any(|child| replace_first_text(child, value))
646    }
647
648    #[test]
649    fn standalone_inputs_reject_redirect_only_so_pages() {
650        let error = parse_manual_bytes(std::path::Path::new("stdin"), b".so man1/target.1\n")
651            .expect_err("standalone input must not follow another file");
652        assert!(error.to_string().contains("require MANPATH discovery"));
653    }
654
655    #[test]
656    fn lowers_man_sections_fonts_definitions_and_literal_blocks() {
657        let path = temporary_source(
658            "man",
659            ".TH MANT 1 \"July 2026\"\n\
660             .SH NAME\n\
661             mant \\- a viewer\n\
662             .SH OPTIONS\n\
663             .TP\n\
664             \\fB\\-h\\fR\n\
665             Show help.\n\
666             .nf\n\
667             mant --help\n\
668             mant git\n\
669             .fi\n",
670        );
671
672        let document = parse_manual_source(&path).expect("lower man source");
673        fs::remove_file(path).expect("remove temporary roff fixture");
674
675        assert_eq!(document.source.format, SourceFormat::Man);
676        assert_eq!(
677            document
678                .sections
679                .iter()
680                .map(|section| section.title.as_str())
681                .collect::<Vec<_>>(),
682            vec!["NAME", "OPTIONS"]
683        );
684        assert!(
685            document.sections[1]
686                .blocks
687                .iter()
688                .any(|block| matches!(block, Block::DefinitionList { .. }))
689        );
690        assert!(document.sections[1].blocks.iter().any(|block| matches!(
691            block,
692            Block::DefinitionList { items, .. }
693                if items.iter().any(|item| item.description.iter().any(
694                    |description| matches!(description, Block::Preformatted { .. })
695                ))
696        )));
697    }
698
699    #[test]
700    fn separates_definition_layout_arguments_from_visible_terms() {
701        let path = temporary_source(
702            "definition-head-roles",
703            ".TH HEAD-ROLES 1\n\
704             .SH EXAMPLES\n\
705             .TP \\w'man\\ 'u\n\
706             .BI man \\ ls\n\
707             Display ls.\n\
708             .TP 4\n\
709             4\n\
710             A numeric term remains visible.\n\
711             .IP \"1\" 8n\n\
712             An IP width remains layout-only.\n",
713        );
714
715        let document = parse_manual_source(&path).expect("lower definition head roles");
716        fs::remove_file(path).expect("remove temporary roff fixture");
717
718        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
719            panic!("expected one definition list");
720        };
721        assert_eq!(
722            items
723                .iter()
724                .flat_map(|item| item.terms.iter())
725                .map(|term| inline_text(term))
726                .collect::<Vec<_>>(),
727            ["man ls", "4", "1"]
728        );
729        assert!(matches!(
730            items[0].terms[0].as_slice(),
731            [Inline::Strong { .. }, Inline::Emphasis { .. }]
732        ));
733        assert!(
734            items
735                .iter()
736                .flat_map(|item| item.terms.iter())
737                .all(|term| !inline_text(term).contains("96u"))
738        );
739    }
740
741    #[test]
742    fn preserves_consecutive_tp_aliases_ending_in_line_continuations() {
743        let path = temporary_source(
744            "continued-definition-aliases",
745            ".TH ALIASES 1\n\
746             .SH OPTIONS\n\
747             .TP\n\
748             .BI \"\\-symbols=\" \"file\"\\c\n\
749             .TP\n\
750             .BI \"\\-s \" \"file\"\\c\n\
751             \\&\n\
752             Read symbols.\n",
753        );
754
755        let document = parse_manual_source(&path).expect("lower consecutive TP aliases");
756        fs::remove_file(path).expect("remove temporary roff fixture");
757
758        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
759            panic!("expected one definition list");
760        };
761        assert_eq!(items.len(), 1);
762        assert_eq!(
763            items[0]
764                .terms
765                .iter()
766                .map(|term| inline_text(term))
767                .collect::<Vec<_>>(),
768            ["-symbols=file", "-s file"]
769        );
770        let Block::Paragraph { children, .. } = &items[0].description[0] else {
771            panic!("expected alias description paragraph");
772        };
773        assert_eq!(inline_text(children), "Read symbols.");
774    }
775
776    #[test]
777    fn preserves_man_synopsis_flow_and_alternating_fonts() {
778        let path = temporary_source(
779            "man-synopsis-flow",
780            ".TH MAN 1\n\
781             .SH SYNOPSIS\n\
782             .B man\n\
783             .RI [\\| \"man options\" \\|]\n\
784             .RI [\\|[\\| section \\|]\n\
785             .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
786             .br\n\
787             .B man\n\
788             .B \\-k\n\
789             .RI [\\| \"apropos options\" \\|]\n\
790             .I regexp\n\
791             \\&.\\|.\\|.\\&\n\
792             .br\n\
793             .B man\n\
794             .BR \\-w \\||\\| \\-W\n\
795             .RI [\\| \"man options\" \\|]\n\
796             .I page\n\
797             \\&.\\|.\\|.\\&\n",
798        );
799
800        let document = parse_manual_source(&path).expect("lower man synopsis");
801        fs::remove_file(path).expect("remove temporary roff fixture");
802
803        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
804            panic!("expected one synopsis paragraph");
805        };
806        assert_eq!(
807            inline_text(children),
808            "man [man options] [[section] page ...] ...\n\
809             man -k [apropos options] regexp ...\n\
810             man -w|-W [man options] page ..."
811        );
812        assert_eq!(
813            children
814                .iter()
815                .filter(|node| matches!(node, Inline::LineBreak))
816                .count(),
817            2
818        );
819        assert!(children.iter().any(
820            |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
821        ));
822        assert!(children.iter().any(
823            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
824        ));
825        assert!(children.iter().any(
826            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
827        ));
828    }
829
830    #[test]
831    fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
832        let document = parse_manual_bytes(
833            std::path::Path::new("sy-heads.1"),
834            b".TH SY-HEADS 1 \"August 17, 2026\"\n\
835.SH SYNOPSIS\n\
836.SY getent\n\
837.RI [ option ]\n\
838.I database\n\
839.YS\n\
840.SH DESCRIPTION\n\
841.SY #!\\f[I]interpreter\\f[]\n\
842.RI [ optional-arg ]\n\
843.YS\n",
844        )
845        .expect("lower SY heads");
846
847        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
848            panic!("expected one synopsis paragraph");
849        };
850        assert_eq!(inline_text(children), "getent [option] database");
851        assert!(matches!(
852            children.first(),
853            Some(Inline::Strong { children }) if inline_text(children) == "getent"
854        ));
855
856        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
857            panic!("expected one description paragraph");
858        };
859        assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
860        assert!(matches!(
861            children.first(),
862            Some(Inline::Strong { children })
863                if children.iter().any(|inline| matches!(
864                    inline,
865                    Inline::Emphasis { children } if inline_text(children) == "interpreter"
866                ))
867        ));
868        assert!(
869            document.diagnostics.is_empty(),
870            "{:?}",
871            document.diagnostics
872        );
873    }
874
875    #[test]
876    fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
877        let document = parse_manual_bytes(
878            std::path::Path::new("no-fill-synopsis.2"),
879            b".TH NO-FILL-SYNOPSIS 2\n\
880.SH DESCRIPTION\n\
881.EX\n\
882.SY #!\\f[I]interpreter\\f[]\n\
883.RI [ optional-arg ]\n\
884.YS\n\
885.EE\n",
886        )
887        .expect("lower synopsis inside example");
888
889        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
890            panic!(
891                "no-fill synopsis must remain one preformatted block: {:?}",
892                document.sections[0].blocks
893            );
894        };
895        assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
896        assert_eq!(
897            children
898                .iter()
899                .filter(|inline| matches!(inline, Inline::LineBreak))
900                .count(),
901            1
902        );
903    }
904
905    #[test]
906    fn preserves_explicit_blank_rows_inside_no_fill_displays() {
907        let document = parse_manual_bytes(
908            std::path::Path::new("no-fill-blank-row.7"),
909            b".TH NO-FILL-BLANK-ROW 7\n\
910.SH EXAMPLE\n\
911.EX\n\
912first line\n\
913\n\
914second line\n\
915.EE\n",
916        )
917        .expect("lower no-fill blank row");
918
919        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
920            panic!(
921                "no-fill display must remain preformatted: {:?}",
922                document.sections[0].blocks
923            );
924        };
925        assert_eq!(inline_text(children), "first line\n\nsecond line");
926        assert_eq!(
927            children
928                .iter()
929                .filter(|inline| matches!(inline, Inline::LineBreak))
930                .count(),
931            2
932        );
933    }
934
935    #[test]
936    fn preserves_zero_width_guard_rows_inside_no_fill_displays() {
937        let document = parse_manual_bytes(
938            std::path::Path::new("no-fill-zero-width-row.7"),
939            b".TH NO-FILL-ZERO-WIDTH-ROW 7\n\
940.SH EXAMPLE\n\
941.EX\n\
942first line\n\
943\\&\n\
944second line\n\
945.EE\n",
946        )
947        .expect("lower no-fill zero-width row");
948
949        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
950            panic!(
951                "no-fill display must remain preformatted: {:?}",
952                document.sections[0].blocks
953            );
954        };
955        assert_eq!(inline_text(children), "first line\n\nsecond line");
956        assert_eq!(
957            children
958                .iter()
959                .filter(|inline| matches!(inline, Inline::LineBreak))
960                .count(),
961            2
962        );
963    }
964
965    #[test]
966    fn preserves_lines_inside_font_blocks_nested_in_literal_displays() {
967        let document = parse_manual_bytes(
968            std::path::Path::new("literal-font-block.7"),
969            b".Dd August 20, 2026\n\
970.Dt LITERAL-FONT-BLOCK 7\n\
971.Os\n\
972.Sh EXAMPLE\n\
973.Bd -literal\n\
974.Bf Sy\n\
975first line\n\
976second line\n\
977.Ef\n\
978.Ed\n",
979        )
980        .expect("lower font block inside literal display");
981
982        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
983            panic!(
984                "literal display must remain one preformatted block: {:?}",
985                document.sections[0].blocks
986            );
987        };
988        assert_eq!(inline_text(children), "first line\nsecond line");
989        assert_eq!(
990            children
991                .iter()
992                .filter(|inline| matches!(inline, Inline::LineBreak))
993                .count(),
994            1
995        );
996    }
997
998    #[test]
999    fn preserves_literal_display_lines_inside_literal_font_blocks() {
1000        let document = parse_manual_bytes(
1001            std::path::Path::new("literal-display-inside-font-block.7"),
1002            b".Dd August 21, 2026\n\
1003.Dt LITERAL-DISPLAY-INSIDE-FONT-BLOCK 7\n\
1004.Os\n\
1005.Sh EXAMPLE\n\
1006.Bf Li\n\
1007.Bd -literal\n\
1008first line\n\
1009second line\n\
1010.Ed\n\
1011.Ef\n",
1012        )
1013        .expect("lower literal display inside literal font block");
1014
1015        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1016            panic!(
1017                "fonted literal display must remain preformatted: {:?}",
1018                document.sections[0].blocks
1019            );
1020        };
1021        assert_eq!(inline_text(children), "first line\nsecond line");
1022        assert_eq!(
1023            children
1024                .iter()
1025                .filter(|inline| matches!(inline, Inline::LineBreak))
1026                .count(),
1027            1
1028        );
1029    }
1030
1031    #[test]
1032    fn preserves_lines_inside_nested_literal_displays() {
1033        let document = parse_manual_bytes(
1034            std::path::Path::new("nested-literal-display.7"),
1035            b".Dd August 21, 2026\n\
1036.Dt NESTED-LITERAL-DISPLAY 7\n\
1037.Os\n\
1038.Sh EXAMPLE\n\
1039.Bd -literal\n\
1040first line\n\
1041.Bd -literal\n\
1042second line\n\
1043third line\n\
1044.Ed\n",
1045        )
1046        .expect("lower nested literal display");
1047
1048        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1049            panic!(
1050                "nested literal displays must remain one preformatted block: {:?}",
1051                document.sections[0].blocks
1052            );
1053        };
1054        assert_eq!(inline_text(children), "first line\nsecond line\nthird line");
1055        assert_eq!(
1056            children
1057                .iter()
1058                .filter(|inline| matches!(inline, Inline::LineBreak))
1059                .count(),
1060            2
1061        );
1062    }
1063
1064    #[test]
1065    fn collapses_a_no_fill_blank_line_run_to_one_visual_separator() {
1066        let document = parse_manual_bytes(
1067            std::path::Path::new("no-fill-blank-run.7"),
1068            b".TH NO-FILL-BLANK-RUN 7\n\
1069.SH EXAMPLE\n\
1070.EX\n\
1071first line\n\
1072\n\
1073\n\
1074second line\n\
1075.EE\n",
1076        )
1077        .expect("lower no-fill blank run");
1078
1079        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1080            panic!(
1081                "no-fill display must remain preformatted: {:?}",
1082                document.sections[0].blocks
1083            );
1084        };
1085        assert_eq!(inline_text(children), "first line\n\nsecond line");
1086        assert_eq!(
1087            children
1088                .iter()
1089                .filter(|inline| matches!(inline, Inline::LineBreak))
1090                .count(),
1091            2
1092        );
1093    }
1094
1095    #[test]
1096    fn adjacent_no_fill_regions_scale_without_changing_their_topology() {
1097        const REGION_COUNT: usize = 2_048;
1098        let mut source = String::from(".TH NO-FILL-SCALE 7\n.SH EXAMPLE\n");
1099        for index in 0..REGION_COUNT {
1100            writeln!(source, ".nf\nline {index}\n.fi").expect("append no-fill region");
1101        }
1102
1103        let document =
1104            parse_manual_bytes(std::path::Path::new("no-fill-scale.7"), source.as_bytes())
1105                .expect("lower adjacent no-fill regions");
1106
1107        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1108            panic!(
1109                "adjacent regions must remain one preformatted block: {:?}",
1110                document.sections[0].blocks
1111            );
1112        };
1113        assert_eq!(
1114            children
1115                .iter()
1116                .filter(|inline| matches!(inline, Inline::LineBreak))
1117                .count(),
1118            REGION_COUNT - 1
1119        );
1120        assert!(inline_text(children).starts_with("line 0\nline 1\n"));
1121        assert!(
1122            inline_text(children).ends_with(&format!("line {}", REGION_COUNT - 1)),
1123            "last no-fill region must remain visible"
1124        );
1125    }
1126
1127    #[test]
1128    fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
1129        let path = temporary_source(
1130            "filled-line-boundaries",
1131            concat!(
1132                ".TH TOOL 1\n",
1133                ".SH SYNOPSIS\n",
1134                "tool [first]\n",
1135                "    [second]\n",
1136                "    [third]\n",
1137                ".PP\n",
1138                "Ordinary source wrapping\n",
1139                "remains one filled paragraph.\n",
1140            ),
1141        );
1142
1143        let document = parse_manual_source(&path).expect("lower filled line boundaries");
1144        fs::remove_file(path).expect("remove temporary roff fixture");
1145
1146        let [
1147            Block::Paragraph {
1148                children: synopsis, ..
1149            },
1150            Block::Paragraph {
1151                children: prose, ..
1152            },
1153        ] = document.sections[0].blocks.as_slice()
1154        else {
1155            panic!("expected synopsis and prose paragraphs");
1156        };
1157        assert_eq!(
1158            inline_text(synopsis),
1159            "tool [first]\n    [second]\n    [third]"
1160        );
1161        assert_eq!(
1162            synopsis
1163                .iter()
1164                .filter(|inline| matches!(inline, Inline::LineBreak))
1165                .count(),
1166            2
1167        );
1168        assert_eq!(
1169            inline_text(prose),
1170            "Ordinary source wrapping remains one filled paragraph."
1171        );
1172    }
1173
1174    #[test]
1175    fn honours_roff_no_space_line_continuations() {
1176        let document = parse_manual_bytes(
1177            std::path::Path::new("line-continuation.1"),
1178            b".TH LINE-CONTINUATION 1\n\
1179.SH DESCRIPTION\n\
1180extsize=\\c\n\
1181nnnn; multi-\\c\n\
1182block; (\\c\n\
1183.BR read (2)\n\
1184.EX\n\
1185literal-\\c\n\
1186continuation\n\
1187.EE\n",
1188        )
1189        .expect("lower no-space line continuations");
1190
1191        let [
1192            Block::Paragraph {
1193                children: prose, ..
1194            },
1195            Block::Preformatted {
1196                children: literal, ..
1197            },
1198        ] = document.sections[0].blocks.as_slice()
1199        else {
1200            panic!(
1201                "expected one filled and one no-fill block: {:?}",
1202                document.sections[0].blocks
1203            );
1204        };
1205        assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
1206        assert_eq!(inline_text(literal), "literal-continuation");
1207    }
1208
1209    #[test]
1210    fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
1211        let document = parse_manual_bytes(
1212            std::path::Path::new("motion-continuation.1"),
1213            b".TH MOTION-CONTINUATION 1\n\
1214.SH DESCRIPTION\n\
1215\\h'-04' 1.\\h'+01'\\c\n\
1216The next line.\n",
1217        )
1218        .expect("lower a horizontally spaced continued line");
1219
1220        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1221            panic!("expected one paragraph: {:?}", document.sections[0].blocks);
1222        };
1223        assert_eq!(inline_text(children), " 1. The next line.");
1224    }
1225
1226    #[test]
1227    fn lets_explicit_fonts_override_an_alternating_macro_default() {
1228        let path = temporary_source(
1229            "alternating-font-reset",
1230            ".TH MAN 1\n\
1231             .SH OPTIONS\n\
1232             .TP\n\
1233             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
1234             Set the pager prompt.\n",
1235        );
1236
1237        let document = parse_manual_source(&path).expect("lower alternating font reset");
1238        fs::remove_file(path).expect("remove temporary roff fixture");
1239
1240        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1241            panic!("expected one definition list");
1242        };
1243        let term = items[0]
1244            .terms
1245            .first()
1246            .expect("first definition term")
1247            .iter()
1248            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
1249            .collect::<Vec<_>>();
1250
1251        assert_eq!(term.len(), 5);
1252        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
1253        assert!(
1254            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
1255        );
1256        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
1257        assert!(
1258            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
1259        );
1260        assert!(
1261            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
1262        );
1263    }
1264
1265    #[test]
1266    fn suppresses_pod_font_requests_around_verbatim_blocks() {
1267        let path = temporary_source(
1268            "pod-verbatim-fonts",
1269            ".de Vb\n\
1270             .ft CW\n\
1271             .nf\n\
1272             ..\n\
1273             .de Ve\n\
1274             .ft R\n\
1275             .fi\n\
1276             ..\n\
1277             .TH POD 1\n\
1278             .SH EXAMPLES\n\
1279             .Vb 2\n\
1280             \\&struct A { int a; };\n\
1281             \\&struct B : A {};\n\
1282             .Ve\n",
1283        );
1284
1285        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
1286        fs::remove_file(path).expect("remove temporary roff fixture");
1287
1288        assert_eq!(document.sections[0].blocks.len(), 1);
1289        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
1290            panic!("expected one preformatted block");
1291        };
1292        assert_eq!(
1293            inline_text(children),
1294            "struct A { int a; };\nstruct B : A {};"
1295        );
1296    }
1297
1298    #[test]
1299    fn lowers_indented_aliases_without_roff_layout_arguments() {
1300        let path = temporary_source(
1301            "indented-aliases",
1302            ".TH CONTROL 1\n\
1303             .SH OPTIONS\n\
1304             .PD 0\n\
1305             .IP \"\\fB-a\\fR\" 4\n\
1306             .IP \"\\fB--all\\fR\" 4\n\
1307             Show all entries.\n\
1308             .PD\n\
1309             .in 168u\n",
1310        );
1311
1312        let document = parse_manual_source(&path).expect("lower indented aliases");
1313        fs::remove_file(path).expect("remove temporary roff fixture");
1314
1315        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1316            panic!("expected one definition list");
1317        };
1318        assert_eq!(items.len(), 1);
1319        assert_eq!(
1320            items[0]
1321                .terms
1322                .iter()
1323                .map(|term| inline_text(term))
1324                .collect::<Vec<_>>(),
1325            ["-a", "--all"]
1326        );
1327        assert_eq!(items[0].description.len(), 1);
1328        let Block::Paragraph { children, .. } = &items[0].description[0] else {
1329            panic!("expected alias description paragraph");
1330        };
1331        assert_eq!(inline_text(children), "Show all entries.");
1332    }
1333
1334    #[test]
1335    fn tq_terms_share_one_semantic_option_identity() {
1336        let path = temporary_source(
1337            "tq-aliases",
1338            ".TH TQ-ALIASES 7\n\
1339             .SH OPTIONS\n\
1340             .TP\n\
1341             .B \\-\\-alpha\n\
1342             .TQ\n\
1343             .B \\-a\n\
1344             .TQ\n\
1345             .B \\-\\-ALPHA\n\
1346             Enable alpha mode.\n",
1347        );
1348
1349        let document = parse_manual_source(&path).expect("lower TQ aliases");
1350        fs::remove_file(path).expect("remove temporary roff fixture");
1351        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1352            panic!("expected one definition list");
1353        };
1354        assert_eq!(items.len(), 1);
1355        assert_eq!(
1356            items[0]
1357                .terms
1358                .iter()
1359                .map(|term| inline_text(term))
1360                .collect::<Vec<_>>(),
1361            ["-a", "--alpha", "--ALPHA"]
1362        );
1363        assert_eq!(
1364            items[0].identity.as_ref().expect("option identity").names,
1365            ["-a", "--alpha", "--ALPHA"]
1366        );
1367    }
1368
1369    #[test]
1370    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
1371        let path = temporary_source(
1372            "paragraph-distance",
1373            ".TH SPACING 1\n\
1374             .SH OPTIONS\n\
1375             .IP \"\\fB-a\\fR\" 4\n\
1376             First.\n\
1377             .IP \"\\fB-b\\fR\" 4\n\
1378             Second.\n\
1379             .PD 0\n\
1380             .IP \"\\fB-c\\fR\" 4\n\
1381             Third.\n\
1382             .IP \"\\fB-d\\fR\" 4\n\
1383             Fourth.\n\
1384             .PD\n\
1385             .IP \"\\fB-e\\fR\" 4\n\
1386             Fifth.\n",
1387        );
1388
1389        let document = parse_manual_source(&path).expect("lower paragraph distance");
1390        fs::remove_file(path).expect("remove temporary roff fixture");
1391
1392        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
1393        else {
1394            panic!("expected one definition list");
1395        };
1396        assert!(!compact);
1397        assert_eq!(items.len(), 5);
1398        assert_eq!(
1399            items
1400                .iter()
1401                .map(|item| item.spacing_before_lines)
1402                .collect::<Vec<_>>(),
1403            [Some(0), Some(1), Some(0), Some(0), Some(1)]
1404        );
1405    }
1406
1407    #[test]
1408    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
1409        let path = temporary_source(
1410            "vertical-layout",
1411            ".TH SPACING 1\n\
1412             .SH FIRST\n\
1413             First paragraph.\n\
1414             .PP\n\
1415             Second paragraph.\n\
1416             .SS CHILD\n\
1417             Child body.\n\
1418             .PD 0\n\
1419             .SS COMPACT\n\
1420             Compact child.\n\
1421             .SH NEXT\n\
1422             Next body.\n\
1423             .PD\n\
1424             .SH FINAL\n\
1425             Final body.\n",
1426        );
1427
1428        let document = parse_manual_source(&path).expect("lower vertical layout");
1429        fs::remove_file(path).expect("remove temporary roff fixture");
1430
1431        let [first, next, final_section] = document.sections.as_slice() else {
1432            panic!("expected three top-level sections");
1433        };
1434        assert_eq!(first.spacing_before_lines, 0);
1435        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
1436        else {
1437            panic!("expected two semantic paragraphs");
1438        };
1439        assert_eq!(layout.spacing_before_lines, 1);
1440
1441        let [child, compact] = first.children.as_slice() else {
1442            panic!("expected two subsections");
1443        };
1444        assert_eq!(child.spacing_before_lines, 1);
1445        assert_eq!(compact.spacing_before_lines, 0);
1446        assert_eq!(next.spacing_before_lines, 0);
1447        assert_eq!(final_section.spacing_before_lines, 1);
1448    }
1449
1450    #[test]
1451    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
1452        let path = temporary_source(
1453            "explicit-space-before-indent",
1454            ".TH SPACING 1\n\
1455             .SH CONTENT\n\
1456             Before.\n\
1457             .sp\n\
1458             .RS 4\n\
1459             After.\n\
1460             .RE\n",
1461        );
1462
1463        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
1464        fs::remove_file(path).expect("remove temporary roff fixture");
1465
1466        let [
1467            Block::Paragraph { .. },
1468            Block::VerticalSpace { lines: 1, .. },
1469            Block::Paragraph { layout, .. },
1470        ] = document.sections[0].blocks.as_slice()
1471        else {
1472            panic!("expected prose, one explicit gap, and indented prose");
1473        };
1474        assert_eq!(layout.indent_columns, 4);
1475        assert_eq!(
1476            layout.spacing_before_lines, 0,
1477            "the explicit gap must not be repeated as wrapper boundary spacing",
1478        );
1479    }
1480
1481    #[test]
1482    fn preserves_mdoc_paragraph_and_heading_distance() {
1483        let path = temporary_source(
1484            "mdoc-vertical-layout",
1485            ".Dd July 19, 2026\n\
1486             .Dt SPACING 1\n\
1487             .Os\n\
1488             .Sh FIRST\n\
1489             First paragraph.\n\
1490             .Pp\n\
1491             Second paragraph.\n\
1492             .Ss CHILD\n\
1493             Child body.\n",
1494        );
1495
1496        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
1497        fs::remove_file(path).expect("remove temporary roff fixture");
1498
1499        let [first] = document.sections.as_slice() else {
1500            panic!("expected one top-level section");
1501        };
1502        assert_eq!(first.spacing_before_lines, 1);
1503        assert!(matches!(
1504            first.blocks.get(1),
1505            Some(Block::VerticalSpace { lines: 1, .. })
1506        ));
1507        assert_eq!(first.children[0].spacing_before_lines, 1);
1508    }
1509
1510    #[test]
1511    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
1512        let path = temporary_source(
1513            "mdoc",
1514            ".Dd July 19, 2026\n\
1515             .Dt MANT 1\n\
1516             .Os\n\
1517             .Sh DESCRIPTION\n\
1518             Use\n\
1519             .Nm mant\n\
1520             with\n\
1521             .Xr man 1\n\
1522             Read\n\
1523             .Lk https://example.test/docs \"the documentation\"\n\
1524             or contact\n\
1525             .Mt docs@example.test\n\
1526             .Ss Details\n\
1527             .Fl h\n",
1528        );
1529
1530        let document = parse_manual_source(&path).expect("lower mdoc source");
1531        fs::remove_file(path).expect("remove temporary roff fixture");
1532
1533        assert_eq!(document.source.format, SourceFormat::Mdoc);
1534        assert_eq!(document.sections[0].children[0].title, "Details");
1535        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1536            panic!("expected description paragraph");
1537        };
1538        assert!(
1539            children
1540                .iter()
1541                .any(|inline| matches!(inline, Inline::Strong { .. }))
1542        );
1543        assert!(
1544            children.iter().any(
1545                |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
1546            )
1547        );
1548        assert!(children.iter().any(
1549            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
1550        ));
1551        assert!(children.iter().any(
1552            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
1553        ));
1554    }
1555
1556    #[test]
1557    fn retains_unlabelled_mdoc_link_targets_before_trailing_punctuation() {
1558        let document = parse_manual_bytes(
1559            std::path::Path::new("external-link.9"),
1560            b".Dd August 19, 2026\n.Dt EXTERNAL-LINK 9\n.Os\n.Sh DESCRIPTION\n.Lk https://example.test/books .\n",
1561        )
1562        .expect("lower an unlabelled mdoc external link");
1563
1564        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1565            panic!("expected one external-link paragraph");
1566        };
1567        assert_eq!(inline_text(children), "https://example.test/books.");
1568        assert!(matches!(
1569            children.as_slice(),
1570            [
1571                Inline::Link {
1572                    target: mant_ir::LinkTarget::External { uri },
1573                    children: link_children,
1574                    ..
1575                },
1576                Inline::Text { value },
1577            ] if uri == "https://example.test/books"
1578                && inline_text(link_children) == "https://example.test/books"
1579                && value == "."
1580        ));
1581    }
1582
1583    #[test]
1584    fn expands_mdoc_bsd_lifecycle_and_release_forms() {
1585        let source = b".Dd August 19, 2026\n.Dt BSD-LIFECYCLE 7\n.Os\n.Sh DESCRIPTION\n.Bx\n.Bx -alpha\n.Bx -beta\n.Bx -devel .\n.Bx 4.3 .\n.Bx 4.3 Net/2 .\n.Bx 386 0.1 .\n";
1586        let document = parse_manual_bytes(std::path::Path::new("bsd-lifecycle.7"), source)
1587            .expect("lower mdoc BSD lifecycle forms");
1588
1589        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1590            panic!("expected one BSD lifecycle paragraph");
1591        };
1592        assert_eq!(
1593            inline_text(children),
1594            "BSD BSD (currently in alpha test) BSD (currently in beta test) BSD (currently under development). 4.3BSD. 4.3BSD Net/2. 386BSD 0.1."
1595        );
1596    }
1597
1598    #[test]
1599    fn preserves_complete_mdoc_include_directives() {
1600        let document = parse_manual_bytes(
1601            std::path::Path::new("include.3"),
1602            b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
1603        )
1604        .expect("lower mdoc include");
1605
1606        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1607            panic!("expected one include paragraph");
1608        };
1609        assert_eq!(inline_text(children), "#include <fido.h>");
1610        assert!(matches!(
1611            children.as_slice(),
1612            [Inline::Code { value }] if value == "#include <fido.h>"
1613        ));
1614    }
1615
1616    #[test]
1617    fn propagates_nested_no_space_and_preserves_prefix_content() {
1618        let document = parse_manual_bytes(
1619            std::path::Path::new("no-space.7"),
1620            b".Dd August 19, 2026\n.Dt NO-SPACE 7\n.Os\n.Sh DESCRIPTION\n\
1621.Em Bell Labs Ns -derived\n\
1622.Ar job Ns s :\n\
1623.Sm off\n\
1624.Pf [\\-]ddd Cm \\&. No ddd\n\
1625.Sm on\n",
1626        )
1627        .expect("lower nested no-space macros");
1628
1629        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1630            panic!("expected one no-space paragraph");
1631        };
1632        assert_eq!(inline_text(children), "Bell Labs-derived jobs: [-]ddd.ddd");
1633    }
1634
1635    #[test]
1636    fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
1637        let path = temporary_source(
1638            "mdoc-delimiters",
1639            ".Dd July 19, 2026\n\
1640             .Dt DELIMITERS 7\n\
1641             .Os\n\
1642             .Sh DESCRIPTION\n\
1643             .Op optional\n\
1644             .Bq bracket\n\
1645             .Dq double\n\
1646             .Sq single\n\
1647             .Pq parenthesized\n\
1648             .Brq braced\n\
1649             .Aq angled\n\
1650             .Oo multi Ar value\n\
1651             .Oc\n\
1652             .Sh CHARACTERS\n\
1653             \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
1654        );
1655
1656        let document = parse_manual_source(&path).expect("lower delimiter and character source");
1657        fs::remove_file(path).expect("remove temporary roff fixture");
1658
1659        let description = document.sections[0]
1660            .blocks
1661            .iter()
1662            .map(|block| match block {
1663                Block::Paragraph { children, .. } => inline_text(children),
1664                _ => String::new(),
1665            })
1666            .collect::<Vec<_>>()
1667            .join(" ");
1668        for expected in [
1669            "[optional]",
1670            "[bracket]",
1671            "“double”",
1672            "‘single’",
1673            "(parenthesized)",
1674            "{braced}",
1675            "<angled>",
1676            "[multi value]",
1677        ] {
1678            assert!(
1679                description.contains(expected),
1680                "missing {expected:?} in {description:?}"
1681            );
1682        }
1683
1684        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
1685            panic!("expected one special-character paragraph");
1686        };
1687        assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
1688    }
1689
1690    #[test]
1691    fn retains_punctuation_after_implicit_mdoc_enclosures() {
1692        let document = parse_manual_bytes(
1693            std::path::Path::new("implicit-enclosure-punctuation.7"),
1694            b".Dd August 19, 2026\n.Dt IMPLICIT-ENCLOSURE-PUNCTUATION 7\n.Os\n\
1695.Sh DESCRIPTION\nWhen disabled\n.Pq all features remain readable ;\ncontinue safely.\n",
1696        )
1697        .expect("lower punctuation after an implicit enclosure");
1698
1699        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1700            panic!("expected one paragraph");
1701        };
1702        assert_eq!(
1703            inline_text(children),
1704            "When disabled (all features remain readable); continue safely."
1705        );
1706    }
1707
1708    #[test]
1709    fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
1710        let document = parse_manual_bytes(
1711            std::path::Path::new("named-characters.7"),
1712            b".TH NAMED-CHARACTERS 7\n\
1713.SH TEST\n\
1714at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
1715de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
1716unknown=\\[future-glyph]\n",
1717        )
1718        .expect("lower named characters");
1719
1720        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1721            panic!("expected one character paragraph");
1722        };
1723        assert_eq!(
1724            inline_text(children),
1725            "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
1726        );
1727    }
1728
1729    #[test]
1730    fn round_trips_raw_and_bracketed_unicode_manual_text() {
1731        let source = ".TH UNICODE 7\n\
1732.SH TEST\n\
1733Raw UTF-8: Mašláňová café — naïve.\n\
1734Escaped: Ma\\[u0161]l\\[u00E1] and \\[u2014] dash.\n";
1735        let document = parse_manual_bytes(std::path::Path::new("unicode.7"), source.as_bytes())
1736            .expect("lower raw and escaped Unicode");
1737
1738        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1739            panic!("expected one Unicode paragraph");
1740        };
1741        let rendered = inline_text(children);
1742        assert!(rendered.contains("Raw UTF-8: Mašláňová café — naïve."));
1743        assert!(rendered.contains("Escaped: Mašlá and — dash."));
1744        assert!(!rendered.contains(r"\[u"));
1745    }
1746
1747    #[test]
1748    fn preserves_explicit_mdoc_function_and_enclosure_structure() {
1749        let document = parse_manual_bytes(
1750            std::path::Path::new("explicit-mdoc.1"),
1751            b".Dd August 17, 2026\n\
1752.Dt EXPLICIT-MDOC 1\n\
1753.Os\n\
1754.Sh NAME\n\
1755.Nm explicit-mdoc\n\
1756.Nd exercise explicit blocks\n\
1757.Sh FUNCTION\n\
1758.Ft int\n\
1759.Fo audit_open\n\
1760.Fa const char *path\n\
1761.Fa int flags\n\
1762.Fc\n\
1763.Sh ENCLOSURES\n\
1764.Ao\nangle\n.Ac\n\
1765.Bo\nbracket\n.Bc\n\
1766.Do\ndouble\n.Dc\n\
1767.Po\nparenthesized\n.Pc\n\
1768.Qo\nquoted\n.Qc\n\
1769.So\nsingle\n.Sc\n\
1770.Bro\nbraced\n.Brc\n\
1771.Oo\noptional\n.Oc\n\
1772.Eo <<\ngeneric\n.Ec >>\n\
1773.Es [[ ]]\n\
1774.En custom\n",
1775        )
1776        .expect("lower explicit mdoc blocks");
1777
1778        let function = &document.sections[1];
1779        let [
1780            Block::Paragraph {
1781                children: return_type,
1782                ..
1783            },
1784            Block::Paragraph {
1785                children: declaration,
1786                ..
1787            },
1788        ] = function.blocks.as_slice()
1789        else {
1790            panic!("expected return type and function declaration paragraphs");
1791        };
1792        assert_eq!(inline_text(return_type), "int");
1793        assert_eq!(
1794            inline_text(declaration),
1795            "audit_open(const char *path, int flags)"
1796        );
1797        assert!(matches!(
1798            declaration.first(),
1799            Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1800        ));
1801
1802        let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1803            panic!("expected one enclosure paragraph");
1804        };
1805        assert_eq!(
1806            inline_text(children),
1807            "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1808             [optional] <<generic>> [[custom]]"
1809        );
1810        assert_eq!(document.diagnostics.len(), 2);
1811        assert!(
1812            document
1813                .diagnostics
1814                .iter()
1815                .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1816            "{:?}",
1817            document.diagnostics
1818        );
1819    }
1820
1821    #[test]
1822    fn preserves_the_complete_libbsd_library_identity() {
1823        let document = parse_manual_bytes(
1824            std::path::Path::new("libbsd.3bsd"),
1825            b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
1826        )
1827        .expect("lower libbsd library declaration");
1828        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1829            panic!("expected one library paragraph");
1830        };
1831
1832        assert_eq!(
1833            inline_text(children),
1834            "Utility functions from BSD systems (libbsd, -lbsd)"
1835        );
1836    }
1837
1838    #[test]
1839    fn joins_the_final_mdoc_bibliography_authors() {
1840        let document = parse_manual_bytes(
1841            std::path::Path::new("bibliography.3"),
1842            b".Dd August 19, 2026\n.Dt BIBLIOGRAPHY 3\n.Os\n.Sh SEE ALSO\n\
1843.Rs\n.%A Bentley, J.L.\n.%A McIlroy, M.D.\n.%T Engineering a Sort Function\n.Re\n",
1844        )
1845        .expect("lower mdoc bibliography");
1846        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1847            panic!("expected one bibliography paragraph");
1848        };
1849
1850        assert_eq!(
1851            inline_text(children),
1852            "Bentley, J.L. and McIlroy, M.D. Engineering a Sort Function."
1853        );
1854    }
1855
1856    #[test]
1857    fn preserves_mdoc_command_names_in_each_synopsis_form() {
1858        let document = parse_manual_bytes(
1859            std::path::Path::new("fido2-cred.1"),
1860            b".Dd August 19, 2026\n.Dt FIDO2-CRED 1\n.Os\n.Sh NAME\n.Nm fido2-cred\n.Nd make a credential\n.Sh SYNOPSIS\n.Nm\n.Fl M\n.Op Fl i Ar input_file\n.Nm fido2-cred\n.Fl V\n.Nm helper\n.Op Fl q\n",
1861        )
1862        .expect("lower mdoc synopsis names");
1863        let synopsis = &document.sections[1];
1864        let rendered = synopsis
1865            .blocks
1866            .iter()
1867            .map(|block| match block {
1868                Block::Paragraph { children, .. } => inline_text(children),
1869                block => panic!("expected synopsis paragraph, got {block:?}"),
1870            })
1871            .collect::<Vec<_>>();
1872
1873        assert_eq!(
1874            rendered,
1875            [
1876                "fido2-cred -M [-i input_file]",
1877                "fido2-cred -V",
1878                "helper [-q]",
1879            ]
1880        );
1881    }
1882
1883    #[test]
1884    fn preserves_mdoc_name_and_function_punctuation_by_context() {
1885        let document = parse_manual_bytes(
1886            std::path::Path::new("function-punctuation.3"),
1887            b".Dd August 19, 2026\n.Dt FUNCTION-PUNCTUATION 3\n.Os\n\
1888.Sh NAME\n.Nm function-punctuation\n.Nd test generated punctuation\n\
1889.Sh SYNOPSIS\n.Fn compact_call \"int value\"\n\
1890.Fo explicit_call\n.Fa \"int value\" \"const char *label\"\n.Fc\n\
1891.Sh DESCRIPTION\nThe\n.Fn prose_call \"int value\"\nfunction.\n",
1892        )
1893        .expect("lower mdoc generated punctuation");
1894
1895        let [Block::Paragraph { children: name, .. }] = document.sections[0].blocks.as_slice()
1896        else {
1897            panic!("expected one NAME paragraph");
1898        };
1899        assert_eq!(
1900            inline_text(name),
1901            "function-punctuation — test generated punctuation"
1902        );
1903
1904        let synopsis = document.sections[1]
1905            .blocks
1906            .iter()
1907            .map(|block| match block {
1908                Block::Paragraph { children, .. } => inline_text(children),
1909                block => panic!("expected synopsis paragraph, got {block:?}"),
1910            })
1911            .collect::<Vec<_>>();
1912        assert_eq!(
1913            synopsis,
1914            [
1915                "compact_call(int value);",
1916                "explicit_call(int value, const char *label);"
1917            ]
1918        );
1919
1920        let [
1921            Block::Paragraph {
1922                children: description,
1923                ..
1924            },
1925        ] = document.sections[2].blocks.as_slice()
1926        else {
1927            panic!("expected one DESCRIPTION paragraph");
1928        };
1929        assert_eq!(
1930            inline_text(description),
1931            "The prose_call(int value) function."
1932        );
1933    }
1934
1935    #[test]
1936    fn preserves_mdoc_synopsis_declaration_units() {
1937        let document = parse_manual_bytes(
1938            std::path::Path::new("synopsis-declarations.3"),
1939            b".Dd August 19, 2026\n.Dt SYNOPSIS-DECLARATIONS 3\n.Os\n\
1940.Sh SYNOPSIS\n.In synprobe.h\n.Ft const struct stat *\n\
1941.Fn synprobe_first \"struct thing *a\"\n.Ft void\n\
1942.Fo synprobe_second\n.Fa \"struct thing *a\"\n.Fa \"int n\"\n.Fc\n\
1943.Fn synprobe_third \"int n\"\n",
1944        )
1945        .expect("lower mdoc synopsis declarations");
1946
1947        let rendered = document.sections[0]
1948            .blocks
1949            .iter()
1950            .map(|block| match block {
1951                Block::Paragraph { children, .. } => inline_text(children),
1952                block => panic!("expected synopsis declaration paragraph, got {block:?}"),
1953            })
1954            .collect::<Vec<_>>();
1955
1956        assert_eq!(
1957            rendered,
1958            [
1959                "#include <synprobe.h>",
1960                "const struct stat * synprobe_first(struct thing *a);",
1961                "void synprobe_second(struct thing *a, int n);",
1962                "synprobe_third(int n);",
1963            ]
1964        );
1965    }
1966
1967    #[test]
1968    fn preserves_printable_roff_content_outside_formal_sections() {
1969        let document = parse_manual_bytes(
1970            std::path::Path::new("manweb.1"),
1971            b".TH MANWEB 1\n .SH NAME\nmanweb - browse generated documentation\n.SH SYNOPSIS\n.B manweb\n",
1972        )
1973        .expect("lower root prose");
1974        let [Block::Paragraph { children, .. }] = document.blocks.as_slice() else {
1975            panic!("expected one root paragraph, got {:?}", document.blocks);
1976        };
1977
1978        assert_eq!(
1979            inline_text(children),
1980            " .SH NAME manweb - browse generated documentation"
1981        );
1982        assert_eq!(document.sections[0].title, "SYNOPSIS");
1983    }
1984
1985    #[test]
1986    fn discards_temporary_indent_arguments_without_hiding_the_next_line() {
1987        let document = parse_manual_bytes(
1988            std::path::Path::new("temporary-indent.8"),
1989            b".TH TEMPORARY-INDENT 8\n.SH EXAMPLES\n.ti +8n\nexample% command\n.ti\nexample% other\n",
1990        )
1991        .expect("lower temporary indentation requests");
1992
1993        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1994            panic!("expected one examples paragraph");
1995        };
1996        assert_eq!(inline_text(children), "example% command example% other");
1997    }
1998
1999    #[test]
2000    fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
2001        let mut report = Parser::default()
2002            .parse_bytes(
2003                "future-structure.1",
2004                b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
2005.Fo future_call\n.Fa argument\n.Fc\n",
2006            )
2007            .expect("parse structural fixture");
2008        let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
2009        block.macro_name = Some("FutureBlock".to_owned());
2010        let mut second_body = block
2011            .children
2012            .iter()
2013            .find(|child| child.kind == libmandoc_rs::NodeKind::Body)
2014            .cloned()
2015            .expect("function body");
2016        assert!(replace_first_text(&mut second_body, "second_argument"));
2017        block.children.push(second_body);
2018
2019        let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
2020
2021        assert!(document.diagnostics.iter().any(|diagnostic| {
2022            diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
2023                && diagnostic.message.contains("FutureBlock")
2024        }));
2025        let rendered = document.sections[0]
2026            .blocks
2027            .iter()
2028            .map(|block| match block {
2029                Block::Paragraph { children, .. } => inline_text(children),
2030                block => panic!("expected fallback paragraph, got {block:?}"),
2031            })
2032            .collect::<Vec<_>>();
2033        assert_eq!(rendered, ["argument", "second_argument"]);
2034    }
2035
2036    #[test]
2037    fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
2038        let path = temporary_source(
2039            "man-see-also",
2040            ".TH TOOL 1\n\
2041             .SH DESCRIPTION\n\
2042             The styled reference \\fBprintf\\fP(3) is usable here.\n\
2043             .SH SEE ALSO\n\
2044             .BR printf (3),\n\
2045             .BR man (1)\n",
2046        );
2047
2048        let document = parse_manual_source(&path).expect("lower man references");
2049        fs::remove_file(path).expect("remove temporary roff fixture");
2050
2051        let see_also = document
2052            .sections
2053            .iter()
2054            .find(|section| section.title == "SEE ALSO")
2055            .expect("SEE ALSO");
2056        let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
2057            panic!("references are a paragraph");
2058        };
2059        assert!(children.iter().any(|inline| matches!(
2060            inline,
2061            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2062                if name == "printf" && manual_section == "3"
2063        )));
2064        assert!(children.iter().any(|inline| matches!(
2065            inline,
2066            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2067                if name == "man" && manual_section == "1"
2068        )));
2069
2070        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2071            panic!("description is a paragraph");
2072        };
2073        assert!(children.iter().any(|inline| matches!(
2074            inline,
2075            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2076                if name == "printf" && manual_section == "3"
2077        )));
2078    }
2079
2080    #[test]
2081    fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
2082        let path = temporary_source(
2083            "sphinx-manual-links",
2084            ".TH BTRFS 8\n\
2085             .SH COMMANDS\n\
2086             See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
2087             .EX\n\
2088             btrfs-subvolume(8) \\%<>\n\
2089             .EE\n",
2090        );
2091
2092        let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
2093        fs::remove_file(path).expect("remove temporary roff fixture");
2094        let section = &document.sections[0];
2095        let paragraph = section
2096            .blocks
2097            .iter()
2098            .find_map(|block| match block {
2099                Block::Paragraph { children, .. } => Some(children),
2100                _ => None,
2101            })
2102            .expect("commands paragraph");
2103        assert_eq!(
2104            inline_text(paragraph),
2105            "See btrfs-subvolume(8) and btrfs(5) for details."
2106        );
2107        let references = paragraph
2108            .iter()
2109            .filter_map(|inline| match inline {
2110                Inline::Link {
2111                    target:
2112                        mant_ir::LinkTarget::Manual {
2113                            name,
2114                            manual_section: Some(manual_section),
2115                        },
2116                    ..
2117                } => Some((name.as_str(), manual_section.as_str())),
2118                _ => None,
2119            })
2120            .collect::<Vec<_>>();
2121        assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
2122
2123        let literal = section
2124            .blocks
2125            .iter()
2126            .find_map(|block| match block {
2127                Block::Preformatted { children, .. } => Some(children),
2128                _ => None,
2129            })
2130            .expect("literal display");
2131        assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
2132        assert!(!literal.iter().any(|inline| matches!(
2133            inline,
2134            Inline::Link {
2135                target: mant_ir::LinkTarget::Manual { .. },
2136                ..
2137            }
2138        )));
2139    }
2140
2141    #[test]
2142    fn lowers_modern_groff_manual_uri_and_mail_macros() {
2143        let path = temporary_source(
2144            "man-modern-links",
2145            ".TH TOOL 1\n\
2146             .SH DESCRIPTION\n\
2147             .MR git-add 1 ,\n\
2148             .PP\n\
2149             Read\n\
2150             .UR https://example.test/docs\n\
2151             Documentation\n\
2152             .UE\n\
2153             now.\n\
2154             .PP\n\
2155             Mail comments, suggestions and bug reports to\n\
2156             .MT docs@example.test\n\
2157             Sean\n\
2158             .ME .\n",
2159        );
2160
2161        let document = parse_manual_source(&path).expect("lower modern man links");
2162        fs::remove_file(path).expect("remove temporary roff fixture");
2163        let section = &document.sections[0];
2164        let mut manual = false;
2165        let mut web = false;
2166        let mut mail = false;
2167        for children in section.blocks.iter().filter_map(|block| match block {
2168            Block::Paragraph { children, .. } => Some(children),
2169            _ => None,
2170        }) {
2171            for inline in children {
2172                match inline {
2173                    Inline::Link {
2174                        target:
2175                            mant_ir::LinkTarget::Manual {
2176                                name,
2177                                manual_section: Some(manual_section),
2178                            },
2179                        ..
2180                    } if name == "git-add" && manual_section == "1" => manual = true,
2181                    Inline::Link {
2182                        target: mant_ir::LinkTarget::External { uri },
2183                        ..
2184                    } if uri == "https://example.test/docs" => {
2185                        web = true;
2186                    }
2187                    Inline::Link {
2188                        target: mant_ir::LinkTarget::Email { address },
2189                        ..
2190                    } if address == "docs@example.test" => {
2191                        mail = true;
2192                    }
2193                    _ => {}
2194                }
2195            }
2196        }
2197
2198        assert!(manual && web && mail);
2199        assert!(section.blocks.iter().any(|block| match block {
2200            Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
2201            _ => false,
2202        }));
2203        let linked_paragraphs = section
2204            .blocks
2205            .iter()
2206            .filter_map(|block| match block {
2207                Block::Paragraph { children, .. }
2208                    if children.iter().any(|inline| {
2209                        matches!(
2210                            inline,
2211                            Inline::Link {
2212                                target: mant_ir::LinkTarget::External { .. },
2213                                ..
2214                            } | Inline::Link {
2215                                target: mant_ir::LinkTarget::Email { .. },
2216                                ..
2217                            }
2218                        )
2219                    }) =>
2220                {
2221                    Some(inline_text(children))
2222                }
2223                _ => None,
2224            })
2225            .collect::<Vec<_>>();
2226        assert_eq!(
2227            linked_paragraphs,
2228            [
2229                "Read Documentation ⟨https://example.test/docs⟩ now.",
2230                "Mail comments, suggestions and bug reports to Sean ⟨docs@example.test⟩."
2231            ]
2232        );
2233    }
2234
2235    #[test]
2236    fn searches_across_man_link_labels_and_visible_targets() {
2237        let source = b".TH LINK-SEARCH 1\n\
2238.SH REPORTING BUGS\n\
2239Mail comments, suggestions and bug reports to\n\
2240.MT docs@example.test\n\
2241Sean\n\
2242.ME .\n";
2243
2244        for pattern in ["bug reports to Sean", "docs@example.test"] {
2245            let query = crate::query_roff_bytes(source).expect("query link fixture");
2246            let result = crate::project_query_view(
2247                query,
2248                &mant_protocol::QueryView::Search {
2249                    pattern: pattern.to_owned(),
2250                    syntax: mant_protocol::SearchSyntax::Literal,
2251                    case: mant_protocol::SearchCase::Sensitive,
2252                    scope: mant_protocol::SearchScope::Visible,
2253                    word: false,
2254                    context_lines: 0,
2255                    limit: 100,
2256                    offset: 0,
2257                },
2258            )
2259            .expect("search link fixture");
2260            let crate::QueryViewResult::Search(search) = result else {
2261                panic!("expected search result");
2262            };
2263            assert_eq!(search.total, 1, "pattern={pattern:?}");
2264        }
2265    }
2266
2267    #[test]
2268    fn resolves_mdoc_section_references_and_explicit_targets() {
2269        let path = temporary_source(
2270            "mdoc-navigation",
2271            ".Dd July 19, 2026\n\
2272             .Dt NAVIGATION 1\n\
2273             .Os\n\
2274             .Sh DESCRIPTION\n\
2275             Continue with\n\
2276             .Sx DETAILS\n\
2277             .Tg explicit-option\n\
2278             .Fl x\n\
2279             .Sh DETAILS\n\
2280             Target content.\n",
2281        );
2282
2283        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
2284        fs::remove_file(path).expect("remove temporary roff fixture");
2285
2286        assert_eq!(document.sections[0].id, "description-1");
2287        assert_eq!(document.sections[1].id, "details-2");
2288        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2289            panic!("expected navigation paragraph");
2290        };
2291        assert!(children.iter().any(|inline| matches!(
2292            inline,
2293            Inline::Link {
2294                target: mant_ir::LinkTarget::Section { id },
2295                children,
2296                ..
2297            } if id == "details-2" && inline_text(children) == "DETAILS"
2298        )));
2299        assert!(children.iter().any(|inline| matches!(
2300            inline,
2301            Inline::Anchor { id } if id == "explicit-option"
2302        )));
2303    }
2304
2305    #[test]
2306    fn resolves_a_unique_parenthetically_qualified_mdoc_section_reference() {
2307        let path = temporary_source(
2308            "mdoc-qualified-navigation",
2309            ".Dd July 19, 2026\n\
2310             .Dt NAVIGATION 1\n\
2311             .Os\n\
2312             .Sh DESCRIPTION\n\
2313             See\n\
2314             .Sx White Space Splitting\n\
2315             .Sh \"White Space Splitting (Field Splitting)\"\n\
2316             Target content.\n",
2317        );
2318
2319        let document = parse_manual_source(&path).expect("lower qualified navigation source");
2320        fs::remove_file(path).expect("remove temporary roff fixture");
2321
2322        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2323            panic!("expected navigation paragraph");
2324        };
2325        assert!(children.iter().any(|inline| matches!(
2326            inline,
2327            Inline::Link {
2328                target: mant_ir::LinkTarget::Section { id },
2329                children,
2330                ..
2331            } if id == "white-space-splitting-field-splitting-2"
2332                && inline_text(children) == "White Space Splitting"
2333        )));
2334        assert!(document.diagnostics.iter().all(|diagnostic| {
2335            diagnostic.code.as_deref() != Some("unresolved-section-reference")
2336        }));
2337    }
2338
2339    #[test]
2340    fn degrades_unresolved_mdoc_section_references_to_text() {
2341        let path = temporary_source(
2342            "mdoc-missing-section",
2343            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
2344        );
2345
2346        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
2347        fs::remove_file(path).expect("remove temporary roff fixture");
2348
2349        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2350            panic!("expected reference paragraph");
2351        };
2352        assert_eq!(inline_text(children), "MISSING");
2353        assert!(children.iter().all(|inline| !matches!(
2354            inline,
2355            Inline::Link {
2356                target: mant_ir::LinkTarget::Section { .. },
2357                ..
2358            }
2359        )));
2360        assert!(document.diagnostics.iter().any(|diagnostic| {
2361            diagnostic.code.as_deref() == Some("unresolved-section-reference")
2362        }));
2363    }
2364
2365    #[test]
2366    fn turns_captured_parser_findings_into_structured_diagnostics() {
2367        let path = temporary_source(
2368            "unsupported",
2369            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
2370        );
2371
2372        let document = parse_manual_source(&path).expect("best-effort parse");
2373        fs::remove_file(path).expect("remove temporary roff fixture");
2374
2375        assert!(
2376            document
2377                .diagnostics
2378                .iter()
2379                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
2380        );
2381    }
2382
2383    #[test]
2384    fn masks_terminal_controls_before_native_parsing() {
2385        let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
2386
2387        let document = parse_manual_source(&path).expect("parse sanitized manual");
2388        fs::remove_file(path).expect("remove temporary roff fixture");
2389
2390        assert!(
2391            document.diagnostics.iter().any(|diagnostic| {
2392                diagnostic.code.as_deref() == Some("manual.control-characters")
2393            })
2394        );
2395    }
2396
2397    #[test]
2398    fn lowers_normalized_ordered_lists_and_literal_displays() {
2399        let path = temporary_source(
2400            "normalized",
2401            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
2402             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
2403             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
2404        );
2405
2406        let document = parse_manual_source(&path).expect("lower normalized mdoc");
2407        fs::remove_file(path).expect("remove temporary roff fixture");
2408
2409        assert!(matches!(
2410            document.sections[0].blocks[0],
2411            Block::List {
2412                kind: mant_ir::ListKind::Ordered,
2413                compact: true,
2414                ..
2415            }
2416        ));
2417        assert!(matches!(
2418            document.sections[0].blocks[1],
2419            Block::Preformatted { layout, .. } if layout.indent_columns == 6
2420        ));
2421    }
2422
2423    #[test]
2424    fn lowers_normalized_mdoc_font_and_author_layout() {
2425        let path = temporary_source(
2426            "normalized-mdoc-modes",
2427            ".Dd July 19, 2026\n\
2428             .Dt NORMALIZED-MODES 1\n\
2429             .Os\n\
2430             .Sh AUTHORS\n\
2431             .An -split\n\
2432             .An Alice Example\n\
2433             .An Bob Example\n\
2434             .An -nosplit\n\
2435             .An Carol Example\n\
2436             .An Dave Example\n\
2437             .Sh DESCRIPTION\n\
2438             .Bf -literal\n\
2439             literal text\n\
2440             .Ef\n",
2441        );
2442
2443        let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
2444        fs::remove_file(path).expect("remove temporary roff fixture");
2445
2446        let authors = &document.sections[0];
2447        let Block::Paragraph { children, .. } = &authors.blocks[0] else {
2448            panic!("authors are one paragraph");
2449        };
2450        assert_eq!(
2451            inline_text(children),
2452            "Alice Example\nBob Example Carol Example Dave Example"
2453        );
2454
2455        let description = &document.sections[1];
2456        let Block::Paragraph { children, .. } = &description.blocks[0] else {
2457            panic!("font block is a paragraph");
2458        };
2459        assert!(matches!(
2460            children.as_slice(),
2461            [Inline::Code { value }] if value == "literal text"
2462        ));
2463    }
2464
2465    #[test]
2466    fn mdoc_definition_layout_uses_the_normalized_list_width() {
2467        let path = temporary_source(
2468            "mdoc-definition-widths",
2469            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
2470             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
2471             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
2472        );
2473
2474        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
2475        fs::remove_file(path).expect("remove temporary roff fixture");
2476
2477        let lists = document.sections[0]
2478            .blocks
2479            .iter()
2480            .filter_map(|block| match block {
2481                Block::DefinitionList { items, .. } => Some(items),
2482                _ => None,
2483            })
2484            .collect::<Vec<_>>();
2485        assert_eq!(lists.len(), 2);
2486        assert!(lists[0][0].inline_term);
2487        assert!(!lists[1][0].inline_term);
2488    }
2489
2490    #[test]
2491    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
2492        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2493            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
2494        if !source.exists() {
2495            // The repository supplies this separately licensed cross-crate
2496            // fixture; the published mant-engine package is self-contained.
2497            return;
2498        }
2499
2500        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
2501
2502        assert!(document.sections.len() > 5);
2503        assert!(
2504            document
2505                .sections
2506                .iter()
2507                .any(|section| section.title == "DESCRIPTION")
2508        );
2509        assert!(
2510            document
2511                .sections
2512                .iter()
2513                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
2514        );
2515    }
2516
2517    #[test]
2518    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
2519        let path = temporary_source(
2520            "table-equation",
2521            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
2522             .SH EQUATION\n.EQ\nx + {width over 2}\n.EN\n",
2523        );
2524
2525        let document = parse_manual_source(&path).expect("lower table and equation");
2526        fs::remove_file(path).expect("remove temporary roff fixture");
2527
2528        assert!(matches!(
2529            document.sections[0].blocks[0],
2530            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
2531        ));
2532        assert!(matches!(
2533            document.sections[1].blocks[0],
2534            Block::Equation { ref value, .. } if value == "x + width / 2"
2535        ));
2536    }
2537
2538    #[test]
2539    fn large_tbl_rows_scale_without_changing_their_topology() {
2540        const ROW_COUNT: usize = 2_048;
2541        let mut source = String::from(".TH TABLE-SCALE 7\n.SH TABLE\n.TS\nl l.\n");
2542        for index in 0..ROW_COUNT {
2543            writeln!(source, "left {index}\tright {index}").expect("append table row");
2544        }
2545        source.push_str(".TE\n");
2546
2547        let document = parse_manual_bytes(std::path::Path::new("table-scale.7"), source.as_bytes())
2548            .expect("lower large table");
2549
2550        let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2551            panic!("large tbl input must remain one table");
2552        };
2553        assert_eq!(rows.len(), ROW_COUNT);
2554        assert!(matches!(
2555            rows.first().and_then(|row| row.cells.first()),
2556            Some(mant_ir::TableCell { blocks, .. })
2557                if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2558                    if inline_text(children) == "left 0")
2559        ));
2560        assert!(matches!(
2561            rows.last().and_then(|row| row.cells.get(1)),
2562            Some(mant_ir::TableCell { blocks, .. })
2563                if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2564                    if inline_text(children) == format!("right {}", ROW_COUNT - 1))
2565        ));
2566    }
2567
2568    #[test]
2569    fn keeps_inline_equations_in_macro_arguments_and_filled_prose() {
2570        let document = parse_manual_bytes(
2571            std::path::Path::new("inline-equation.7"),
2572            b".TH EQNPROBE2 7\n.SH DESCRIPTION\n.EQ\ndelim $$\n.EN\n.TP\n.BR Dp\\~ \"$dx sub 1 ~ ldots ~ dx sub n$\"\nDraw a polygon with,\nfor $i = 1 , ldots , n + 1$,\nits vertex.\n",
2573        )
2574        .expect("lower inline equations");
2575
2576        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
2577            panic!(
2578                "expected one definition list: {:?}",
2579                document.sections[0].blocks
2580            );
2581        };
2582        let [item] = items.as_slice() else {
2583            panic!("expected one equation definition");
2584        };
2585        assert_eq!(inline_text(&item.terms[0]), "Dp dx _ 1 ... dx _ n");
2586        let [Block::Paragraph { children, .. }] = item.description.as_slice() else {
2587            panic!("expected one filled description: {:?}", item.description);
2588        };
2589        assert_eq!(
2590            inline_text(children),
2591            "Draw a polygon with, for i = 1 , ... , n + 1, its vertex."
2592        );
2593        assert!(children.iter().any(
2594            |child| matches!(child, Inline::Code { value } if value == "i = 1 , ... , n + 1")
2595        ));
2596    }
2597
2598    #[test]
2599    fn normalizes_inline_equations_retained_as_tbl_cell_text() {
2600        let document = parse_manual_bytes(
2601            std::path::Path::new("table-inline-equation.3"),
2602            b".TH TABLE-EQN 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl l.\n%0%\tfor values in % [ 0 , ~pi over 2 ]%\n.TE\n",
2603        )
2604        .expect("lower table equations");
2605
2606        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2607            panic!("expected equation table");
2608        };
2609        let [left, right] = rows[0].cells.as_slice() else {
2610            panic!("expected two cells");
2611        };
2612        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2613            panic!("expected left paragraph");
2614        };
2615        let [
2616            Block::Paragraph {
2617                children: right, ..
2618            },
2619        ] = right.blocks.as_slice()
2620        else {
2621            panic!("expected right paragraph");
2622        };
2623        assert!(matches!(left.as_slice(), [Inline::Code { value }] if value == "0"));
2624        assert_eq!(inline_text(right), "for values in [ 0 , π / 2 ]");
2625        assert!(
2626            right
2627                .iter()
2628                .any(|child| matches!(child, Inline::Code { .. }))
2629        );
2630    }
2631
2632    #[test]
2633    fn bounds_distinct_tbl_equation_normalization_work() {
2634        let mut source =
2635            String::from(".TH TABLE-EQN-BUDGET 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl.\n");
2636        for index in 0..=MAX_INLINE_EQUATION_NORMALIZATIONS {
2637            writeln!(source, "%x{index}%").expect("write fixture row");
2638        }
2639        source.push_str(".TE\n");
2640
2641        let document = parse_manual_bytes(
2642            std::path::Path::new("table-inline-equation-budget.3"),
2643            source.as_bytes(),
2644        )
2645        .expect("lower a bounded number of table equations");
2646
2647        assert!(document.diagnostics.iter().any(|diagnostic| {
2648            diagnostic.code.as_deref() == Some("manual.inline-equation-budget")
2649        }));
2650        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2651            panic!("expected equation table");
2652        };
2653        assert_eq!(rows.len(), MAX_INLINE_EQUATION_NORMALIZATIONS + 1);
2654    }
2655
2656    #[test]
2657    fn preserves_tbl_rows_across_interleaved_comments_and_text_blocks() {
2658        let source = b".TH COMMENTED-TABLE 1\n.SH TABLE\n.TS\nl l.\na\t1\n.\\\" disabled text block T{\n.\\\" ignored\n.\\\" T}\nb\t2\nc\t3\nT{\n.BR d (1)\nT}\t4\ne\t5\n.TE\n";
2659        let document = parse_manual_bytes(std::path::Path::new("commented-table.1"), source)
2660            .expect("lower commented table");
2661
2662        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2663            panic!("expected a table");
2664        };
2665        assert_eq!(rows.len(), 5);
2666        let first_cells = rows
2667            .iter()
2668            .map(|row| match row.cells[0].blocks.as_slice() {
2669                [Block::Paragraph { children, .. }] => inline_text(children),
2670                cells => panic!("expected one paragraph per table cell: {cells:?}"),
2671            })
2672            .collect::<Vec<_>>();
2673        assert_eq!(first_cells, ["a", "b", "c", "d(1)", "e"]);
2674    }
2675
2676    #[test]
2677    fn keeps_multiline_cells_aligned_after_an_empty_text_block() {
2678        let source = b".TH EMPTY-TABLE-CELL 7\n.SH TABLE\n.TS\ntab(@);\nl l l.\n\
2679T{\nT}@T{\nCore\nT}@T{\nProduction-grade, first-class\nT}\n.TE\n";
2680        let document = parse_manual_bytes(std::path::Path::new("empty-table-cell.7"), source)
2681            .expect("lower a row beginning with an empty text block");
2682
2683        let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2684            panic!("expected one table");
2685        };
2686        let [row] = rows.as_slice() else {
2687            panic!("expected one table row");
2688        };
2689        let values = row
2690            .cells
2691            .iter()
2692            .map(|cell| match cell.blocks.as_slice() {
2693                [Block::Paragraph { children, .. }] => inline_text(children),
2694                [] => String::new(),
2695                blocks => panic!("unexpected table cell blocks: {blocks:?}"),
2696            })
2697            .collect::<Vec<_>>();
2698        assert_eq!(values, ["", "Core", "Production-grade, first-class"]);
2699    }
2700
2701    #[test]
2702    fn keeps_tbl_vertical_span_markers_out_of_visible_cells() {
2703        let document = parse_manual_bytes(
2704            std::path::Path::new("vertical-table-span.1"),
2705            b".TH VERTICAL-TABLE-SPAN 1\n.SH ATTRIBUTES\n.TS\nl l l.\nInterface\tAttribute\tValue\nT{\n.BR demo (1)\nT}\tThread safety\tMT-Safe\n\\^\tAsync-signal safety\tAS-Unsafe\n\\^\tAsync-cancel safety\tAC-Unsafe\n.TE\n",
2706        )
2707        .expect("lower vertical table span");
2708
2709        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2710            panic!("expected a table");
2711        };
2712        assert_eq!(rows.len(), 4);
2713        assert_eq!(rows[1].cells[0].row_span, 3);
2714        assert!(rows[2].cells[0].blocks.is_empty());
2715        assert!(rows[3].cells[0].blocks.is_empty());
2716    }
2717
2718    #[test]
2719    fn preserves_tbl_rows_nested_in_unfilled_mdoc_displays() {
2720        let document = parse_manual_bytes(
2721            std::path::Path::new("unfilled-table.7"),
2722            b".Dd August 19, 2026\n.Dt UNFILLED-TABLE 7\n.Os\n.Sh DESCRIPTION\n\
2723.Bd -unfilled -offset indent\n.TS\ntab(@);\nl l.\nleft@right\nnext@value\n.TE\n.Ed\n",
2724        )
2725        .expect("lower table nested in an unfilled display");
2726
2727        let table = document.sections[0]
2728            .blocks
2729            .iter()
2730            .find_map(|block| match block {
2731                Block::Table { rows, .. } => Some(rows),
2732                _ => None,
2733            })
2734            .expect("nested table must remain structured");
2735        assert_eq!(table.len(), 2);
2736        assert_eq!(table[0].cells.len(), 2);
2737        assert!(
2738            document.sections[0]
2739                .blocks
2740                .iter()
2741                .all(|block| !matches!(block, Block::Preformatted { children, .. } if children.is_empty())),
2742            "the surrounding display must not leave an empty placeholder"
2743        );
2744    }
2745
2746    #[test]
2747    fn keeps_unexpanded_tabular_cells_visible_with_a_diagnostic() {
2748        let document = parse_manual_bytes(
2749            std::path::Path::new("unexpanded-table-cell.7"),
2750            b".TH UNEXPANDED-TABLE-CELL 7\n.SH DESCRIPTION\n.TS\nl l.\n1\t\\*[unknown-label]\n.TE\n",
2751        )
2752        .expect("lower unresolved formatter string in a table cell");
2753
2754        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2755            panic!("expected a structured table");
2756        };
2757        assert_eq!(rows[0].cells.len(), 2);
2758        let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2759            panic!("expected one recovered table-cell paragraph");
2760        };
2761        assert_eq!(inline_text(children), r"\*[unknown-label]");
2762        assert!(document.diagnostics.iter().any(|diagnostic| {
2763            diagnostic.level == DiagnosticLevel::Unsupported
2764                && diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell")
2765        }));
2766    }
2767
2768    #[test]
2769    fn restores_mdoc_names_inside_tbl_text_blocks() {
2770        let document = parse_manual_bytes(
2771            std::path::Path::new("table-text-block.3"),
2772            b".Dd August 19, 2026\n.Dt TABLE-TEXT-BLOCK 3\n.Os\n\
2773.Sh NAME\n.Nm table-text-block\n.Nd test tbl text blocks\n\
2774.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\n\
2775T{\n.Nm\nT}\tMT-Safe\n.TE\n",
2776        )
2777        .expect("lower tbl text blocks");
2778
2779        let Block::Table { rows, .. } = &document.sections[1].blocks[0] else {
2780            panic!("expected attributes table");
2781        };
2782        let [Block::Paragraph { children, .. }] = rows[1].cells[0].blocks.as_slice() else {
2783            panic!("expected recovered name cell");
2784        };
2785        assert_eq!(inline_text(children), "table-text-block");
2786        assert!(matches!(children.as_slice(), [Inline::Strong { .. }]));
2787    }
2788
2789    #[test]
2790    fn keeps_semantic_links_inside_tbl_text_blocks() {
2791        let document = parse_manual_bytes(
2792            std::path::Path::new("table-text-link.1"),
2793            b".TH TABLE-TEXT-LINK 1\n\
2794.nr do-fallback 0\n\
2795.if !\\n(.f .nr do-fallback 1\n\
2796.if \\n[do-fallback] \\{\\\n\
2797.  de MR\n\
2798.    ie \\\\n(.$=1 \\\n\
2799.      I \\%\\\\$1\n\
2800.    el \\\n\
2801.      IR \\%\\\\$1 (\\\\$2)\\\\$3\n\
2802.  .\n\
2803.\\}\n\
2804.rr do-fallback\n\
2805.SH DESCRIPTION\n\
2806.TS\ntab($);\nl l.\ngrn$T{\nrenders\n.MR gremlin 1\ndiagrams;\nT}\n\
2807gperl$T{\npopulates\n.I groff\nregisters using\n.MR perl 1 ;\nT}\n.TE\n",
2808        )
2809        .expect("lower semantic tbl text block");
2810
2811        let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2812            panic!("semantic table content must not escape into a separate paragraph");
2813        };
2814        let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2815            panic!("expected semantic table cell paragraph");
2816        };
2817        assert_eq!(inline_text(children), "renders gremlin(1) diagrams;");
2818        assert!(children.iter().any(|child| matches!(
2819            child,
2820            Inline::Link {
2821                target: mant_ir::LinkTarget::Manual { name, manual_section },
2822                ..
2823            } if name == "gremlin" && manual_section.as_deref() == Some("1")
2824        )));
2825        let [Block::Paragraph { children, .. }] = rows[1].cells[1].blocks.as_slice() else {
2826            panic!("expected styled semantic table cell paragraph");
2827        };
2828        assert_eq!(
2829            inline_text(children),
2830            "populates groff registers using perl(1);"
2831        );
2832        assert!(
2833            children
2834                .iter()
2835                .any(|child| matches!(child, Inline::Emphasis { .. }))
2836        );
2837    }
2838
2839    #[test]
2840    fn restores_alternating_font_arguments_inside_tbl_text_blocks() {
2841        let document = parse_manual_bytes(
2842            std::path::Path::new("table-text-alternation.7"),
2843            b".TH TABLE-TEXT-ALTERNATION 7\n.SH DESCRIPTION\n.TS\nl l.\nT{\n\
2844.BI \\[aq] s1 \\[aq] s2 \\[aq]\nT}\tT{\n\
2845.I s1\nproduces the same formatted output as\n.IR s2 .\nT}\n.TE\n",
2846        )
2847        .expect("lower alternating man macros inside a tbl text block");
2848
2849        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2850            panic!("expected a structured table");
2851        };
2852        let [left, right] = rows[0].cells.as_slice() else {
2853            panic!("expected both reconstructed table cells");
2854        };
2855        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2856            panic!("expected a reconstructed left table-cell paragraph");
2857        };
2858        let [
2859            Block::Paragraph {
2860                children: right, ..
2861            },
2862        ] = right.blocks.as_slice()
2863        else {
2864            panic!("expected a reconstructed right table-cell paragraph");
2865        };
2866        assert_eq!(inline_text(left), "'s1's2'");
2867        assert_eq!(
2868            inline_text(right),
2869            "s1 produces the same formatted output as s2."
2870        );
2871        assert!(
2872            right
2873                .iter()
2874                .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2875        );
2876    }
2877
2878    #[test]
2879    fn restores_nested_mdoc_requests_inside_tbl_text_blocks() {
2880        let document = parse_manual_bytes(
2881            std::path::Path::new("table-mdoc-requests.8"),
2882            b".Dd August 19, 2026\n.Dt TABLE-MDOC-REQUESTS 8\n.Os\n.Sh DESCRIPTION\n\
2883.TS\ntab(@);\nl l.\nT{\n.Cm sip Ar addr Ns Op / Ns Ar mask\nT}@T{\n\
2884bitwise and of the address with\n.Ar mask\nequals\n.Ar addr .\n.Ar addr\n\
2885can be an IPv4 or IPv6 address.\nT}\n.TE\n",
2886        )
2887        .expect("lower nested mdoc requests in table text blocks");
2888
2889        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2890            panic!("expected a structured table");
2891        };
2892        let [left, right] = rows[0].cells.as_slice() else {
2893            panic!("expected two reconstructed table cells");
2894        };
2895        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2896            panic!("expected reconstructed selector cell");
2897        };
2898        let [
2899            Block::Paragraph {
2900                children: right, ..
2901            },
2902        ] = right.blocks.as_slice()
2903        else {
2904            panic!("expected reconstructed description cell");
2905        };
2906        assert_eq!(inline_text(left), "sip addr[/mask]");
2907        assert_eq!(
2908            inline_text(right),
2909            "bitwise and of the address with mask equals addr. addr can be an IPv4 or IPv6 address."
2910        );
2911        assert!(
2912            left.iter()
2913                .any(|inline| matches!(inline, Inline::Strong { .. }))
2914        );
2915        assert!(
2916            right
2917                .iter()
2918                .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2919        );
2920    }
2921
2922    #[test]
2923    fn keeps_command_names_in_extended_mdoc_synopsis_terms() {
2924        let document = parse_manual_bytes(
2925            std::path::Path::new("extended-synopsis.8"),
2926            b".Dd August 19, 2026\n.Dt EXTENDED-SYNOPSIS 8\n.Os\n.Sh NAME\n\
2927.Nm zinject\n.Nd inject faults\n.Sh SYNOPSIS\n.Bl -tag -width Ds\n\
2928.It Xo\n.Nm zinject\n.Xc\nList injections.\n\
2929.It Xo\n.Nm zinject\n.Fl b Ar bookmark\n.Xc\nInject a bookmark.\n.El\n",
2930        )
2931        .expect("lower extended mdoc synopsis terms");
2932
2933        let Block::DefinitionList { items, .. } = &document.sections[1].blocks[0] else {
2934            panic!("expected synopsis definition list");
2935        };
2936        assert_eq!(inline_text(&items[0].terms[0]), "zinject");
2937        assert_eq!(inline_text(&items[1].terms[0]), "zinject -b bookmark");
2938        assert!(matches!(
2939            items[0].terms[0].as_slice(),
2940            [Inline::Strong { .. }]
2941        ));
2942        assert!(
2943            items[1].terms[0]
2944                .iter()
2945                .any(|inline| matches!(inline, Inline::Strong { .. }))
2946        );
2947    }
2948
2949    #[test]
2950    fn decodes_named_characters_inside_equations() {
2951        let document = parse_manual_bytes(
2952            std::path::Path::new("equation-characters.1"),
2953            b".TH EQUATION-CHARACTERS 1\n.SH EQUATION\n.EQ\n\\[*p] \\[mi] x\n.EN\n",
2954        )
2955        .expect("lower equation characters");
2956
2957        assert!(matches!(
2958            document.sections[0].blocks[0],
2959            Block::Equation { ref value, .. } if value == "\u{03c0} \u{2212} x"
2960        ));
2961    }
2962
2963    #[test]
2964    fn lowers_every_mdoc_column_list_cell() {
2965        let document = parse_manual_bytes(
2966            std::path::Path::new("columns.3"),
2967            b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
2968.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
2969        )
2970        .expect("lower mdoc column list");
2971
2972        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2973            panic!("expected column list to lower as a table");
2974        };
2975        assert_eq!(rows.len(), 1);
2976        assert_eq!(rows[0].cells.len(), 3);
2977        let rendered = rows[0]
2978            .cells
2979            .iter()
2980            .map(|cell| match cell.blocks.as_slice() {
2981                [Block::Paragraph { children, .. }] => inline_text(children),
2982                blocks => panic!("expected one paragraph per cell, got {blocks:?}"),
2983            })
2984            .collect::<Vec<_>>();
2985        assert_eq!(
2986            rendered,
2987            ["CLSET_TIMEOUT", "struct timeval *", "set total timeout"]
2988        );
2989    }
2990
2991    #[test]
2992    fn preserves_nested_mdoc_spacing_state_in_definition_terms() {
2993        let document = parse_manual_bytes(
2994            std::path::Path::new("nested-spacing.1"),
2995            b".Dd August 19, 2026\n.Dt NESTED-SPACING 1\n.Os\n.Sh OPTIONS\n\
2996.Bl -tag -width Ds\n.It Fl L Xo\n.Sm off\n.Ar local_socket : host : hostport\n.Sm on\n.Xc\nForward a socket.\n.El\n",
2997        )
2998        .expect("lower nested mdoc spacing controls");
2999
3000        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3001            panic!("expected an option definition list");
3002        };
3003        assert_eq!(
3004            inline_text(&items[0].terms[0]),
3005            "-L local_socket:host:hostport"
3006        );
3007    }
3008
3009    #[test]
3010    fn carries_mdoc_spacing_state_into_display_lines() {
3011        let document = parse_manual_bytes(
3012            std::path::Path::new("display-spacing.8"),
3013            b".Dd August 24, 2026\n.Dt DISPLAY-SPACING 8\n.Os\n.Sh FORMAT\n\
3014.Sm off\n.D1 Ar name : uid : gid\n.Sm on\n",
3015        )
3016        .expect("lower display-scoped mdoc spacing controls");
3017
3018        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
3019            panic!("expected one display line");
3020        };
3021        assert_eq!(inline_text(children), "name:uid:gid");
3022    }
3023
3024    #[test]
3025    fn carries_mdoc_spacing_state_across_list_item_boundaries() {
3026        let document = parse_manual_bytes(
3027            std::path::Path::new("list-spacing.8"),
3028            b".Dd August 19, 2026\n.Dt LIST-SPACING 8\n.Os\n.Sh COMMANDS\n\
3029.Bl -tag -width Ds\n.Sm off\n.It Ic O Ar device\n.Sm on\n.It Ic done\nFinished.\n.El\n",
3030        )
3031        .expect("lower list-scoped mdoc spacing controls");
3032
3033        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3034            panic!("expected a command definition list");
3035        };
3036        assert_eq!(inline_text(&items[0].terms[0]), "Odevice");
3037        assert_eq!(inline_text(&items[1].terms[0]), "done");
3038    }
3039
3040    #[test]
3041    fn carries_mdoc_spacing_state_out_of_nested_synopsis_enclosures() {
3042        let document = parse_manual_bytes(
3043            std::path::Path::new("nested-synopsis-spacing.8"),
3044            b".Dd August 19, 2026\n.Dt NESTED-SYNOPSIS-SPACING 8\n.Os\n.Sh SYNOPSIS\n\
3045.Nm demo\n.Sm off\n.Oo Fl m\\~\n.Ar memory\n.Sm on\n.Oc\n\
3046.Op Fl o Ar variable Ns Cm = Ns Ar value\n.Ar name\n",
3047        )
3048        .expect("lower nested synopsis spacing transitions");
3049
3050        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
3051            panic!("expected synopsis paragraph");
3052        };
3053        assert_eq!(
3054            inline_text(children),
3055            "demo [-m memory] [-o variable=value] name"
3056        );
3057    }
3058
3059    #[test]
3060    fn preserves_the_boundary_that_enters_a_compact_mdoc_term() {
3061        let document = parse_manual_bytes(
3062            std::path::Path::new("spacing-transition.5"),
3063            b".Dd August 19, 2026\n.Dt SPACING-TRANSITION 5\n.Os\n.Sh KEYWORDS\n\
3064.Bl -tag -width Ds\n.It Xo\n.Cm @newuser\n.Sm off\n.Ar name : uid : gid\n.Sm on\n.Xc\nCreate a user.\n.El\n",
3065        )
3066        .expect("lower an mdoc spacing transition inside a term");
3067
3068        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3069            panic!("expected a keyword definition list");
3070        };
3071        assert_eq!(inline_text(&items[0].terms[0]), "@newuser name:uid:gid");
3072    }
3073
3074    #[test]
3075    fn separates_alternative_terms_in_an_extended_mdoc_definition_head() {
3076        let document = parse_manual_bytes(
3077            std::path::Path::new("extended-term-alternatives.8"),
3078            b".Dd August 19, 2026\n.Dt EXTENDED-TERM-ALTERNATIVES 8\n.Os\n.Sh OPTIONS\n\
3079.Bl -tag -width Ds\n.It Xo\n.Sm off\n.Ar ipaddr\n.Op / Ar masklen\n.Pp\n\
3080.Ar ipaddr\n.Op / Ar prefixlen\n.Sm on\n.Xc\nAccept this peer.\n.El\n",
3081        )
3082        .expect("lower alternative extended definition terms");
3083
3084        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3085            panic!("expected a definition list");
3086        };
3087        assert_eq!(items.len(), 1);
3088        assert_eq!(items[0].terms.len(), 2);
3089        assert_eq!(inline_text(&items[0].terms[0]), "ipaddr[/masklen]");
3090        assert_eq!(inline_text(&items[0].terms[1]), "ipaddr[/prefixlen]");
3091    }
3092
3093    fn inline_text(children: &[Inline]) -> String {
3094        children
3095            .iter()
3096            .map(|child| match child {
3097                Inline::Text { value } | Inline::Code { value } => value.clone(),
3098                Inline::Strong { children }
3099                | Inline::Emphasis { children }
3100                | Inline::Link { children, .. } => inline_text(children),
3101                Inline::Anchor { .. } => String::new(),
3102                Inline::LineBreak => "\n".to_owned(),
3103            })
3104            .collect()
3105    }
3106}