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_man_synopsis_flow_and_alternating_fonts() {
743        let path = temporary_source(
744            "man-synopsis-flow",
745            ".TH MAN 1\n\
746             .SH SYNOPSIS\n\
747             .B man\n\
748             .RI [\\| \"man options\" \\|]\n\
749             .RI [\\|[\\| section \\|]\n\
750             .IR page \\ \\|.\\|.\\|.\\|]\\ \\.\\|.\\|.\\&\n\
751             .br\n\
752             .B man\n\
753             .B \\-k\n\
754             .RI [\\| \"apropos options\" \\|]\n\
755             .I regexp\n\
756             \\&.\\|.\\|.\\&\n\
757             .br\n\
758             .B man\n\
759             .BR \\-w \\||\\| \\-W\n\
760             .RI [\\| \"man options\" \\|]\n\
761             .I page\n\
762             \\&.\\|.\\|.\\&\n",
763        );
764
765        let document = parse_manual_source(&path).expect("lower man synopsis");
766        fs::remove_file(path).expect("remove temporary roff fixture");
767
768        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
769            panic!("expected one synopsis paragraph");
770        };
771        assert_eq!(
772            inline_text(children),
773            "man [man options] [[section] page ...] ...\n\
774             man -k [apropos options] regexp ...\n\
775             man -w|-W [man options] page ..."
776        );
777        assert_eq!(
778            children
779                .iter()
780                .filter(|node| matches!(node, Inline::LineBreak))
781                .count(),
782            2
783        );
784        assert!(children.iter().any(
785            |node| matches!(node, Inline::Emphasis { children } if inline_text(children) == "man options")
786        ));
787        assert!(children.iter().any(
788            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-w")
789        ));
790        assert!(children.iter().any(
791            |node| matches!(node, Inline::Strong { children } if inline_text(children) == "-W")
792        ));
793    }
794
795    #[test]
796    fn preserves_man_sy_heads_with_body_content_and_inline_fonts() {
797        let document = parse_manual_bytes(
798            std::path::Path::new("sy-heads.1"),
799            b".TH SY-HEADS 1 \"August 17, 2026\"\n\
800.SH SYNOPSIS\n\
801.SY getent\n\
802.RI [ option ]\n\
803.I database\n\
804.YS\n\
805.SH DESCRIPTION\n\
806.SY #!\\f[I]interpreter\\f[]\n\
807.RI [ optional-arg ]\n\
808.YS\n",
809        )
810        .expect("lower SY heads");
811
812        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
813            panic!("expected one synopsis paragraph");
814        };
815        assert_eq!(inline_text(children), "getent [option] database");
816        assert!(matches!(
817            children.first(),
818            Some(Inline::Strong { children }) if inline_text(children) == "getent"
819        ));
820
821        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
822            panic!("expected one description paragraph");
823        };
824        assert_eq!(inline_text(children), "#!interpreter [optional-arg]");
825        assert!(matches!(
826            children.first(),
827            Some(Inline::Strong { children })
828                if children.iter().any(|inline| matches!(
829                    inline,
830                    Inline::Emphasis { children } if inline_text(children) == "interpreter"
831                ))
832        ));
833        assert!(
834            document.diagnostics.is_empty(),
835            "{:?}",
836            document.diagnostics
837        );
838    }
839
840    #[test]
841    fn keeps_man_synopsis_lines_together_inside_no_fill_examples() {
842        let document = parse_manual_bytes(
843            std::path::Path::new("no-fill-synopsis.2"),
844            b".TH NO-FILL-SYNOPSIS 2\n\
845.SH DESCRIPTION\n\
846.EX\n\
847.SY #!\\f[I]interpreter\\f[]\n\
848.RI [ optional-arg ]\n\
849.YS\n\
850.EE\n",
851        )
852        .expect("lower synopsis inside example");
853
854        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
855            panic!(
856                "no-fill synopsis must remain one preformatted block: {:?}",
857                document.sections[0].blocks
858            );
859        };
860        assert_eq!(inline_text(children), "#!interpreter\n[optional-arg]");
861        assert_eq!(
862            children
863                .iter()
864                .filter(|inline| matches!(inline, Inline::LineBreak))
865                .count(),
866            1
867        );
868    }
869
870    #[test]
871    fn preserves_explicit_blank_rows_inside_no_fill_displays() {
872        let document = parse_manual_bytes(
873            std::path::Path::new("no-fill-blank-row.7"),
874            b".TH NO-FILL-BLANK-ROW 7\n\
875.SH EXAMPLE\n\
876.EX\n\
877first line\n\
878\n\
879second line\n\
880.EE\n",
881        )
882        .expect("lower no-fill blank row");
883
884        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
885            panic!(
886                "no-fill display must remain preformatted: {:?}",
887                document.sections[0].blocks
888            );
889        };
890        assert_eq!(inline_text(children), "first line\n\nsecond line");
891        assert_eq!(
892            children
893                .iter()
894                .filter(|inline| matches!(inline, Inline::LineBreak))
895                .count(),
896            2
897        );
898    }
899
900    #[test]
901    fn preserves_zero_width_guard_rows_inside_no_fill_displays() {
902        let document = parse_manual_bytes(
903            std::path::Path::new("no-fill-zero-width-row.7"),
904            b".TH NO-FILL-ZERO-WIDTH-ROW 7\n\
905.SH EXAMPLE\n\
906.EX\n\
907first line\n\
908\\&\n\
909second line\n\
910.EE\n",
911        )
912        .expect("lower no-fill zero-width row");
913
914        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
915            panic!(
916                "no-fill display must remain preformatted: {:?}",
917                document.sections[0].blocks
918            );
919        };
920        assert_eq!(inline_text(children), "first line\n\nsecond line");
921        assert_eq!(
922            children
923                .iter()
924                .filter(|inline| matches!(inline, Inline::LineBreak))
925                .count(),
926            2
927        );
928    }
929
930    #[test]
931    fn preserves_lines_inside_font_blocks_nested_in_literal_displays() {
932        let document = parse_manual_bytes(
933            std::path::Path::new("literal-font-block.7"),
934            b".Dd August 20, 2026\n\
935.Dt LITERAL-FONT-BLOCK 7\n\
936.Os\n\
937.Sh EXAMPLE\n\
938.Bd -literal\n\
939.Bf Sy\n\
940first line\n\
941second line\n\
942.Ef\n\
943.Ed\n",
944        )
945        .expect("lower font block inside literal display");
946
947        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
948            panic!(
949                "literal display must remain one preformatted block: {:?}",
950                document.sections[0].blocks
951            );
952        };
953        assert_eq!(inline_text(children), "first line\nsecond line");
954        assert_eq!(
955            children
956                .iter()
957                .filter(|inline| matches!(inline, Inline::LineBreak))
958                .count(),
959            1
960        );
961    }
962
963    #[test]
964    fn preserves_literal_display_lines_inside_literal_font_blocks() {
965        let document = parse_manual_bytes(
966            std::path::Path::new("literal-display-inside-font-block.7"),
967            b".Dd August 21, 2026\n\
968.Dt LITERAL-DISPLAY-INSIDE-FONT-BLOCK 7\n\
969.Os\n\
970.Sh EXAMPLE\n\
971.Bf Li\n\
972.Bd -literal\n\
973first line\n\
974second line\n\
975.Ed\n\
976.Ef\n",
977        )
978        .expect("lower literal display inside literal font block");
979
980        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
981            panic!(
982                "fonted literal display must remain preformatted: {:?}",
983                document.sections[0].blocks
984            );
985        };
986        assert_eq!(inline_text(children), "first line\nsecond line");
987        assert_eq!(
988            children
989                .iter()
990                .filter(|inline| matches!(inline, Inline::LineBreak))
991                .count(),
992            1
993        );
994    }
995
996    #[test]
997    fn preserves_lines_inside_nested_literal_displays() {
998        let document = parse_manual_bytes(
999            std::path::Path::new("nested-literal-display.7"),
1000            b".Dd August 21, 2026\n\
1001.Dt NESTED-LITERAL-DISPLAY 7\n\
1002.Os\n\
1003.Sh EXAMPLE\n\
1004.Bd -literal\n\
1005first line\n\
1006.Bd -literal\n\
1007second line\n\
1008third line\n\
1009.Ed\n",
1010        )
1011        .expect("lower nested literal display");
1012
1013        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1014            panic!(
1015                "nested literal displays must remain one preformatted block: {:?}",
1016                document.sections[0].blocks
1017            );
1018        };
1019        assert_eq!(inline_text(children), "first line\nsecond line\nthird line");
1020        assert_eq!(
1021            children
1022                .iter()
1023                .filter(|inline| matches!(inline, Inline::LineBreak))
1024                .count(),
1025            2
1026        );
1027    }
1028
1029    #[test]
1030    fn collapses_a_no_fill_blank_line_run_to_one_visual_separator() {
1031        let document = parse_manual_bytes(
1032            std::path::Path::new("no-fill-blank-run.7"),
1033            b".TH NO-FILL-BLANK-RUN 7\n\
1034.SH EXAMPLE\n\
1035.EX\n\
1036first line\n\
1037\n\
1038\n\
1039second line\n\
1040.EE\n",
1041        )
1042        .expect("lower no-fill blank run");
1043
1044        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1045            panic!(
1046                "no-fill display must remain preformatted: {:?}",
1047                document.sections[0].blocks
1048            );
1049        };
1050        assert_eq!(inline_text(children), "first line\n\nsecond line");
1051        assert_eq!(
1052            children
1053                .iter()
1054                .filter(|inline| matches!(inline, Inline::LineBreak))
1055                .count(),
1056            2
1057        );
1058    }
1059
1060    #[test]
1061    fn adjacent_no_fill_regions_scale_without_changing_their_topology() {
1062        const REGION_COUNT: usize = 2_048;
1063        let mut source = String::from(".TH NO-FILL-SCALE 7\n.SH EXAMPLE\n");
1064        for index in 0..REGION_COUNT {
1065            writeln!(source, ".nf\nline {index}\n.fi").expect("append no-fill region");
1066        }
1067
1068        let document =
1069            parse_manual_bytes(std::path::Path::new("no-fill-scale.7"), source.as_bytes())
1070                .expect("lower adjacent no-fill regions");
1071
1072        let [Block::Preformatted { children, .. }] = document.sections[0].blocks.as_slice() else {
1073            panic!(
1074                "adjacent regions must remain one preformatted block: {:?}",
1075                document.sections[0].blocks
1076            );
1077        };
1078        assert_eq!(
1079            children
1080                .iter()
1081                .filter(|inline| matches!(inline, Inline::LineBreak))
1082                .count(),
1083            REGION_COUNT - 1
1084        );
1085        assert!(inline_text(children).starts_with("line 0\nline 1\n"));
1086        assert!(
1087            inline_text(children).ends_with(&format!("line {}", REGION_COUNT - 1)),
1088            "last no-fill region must remain visible"
1089        );
1090    }
1091
1092    #[test]
1093    fn distinguishes_filled_source_wrapping_from_indented_output_lines() {
1094        let path = temporary_source(
1095            "filled-line-boundaries",
1096            concat!(
1097                ".TH TOOL 1\n",
1098                ".SH SYNOPSIS\n",
1099                "tool [first]\n",
1100                "    [second]\n",
1101                "    [third]\n",
1102                ".PP\n",
1103                "Ordinary source wrapping\n",
1104                "remains one filled paragraph.\n",
1105            ),
1106        );
1107
1108        let document = parse_manual_source(&path).expect("lower filled line boundaries");
1109        fs::remove_file(path).expect("remove temporary roff fixture");
1110
1111        let [
1112            Block::Paragraph {
1113                children: synopsis, ..
1114            },
1115            Block::Paragraph {
1116                children: prose, ..
1117            },
1118        ] = document.sections[0].blocks.as_slice()
1119        else {
1120            panic!("expected synopsis and prose paragraphs");
1121        };
1122        assert_eq!(
1123            inline_text(synopsis),
1124            "tool [first]\n    [second]\n    [third]"
1125        );
1126        assert_eq!(
1127            synopsis
1128                .iter()
1129                .filter(|inline| matches!(inline, Inline::LineBreak))
1130                .count(),
1131            2
1132        );
1133        assert_eq!(
1134            inline_text(prose),
1135            "Ordinary source wrapping remains one filled paragraph."
1136        );
1137    }
1138
1139    #[test]
1140    fn honours_roff_no_space_line_continuations() {
1141        let document = parse_manual_bytes(
1142            std::path::Path::new("line-continuation.1"),
1143            b".TH LINE-CONTINUATION 1\n\
1144.SH DESCRIPTION\n\
1145extsize=\\c\n\
1146nnnn; multi-\\c\n\
1147block; (\\c\n\
1148.BR read (2)\n\
1149.EX\n\
1150literal-\\c\n\
1151continuation\n\
1152.EE\n",
1153        )
1154        .expect("lower no-space line continuations");
1155
1156        let [
1157            Block::Paragraph {
1158                children: prose, ..
1159            },
1160            Block::Preformatted {
1161                children: literal, ..
1162            },
1163        ] = document.sections[0].blocks.as_slice()
1164        else {
1165            panic!(
1166                "expected one filled and one no-fill block: {:?}",
1167                document.sections[0].blocks
1168            );
1169        };
1170        assert_eq!(inline_text(prose), "extsize=nnnn; multi-block; (read(2)");
1171        assert_eq!(inline_text(literal), "literal-continuation");
1172    }
1173
1174    #[test]
1175    fn keeps_explicit_horizontal_separation_at_a_tight_line_join() {
1176        let document = parse_manual_bytes(
1177            std::path::Path::new("motion-continuation.1"),
1178            b".TH MOTION-CONTINUATION 1\n\
1179.SH DESCRIPTION\n\
1180\\h'-04' 1.\\h'+01'\\c\n\
1181The next line.\n",
1182        )
1183        .expect("lower a horizontally spaced continued line");
1184
1185        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1186            panic!("expected one paragraph: {:?}", document.sections[0].blocks);
1187        };
1188        assert_eq!(inline_text(children), " 1. The next line.");
1189    }
1190
1191    #[test]
1192    fn lets_explicit_fonts_override_an_alternating_macro_default() {
1193        let path = temporary_source(
1194            "alternating-font-reset",
1195            ".TH MAN 1\n\
1196             .SH OPTIONS\n\
1197             .TP\n\
1198             .BI \\-r\\  prompt \\fR,\\ \\fB\\-\\-prompt= prompt\n\
1199             Set the pager prompt.\n",
1200        );
1201
1202        let document = parse_manual_source(&path).expect("lower alternating font reset");
1203        fs::remove_file(path).expect("remove temporary roff fixture");
1204
1205        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1206            panic!("expected one definition list");
1207        };
1208        let term = items[0]
1209            .terms
1210            .first()
1211            .expect("first definition term")
1212            .iter()
1213            .filter(|inline| !matches!(inline, Inline::Anchor { .. }))
1214            .collect::<Vec<_>>();
1215
1216        assert_eq!(term.len(), 5);
1217        assert!(matches!(term[0], Inline::Strong { children } if inline_text(children) == "-r "));
1218        assert!(
1219            matches!(term[1], Inline::Emphasis { children } if inline_text(children) == "prompt")
1220        );
1221        assert!(matches!(term[2], Inline::Text { value } if value == ", "));
1222        assert!(
1223            matches!(term[3], Inline::Strong { children } if inline_text(children) == "--prompt=")
1224        );
1225        assert!(
1226            matches!(term[4], Inline::Emphasis { children } if inline_text(children) == "prompt")
1227        );
1228    }
1229
1230    #[test]
1231    fn suppresses_pod_font_requests_around_verbatim_blocks() {
1232        let path = temporary_source(
1233            "pod-verbatim-fonts",
1234            ".de Vb\n\
1235             .ft CW\n\
1236             .nf\n\
1237             ..\n\
1238             .de Ve\n\
1239             .ft R\n\
1240             .fi\n\
1241             ..\n\
1242             .TH POD 1\n\
1243             .SH EXAMPLES\n\
1244             .Vb 2\n\
1245             \\&struct A { int a; };\n\
1246             \\&struct B : A {};\n\
1247             .Ve\n",
1248        );
1249
1250        let document = parse_manual_source(&path).expect("lower Pod::Man verbatim source");
1251        fs::remove_file(path).expect("remove temporary roff fixture");
1252
1253        assert_eq!(document.sections[0].blocks.len(), 1);
1254        let Block::Preformatted { children, .. } = &document.sections[0].blocks[0] else {
1255            panic!("expected one preformatted block");
1256        };
1257        assert_eq!(
1258            inline_text(children),
1259            "struct A { int a; };\nstruct B : A {};"
1260        );
1261    }
1262
1263    #[test]
1264    fn lowers_indented_aliases_without_roff_layout_arguments() {
1265        let path = temporary_source(
1266            "indented-aliases",
1267            ".TH CONTROL 1\n\
1268             .SH OPTIONS\n\
1269             .PD 0\n\
1270             .IP \"\\fB-a\\fR\" 4\n\
1271             .IP \"\\fB--all\\fR\" 4\n\
1272             Show all entries.\n\
1273             .PD\n\
1274             .in 168u\n",
1275        );
1276
1277        let document = parse_manual_source(&path).expect("lower indented aliases");
1278        fs::remove_file(path).expect("remove temporary roff fixture");
1279
1280        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1281            panic!("expected one definition list");
1282        };
1283        assert_eq!(items.len(), 1);
1284        assert_eq!(
1285            items[0]
1286                .terms
1287                .iter()
1288                .map(|term| inline_text(term))
1289                .collect::<Vec<_>>(),
1290            ["-a", "--all"]
1291        );
1292        assert_eq!(items[0].description.len(), 1);
1293        let Block::Paragraph { children, .. } = &items[0].description[0] else {
1294            panic!("expected alias description paragraph");
1295        };
1296        assert_eq!(inline_text(children), "Show all entries.");
1297    }
1298
1299    #[test]
1300    fn tq_terms_share_one_semantic_option_identity() {
1301        let path = temporary_source(
1302            "tq-aliases",
1303            ".TH TQ-ALIASES 7\n\
1304             .SH OPTIONS\n\
1305             .TP\n\
1306             .B \\-\\-alpha\n\
1307             .TQ\n\
1308             .B \\-a\n\
1309             .TQ\n\
1310             .B \\-\\-ALPHA\n\
1311             Enable alpha mode.\n",
1312        );
1313
1314        let document = parse_manual_source(&path).expect("lower TQ aliases");
1315        fs::remove_file(path).expect("remove temporary roff fixture");
1316        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
1317            panic!("expected one definition list");
1318        };
1319        assert_eq!(items.len(), 1);
1320        assert_eq!(
1321            items[0]
1322                .terms
1323                .iter()
1324                .map(|term| inline_text(term))
1325                .collect::<Vec<_>>(),
1326            ["-a", "--alpha", "--ALPHA"]
1327        );
1328        assert_eq!(
1329            items[0].identity.as_ref().expect("option identity").names,
1330            ["-a", "--alpha", "--ALPHA"]
1331        );
1332    }
1333
1334    #[test]
1335    fn preserves_man_paragraph_distance_between_indented_paragraphs() {
1336        let path = temporary_source(
1337            "paragraph-distance",
1338            ".TH SPACING 1\n\
1339             .SH OPTIONS\n\
1340             .IP \"\\fB-a\\fR\" 4\n\
1341             First.\n\
1342             .IP \"\\fB-b\\fR\" 4\n\
1343             Second.\n\
1344             .PD 0\n\
1345             .IP \"\\fB-c\\fR\" 4\n\
1346             Third.\n\
1347             .IP \"\\fB-d\\fR\" 4\n\
1348             Fourth.\n\
1349             .PD\n\
1350             .IP \"\\fB-e\\fR\" 4\n\
1351             Fifth.\n",
1352        );
1353
1354        let document = parse_manual_source(&path).expect("lower paragraph distance");
1355        fs::remove_file(path).expect("remove temporary roff fixture");
1356
1357        let [Block::DefinitionList { items, compact, .. }] = document.sections[0].blocks.as_slice()
1358        else {
1359            panic!("expected one definition list");
1360        };
1361        assert!(!compact);
1362        assert_eq!(items.len(), 5);
1363        assert_eq!(
1364            items
1365                .iter()
1366                .map(|item| item.spacing_before_lines)
1367                .collect::<Vec<_>>(),
1368            [Some(0), Some(1), Some(0), Some(0), Some(1)]
1369        );
1370    }
1371
1372    #[test]
1373    fn preserves_man_paragraph_and_heading_distance_as_one_layout_model() {
1374        let path = temporary_source(
1375            "vertical-layout",
1376            ".TH SPACING 1\n\
1377             .SH FIRST\n\
1378             First paragraph.\n\
1379             .PP\n\
1380             Second paragraph.\n\
1381             .SS CHILD\n\
1382             Child body.\n\
1383             .PD 0\n\
1384             .SS COMPACT\n\
1385             Compact child.\n\
1386             .SH NEXT\n\
1387             Next body.\n\
1388             .PD\n\
1389             .SH FINAL\n\
1390             Final body.\n",
1391        );
1392
1393        let document = parse_manual_source(&path).expect("lower vertical layout");
1394        fs::remove_file(path).expect("remove temporary roff fixture");
1395
1396        let [first, next, final_section] = document.sections.as_slice() else {
1397            panic!("expected three top-level sections");
1398        };
1399        assert_eq!(first.spacing_before_lines, 0);
1400        let [Block::Paragraph { .. }, Block::Paragraph { layout, .. }] = first.blocks.as_slice()
1401        else {
1402            panic!("expected two semantic paragraphs");
1403        };
1404        assert_eq!(layout.spacing_before_lines, 1);
1405
1406        let [child, compact] = first.children.as_slice() else {
1407            panic!("expected two subsections");
1408        };
1409        assert_eq!(child.spacing_before_lines, 1);
1410        assert_eq!(compact.spacing_before_lines, 0);
1411        assert_eq!(next.spacing_before_lines, 0);
1412        assert_eq!(final_section.spacing_before_lines, 1);
1413    }
1414
1415    #[test]
1416    fn does_not_duplicate_explicit_space_before_a_transparent_indent() {
1417        let path = temporary_source(
1418            "explicit-space-before-indent",
1419            ".TH SPACING 1\n\
1420             .SH CONTENT\n\
1421             Before.\n\
1422             .sp\n\
1423             .RS 4\n\
1424             After.\n\
1425             .RE\n",
1426        );
1427
1428        let document = parse_manual_source(&path).expect("lower explicit indented spacing");
1429        fs::remove_file(path).expect("remove temporary roff fixture");
1430
1431        let [
1432            Block::Paragraph { .. },
1433            Block::VerticalSpace { lines: 1, .. },
1434            Block::Paragraph { layout, .. },
1435        ] = document.sections[0].blocks.as_slice()
1436        else {
1437            panic!("expected prose, one explicit gap, and indented prose");
1438        };
1439        assert_eq!(layout.indent_columns, 4);
1440        assert_eq!(
1441            layout.spacing_before_lines, 0,
1442            "the explicit gap must not be repeated as wrapper boundary spacing",
1443        );
1444    }
1445
1446    #[test]
1447    fn preserves_mdoc_paragraph_and_heading_distance() {
1448        let path = temporary_source(
1449            "mdoc-vertical-layout",
1450            ".Dd July 19, 2026\n\
1451             .Dt SPACING 1\n\
1452             .Os\n\
1453             .Sh FIRST\n\
1454             First paragraph.\n\
1455             .Pp\n\
1456             Second paragraph.\n\
1457             .Ss CHILD\n\
1458             Child body.\n",
1459        );
1460
1461        let document = parse_manual_source(&path).expect("lower mdoc vertical layout");
1462        fs::remove_file(path).expect("remove temporary roff fixture");
1463
1464        let [first] = document.sections.as_slice() else {
1465            panic!("expected one top-level section");
1466        };
1467        assert_eq!(first.spacing_before_lines, 1);
1468        assert!(matches!(
1469            first.blocks.get(1),
1470            Some(Block::VerticalSpace { lines: 1, .. })
1471        ));
1472        assert_eq!(first.children[0].spacing_before_lines, 1);
1473    }
1474
1475    #[test]
1476    fn lowers_mdoc_semantic_inline_nodes_and_nested_sections() {
1477        let path = temporary_source(
1478            "mdoc",
1479            ".Dd July 19, 2026\n\
1480             .Dt MANT 1\n\
1481             .Os\n\
1482             .Sh DESCRIPTION\n\
1483             Use\n\
1484             .Nm mant\n\
1485             with\n\
1486             .Xr man 1\n\
1487             Read\n\
1488             .Lk https://example.test/docs \"the documentation\"\n\
1489             or contact\n\
1490             .Mt docs@example.test\n\
1491             .Ss Details\n\
1492             .Fl h\n",
1493        );
1494
1495        let document = parse_manual_source(&path).expect("lower mdoc source");
1496        fs::remove_file(path).expect("remove temporary roff fixture");
1497
1498        assert_eq!(document.source.format, SourceFormat::Mdoc);
1499        assert_eq!(document.sections[0].children[0].title, "Details");
1500        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1501            panic!("expected description paragraph");
1502        };
1503        assert!(
1504            children
1505                .iter()
1506                .any(|inline| matches!(inline, Inline::Strong { .. }))
1507        );
1508        assert!(
1509            children.iter().any(
1510                |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Manual { name, .. }, .. } if name == "man")
1511            )
1512        );
1513        assert!(children.iter().any(
1514            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::External { uri }, .. } if uri == "https://example.test/docs")
1515        ));
1516        assert!(children.iter().any(
1517            |inline| matches!(inline, Inline::Link { target: mant_ir::LinkTarget::Email { address }, .. } if address == "docs@example.test")
1518        ));
1519    }
1520
1521    #[test]
1522    fn retains_unlabelled_mdoc_link_targets_before_trailing_punctuation() {
1523        let document = parse_manual_bytes(
1524            std::path::Path::new("external-link.9"),
1525            b".Dd August 19, 2026\n.Dt EXTERNAL-LINK 9\n.Os\n.Sh DESCRIPTION\n.Lk https://example.test/books .\n",
1526        )
1527        .expect("lower an unlabelled mdoc external link");
1528
1529        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1530            panic!("expected one external-link paragraph");
1531        };
1532        assert_eq!(inline_text(children), "https://example.test/books.");
1533        assert!(matches!(
1534            children.as_slice(),
1535            [
1536                Inline::Link {
1537                    target: mant_ir::LinkTarget::External { uri },
1538                    children: link_children,
1539                    ..
1540                },
1541                Inline::Text { value },
1542            ] if uri == "https://example.test/books"
1543                && inline_text(link_children) == "https://example.test/books"
1544                && value == "."
1545        ));
1546    }
1547
1548    #[test]
1549    fn expands_mdoc_bsd_lifecycle_and_release_forms() {
1550        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";
1551        let document = parse_manual_bytes(std::path::Path::new("bsd-lifecycle.7"), source)
1552            .expect("lower mdoc BSD lifecycle forms");
1553
1554        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1555            panic!("expected one BSD lifecycle paragraph");
1556        };
1557        assert_eq!(
1558            inline_text(children),
1559            "BSD BSD (currently in alpha test) BSD (currently in beta test) BSD (currently under development). 4.3BSD. 4.3BSD Net/2. 386BSD 0.1."
1560        );
1561    }
1562
1563    #[test]
1564    fn preserves_complete_mdoc_include_directives() {
1565        let document = parse_manual_bytes(
1566            std::path::Path::new("include.3"),
1567            b".Dd August 19, 2026\n.Dt INCLUDE 3\n.Os\n.Sh SYNOPSIS\n.In fido.h\n",
1568        )
1569        .expect("lower mdoc include");
1570
1571        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1572            panic!("expected one include paragraph");
1573        };
1574        assert_eq!(inline_text(children), "#include <fido.h>");
1575        assert!(matches!(
1576            children.as_slice(),
1577            [Inline::Code { value }] if value == "#include <fido.h>"
1578        ));
1579    }
1580
1581    #[test]
1582    fn propagates_nested_no_space_and_preserves_prefix_content() {
1583        let document = parse_manual_bytes(
1584            std::path::Path::new("no-space.7"),
1585            b".Dd August 19, 2026\n.Dt NO-SPACE 7\n.Os\n.Sh DESCRIPTION\n\
1586.Em Bell Labs Ns -derived\n\
1587.Ar job Ns s :\n\
1588.Sm off\n\
1589.Pf [\\-]ddd Cm \\&. No ddd\n\
1590.Sm on\n",
1591        )
1592        .expect("lower nested no-space macros");
1593
1594        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1595            panic!("expected one no-space paragraph");
1596        };
1597        assert_eq!(inline_text(children), "Bell Labs-derived jobs: [-]ddd.ddd");
1598    }
1599
1600    #[test]
1601    fn lowers_documented_mdoc_delimiters_and_common_roff_characters() {
1602        let path = temporary_source(
1603            "mdoc-delimiters",
1604            ".Dd July 19, 2026\n\
1605             .Dt DELIMITERS 7\n\
1606             .Os\n\
1607             .Sh DESCRIPTION\n\
1608             .Op optional\n\
1609             .Bq bracket\n\
1610             .Dq double\n\
1611             .Sq single\n\
1612             .Pq parenthesized\n\
1613             .Brq braced\n\
1614             .Aq angled\n\
1615             .Oo multi Ar value\n\
1616             .Oc\n\
1617             .Sh CHARACTERS\n\
1618             \\(en \\(em \\(aq \\(dq \\(co \\(rg \\(tm \\(bu \\(ha \\(ti \\(rs\n",
1619        );
1620
1621        let document = parse_manual_source(&path).expect("lower delimiter and character source");
1622        fs::remove_file(path).expect("remove temporary roff fixture");
1623
1624        let description = document.sections[0]
1625            .blocks
1626            .iter()
1627            .map(|block| match block {
1628                Block::Paragraph { children, .. } => inline_text(children),
1629                _ => String::new(),
1630            })
1631            .collect::<Vec<_>>()
1632            .join(" ");
1633        for expected in [
1634            "[optional]",
1635            "[bracket]",
1636            "“double”",
1637            "‘single’",
1638            "(parenthesized)",
1639            "{braced}",
1640            "<angled>",
1641            "[multi value]",
1642        ] {
1643            assert!(
1644                description.contains(expected),
1645                "missing {expected:?} in {description:?}"
1646            );
1647        }
1648
1649        let [Block::Paragraph { children, .. }] = document.sections[1].blocks.as_slice() else {
1650            panic!("expected one special-character paragraph");
1651        };
1652        assert_eq!(inline_text(children), "– — ' \" © ® ™ • ^ ~ \\");
1653    }
1654
1655    #[test]
1656    fn retains_punctuation_after_implicit_mdoc_enclosures() {
1657        let document = parse_manual_bytes(
1658            std::path::Path::new("implicit-enclosure-punctuation.7"),
1659            b".Dd August 19, 2026\n.Dt IMPLICIT-ENCLOSURE-PUNCTUATION 7\n.Os\n\
1660.Sh DESCRIPTION\nWhen disabled\n.Pq all features remain readable ;\ncontinue safely.\n",
1661        )
1662        .expect("lower punctuation after an implicit enclosure");
1663
1664        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
1665            panic!("expected one paragraph");
1666        };
1667        assert_eq!(
1668            inline_text(children),
1669            "When disabled (all features remain readable); continue safely."
1670        );
1671    }
1672
1673    #[test]
1674    fn lowers_the_pinned_named_character_catalog_without_silent_deletion() {
1675        let document = parse_manual_bytes(
1676            std::path::Path::new("named-characters.7"),
1677            b".TH NAMED-CHARACTERS 7\n\
1678.SH TEST\n\
1679at=\\(at ga=\\(ga oq=\\(oq arrow=\\(-> larrow=\\(<- mu=\\(mu\n\
1680de=\\(de pl=\\(pl dg=\\(dg ua=\\(ua da=\\(da lB=\\(lB rB=\\(rB\n\
1681unknown=\\[future-glyph]\n",
1682        )
1683        .expect("lower named characters");
1684
1685        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1686            panic!("expected one character paragraph");
1687        };
1688        assert_eq!(
1689            inline_text(children),
1690            "at=@ ga=` oq=' arrow=→ larrow=← mu=× de=° pl=+ dg=† ua=↑ da=↓ lB=[ rB=] unknown=\\[future-glyph]"
1691        );
1692    }
1693
1694    #[test]
1695    fn round_trips_raw_and_bracketed_unicode_manual_text() {
1696        let source = ".TH UNICODE 7\n\
1697.SH TEST\n\
1698Raw UTF-8: Mašláňová café — naïve.\n\
1699Escaped: Ma\\[u0161]l\\[u00E1] and \\[u2014] dash.\n";
1700        let document = parse_manual_bytes(std::path::Path::new("unicode.7"), source.as_bytes())
1701            .expect("lower raw and escaped Unicode");
1702
1703        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1704            panic!("expected one Unicode paragraph");
1705        };
1706        let rendered = inline_text(children);
1707        assert!(rendered.contains("Raw UTF-8: Mašláňová café — naïve."));
1708        assert!(rendered.contains("Escaped: Mašlá and — dash."));
1709        assert!(!rendered.contains(r"\[u"));
1710    }
1711
1712    #[test]
1713    fn preserves_explicit_mdoc_function_and_enclosure_structure() {
1714        let document = parse_manual_bytes(
1715            std::path::Path::new("explicit-mdoc.1"),
1716            b".Dd August 17, 2026\n\
1717.Dt EXPLICIT-MDOC 1\n\
1718.Os\n\
1719.Sh NAME\n\
1720.Nm explicit-mdoc\n\
1721.Nd exercise explicit blocks\n\
1722.Sh FUNCTION\n\
1723.Ft int\n\
1724.Fo audit_open\n\
1725.Fa const char *path\n\
1726.Fa int flags\n\
1727.Fc\n\
1728.Sh ENCLOSURES\n\
1729.Ao\nangle\n.Ac\n\
1730.Bo\nbracket\n.Bc\n\
1731.Do\ndouble\n.Dc\n\
1732.Po\nparenthesized\n.Pc\n\
1733.Qo\nquoted\n.Qc\n\
1734.So\nsingle\n.Sc\n\
1735.Bro\nbraced\n.Brc\n\
1736.Oo\noptional\n.Oc\n\
1737.Eo <<\ngeneric\n.Ec >>\n\
1738.Es [[ ]]\n\
1739.En custom\n",
1740        )
1741        .expect("lower explicit mdoc blocks");
1742
1743        let function = &document.sections[1];
1744        let [
1745            Block::Paragraph {
1746                children: return_type,
1747                ..
1748            },
1749            Block::Paragraph {
1750                children: declaration,
1751                ..
1752            },
1753        ] = function.blocks.as_slice()
1754        else {
1755            panic!("expected return type and function declaration paragraphs");
1756        };
1757        assert_eq!(inline_text(return_type), "int");
1758        assert_eq!(
1759            inline_text(declaration),
1760            "audit_open(const char *path, int flags)"
1761        );
1762        assert!(matches!(
1763            declaration.first(),
1764            Some(Inline::Strong { children }) if inline_text(children) == "audit_open"
1765        ));
1766
1767        let [Block::Paragraph { children, .. }] = document.sections[2].blocks.as_slice() else {
1768            panic!("expected one enclosure paragraph");
1769        };
1770        assert_eq!(
1771            inline_text(children),
1772            "<angle> [bracket] “double” (parenthesized) “quoted” ‘single’ {braced} \
1773             [optional] <<generic>> [[custom]]"
1774        );
1775        assert_eq!(document.diagnostics.len(), 2);
1776        assert!(
1777            document
1778                .diagnostics
1779                .iter()
1780                .all(|diagnostic| diagnostic.message.starts_with("obsolete macro:")),
1781            "{:?}",
1782            document.diagnostics
1783        );
1784    }
1785
1786    #[test]
1787    fn preserves_the_complete_libbsd_library_identity() {
1788        let document = parse_manual_bytes(
1789            std::path::Path::new("libbsd.3bsd"),
1790            b".Dd August 19, 2026\n.Dt LIBBSD 3bsd\n.Os\n.Sh LIBRARY\n.Lb libbsd\n",
1791        )
1792        .expect("lower libbsd library declaration");
1793        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1794            panic!("expected one library paragraph");
1795        };
1796
1797        assert_eq!(
1798            inline_text(children),
1799            "Utility functions from BSD systems (libbsd, -lbsd)"
1800        );
1801    }
1802
1803    #[test]
1804    fn joins_the_final_mdoc_bibliography_authors() {
1805        let document = parse_manual_bytes(
1806            std::path::Path::new("bibliography.3"),
1807            b".Dd August 19, 2026\n.Dt BIBLIOGRAPHY 3\n.Os\n.Sh SEE ALSO\n\
1808.Rs\n.%A Bentley, J.L.\n.%A McIlroy, M.D.\n.%T Engineering a Sort Function\n.Re\n",
1809        )
1810        .expect("lower mdoc bibliography");
1811        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1812            panic!("expected one bibliography paragraph");
1813        };
1814
1815        assert_eq!(
1816            inline_text(children),
1817            "Bentley, J.L. and McIlroy, M.D. Engineering a Sort Function."
1818        );
1819    }
1820
1821    #[test]
1822    fn preserves_mdoc_command_names_in_each_synopsis_form() {
1823        let document = parse_manual_bytes(
1824            std::path::Path::new("fido2-cred.1"),
1825            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",
1826        )
1827        .expect("lower mdoc synopsis names");
1828        let synopsis = &document.sections[1];
1829        let rendered = synopsis
1830            .blocks
1831            .iter()
1832            .map(|block| match block {
1833                Block::Paragraph { children, .. } => inline_text(children),
1834                block => panic!("expected synopsis paragraph, got {block:?}"),
1835            })
1836            .collect::<Vec<_>>();
1837
1838        assert_eq!(
1839            rendered,
1840            [
1841                "fido2-cred -M [-i input_file]",
1842                "fido2-cred -V",
1843                "helper [-q]",
1844            ]
1845        );
1846    }
1847
1848    #[test]
1849    fn preserves_mdoc_name_and_function_punctuation_by_context() {
1850        let document = parse_manual_bytes(
1851            std::path::Path::new("function-punctuation.3"),
1852            b".Dd August 19, 2026\n.Dt FUNCTION-PUNCTUATION 3\n.Os\n\
1853.Sh NAME\n.Nm function-punctuation\n.Nd test generated punctuation\n\
1854.Sh SYNOPSIS\n.Fn compact_call \"int value\"\n\
1855.Fo explicit_call\n.Fa \"int value\"\n.Fc\n\
1856.Sh DESCRIPTION\nThe\n.Fn prose_call \"int value\"\nfunction.\n",
1857        )
1858        .expect("lower mdoc generated punctuation");
1859
1860        let [Block::Paragraph { children: name, .. }] = document.sections[0].blocks.as_slice()
1861        else {
1862            panic!("expected one NAME paragraph");
1863        };
1864        assert_eq!(
1865            inline_text(name),
1866            "function-punctuation — test generated punctuation"
1867        );
1868
1869        let synopsis = document.sections[1]
1870            .blocks
1871            .iter()
1872            .map(|block| match block {
1873                Block::Paragraph { children, .. } => inline_text(children),
1874                block => panic!("expected synopsis paragraph, got {block:?}"),
1875            })
1876            .collect::<Vec<_>>();
1877        assert_eq!(
1878            synopsis,
1879            ["compact_call(int value);", "explicit_call(int value);"]
1880        );
1881
1882        let [
1883            Block::Paragraph {
1884                children: description,
1885                ..
1886            },
1887        ] = document.sections[2].blocks.as_slice()
1888        else {
1889            panic!("expected one DESCRIPTION paragraph");
1890        };
1891        assert_eq!(
1892            inline_text(description),
1893            "The prose_call(int value) function."
1894        );
1895    }
1896
1897    #[test]
1898    fn preserves_mdoc_synopsis_declaration_units() {
1899        let document = parse_manual_bytes(
1900            std::path::Path::new("synopsis-declarations.3"),
1901            b".Dd August 19, 2026\n.Dt SYNOPSIS-DECLARATIONS 3\n.Os\n\
1902.Sh SYNOPSIS\n.In synprobe.h\n.Ft const struct stat *\n\
1903.Fn synprobe_first \"struct thing *a\"\n.Ft void\n\
1904.Fo synprobe_second\n.Fa \"struct thing *a\"\n.Fa \"int n\"\n.Fc\n\
1905.Fn synprobe_third \"int n\"\n",
1906        )
1907        .expect("lower mdoc synopsis declarations");
1908
1909        let rendered = document.sections[0]
1910            .blocks
1911            .iter()
1912            .map(|block| match block {
1913                Block::Paragraph { children, .. } => inline_text(children),
1914                block => panic!("expected synopsis declaration paragraph, got {block:?}"),
1915            })
1916            .collect::<Vec<_>>();
1917
1918        assert_eq!(
1919            rendered,
1920            [
1921                "#include <synprobe.h>",
1922                "const struct stat * synprobe_first(struct thing *a);",
1923                "void synprobe_second(struct thing *a, int n);",
1924                "synprobe_third(int n);",
1925            ]
1926        );
1927    }
1928
1929    #[test]
1930    fn preserves_printable_roff_content_outside_formal_sections() {
1931        let document = parse_manual_bytes(
1932            std::path::Path::new("manweb.1"),
1933            b".TH MANWEB 1\n .SH NAME\nmanweb - browse generated documentation\n.SH SYNOPSIS\n.B manweb\n",
1934        )
1935        .expect("lower root prose");
1936        let [Block::Paragraph { children, .. }] = document.blocks.as_slice() else {
1937            panic!("expected one root paragraph, got {:?}", document.blocks);
1938        };
1939
1940        assert_eq!(
1941            inline_text(children),
1942            " .SH NAME manweb - browse generated documentation"
1943        );
1944        assert_eq!(document.sections[0].title, "SYNOPSIS");
1945    }
1946
1947    #[test]
1948    fn discards_temporary_indent_arguments_without_hiding_the_next_line() {
1949        let document = parse_manual_bytes(
1950            std::path::Path::new("temporary-indent.8"),
1951            b".TH TEMPORARY-INDENT 8\n.SH EXAMPLES\n.ti +8n\nexample% command\n.ti\nexample% other\n",
1952        )
1953        .expect("lower temporary indentation requests");
1954
1955        let [Block::Paragraph { children, .. }] = document.sections[0].blocks.as_slice() else {
1956            panic!("expected one examples paragraph");
1957        };
1958        assert_eq!(inline_text(children), "example% command example% other");
1959    }
1960
1961    #[test]
1962    fn diagnoses_future_structural_macros_before_discarding_visible_parts() {
1963        let mut report = Parser::default()
1964            .parse_bytes(
1965                "future-structure.1",
1966                b".Dd August 17, 2026\n.Dt FUTURE 1\n.Os\n.Sh SYNOPSIS\n\
1967.Fo future_call\n.Fa argument\n.Fc\n",
1968            )
1969            .expect("parse structural fixture");
1970        let block = find_macro_mut(&mut report.document.root, "Fo").expect("Fo block");
1971        block.macro_name = Some("FutureBlock".to_owned());
1972        let mut second_body = block
1973            .children
1974            .iter()
1975            .find(|child| child.kind == libmandoc_rs::NodeKind::Body)
1976            .cloned()
1977            .expect("function body");
1978        assert!(replace_first_text(&mut second_body, "second_argument"));
1979        block.children.push(second_body);
1980
1981        let document = lower_mandoc_document(std::path::Path::new("future-structure.1"), &report);
1982
1983        assert!(document.diagnostics.iter().any(|diagnostic| {
1984            diagnostic.code.as_deref() == Some("manual.unhandled-structural-parts")
1985                && diagnostic.message.contains("FutureBlock")
1986        }));
1987        let rendered = document.sections[0]
1988            .blocks
1989            .iter()
1990            .map(|block| match block {
1991                Block::Paragraph { children, .. } => inline_text(children),
1992                block => panic!("expected fallback paragraph, got {block:?}"),
1993            })
1994            .collect::<Vec<_>>();
1995        assert_eq!(rendered, ["argument", "second_argument"]);
1996    }
1997
1998    #[test]
1999    fn recognizes_explicitly_styled_traditional_man_references_in_any_section() {
2000        let path = temporary_source(
2001            "man-see-also",
2002            ".TH TOOL 1\n\
2003             .SH DESCRIPTION\n\
2004             The styled reference \\fBprintf\\fP(3) is usable here.\n\
2005             .SH SEE ALSO\n\
2006             .BR printf (3),\n\
2007             .BR man (1)\n",
2008        );
2009
2010        let document = parse_manual_source(&path).expect("lower man references");
2011        fs::remove_file(path).expect("remove temporary roff fixture");
2012
2013        let see_also = document
2014            .sections
2015            .iter()
2016            .find(|section| section.title == "SEE ALSO")
2017            .expect("SEE ALSO");
2018        let Block::Paragraph { children, .. } = &see_also.blocks[0] else {
2019            panic!("references are a paragraph");
2020        };
2021        assert!(children.iter().any(|inline| matches!(
2022            inline,
2023            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2024                if name == "printf" && manual_section == "3"
2025        )));
2026        assert!(children.iter().any(|inline| matches!(
2027            inline,
2028            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2029                if name == "man" && manual_section == "1"
2030        )));
2031
2032        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2033            panic!("description is a paragraph");
2034        };
2035        assert!(children.iter().any(|inline| matches!(
2036            inline,
2037            Inline::Link { target: mant_ir::LinkTarget::Manual { name, manual_section: Some(manual_section) }, .. }
2038                if name == "printf" && manual_section == "3"
2039        )));
2040    }
2041
2042    #[test]
2043    fn recognizes_legacy_sphinx_manual_links_in_roff_inputs() {
2044        let path = temporary_source(
2045            "sphinx-manual-links",
2046            ".TH BTRFS 8\n\
2047             .SH COMMANDS\n\
2048             See btrfs\\-subvolume(8) \\%<> and btrfs(5) \\%<> for details.\n\
2049             .EX\n\
2050             btrfs-subvolume(8) \\%<>\n\
2051             .EE\n",
2052        );
2053
2054        let document = parse_manual_source(&path).expect("lower legacy Sphinx references");
2055        fs::remove_file(path).expect("remove temporary roff fixture");
2056        let section = &document.sections[0];
2057        let paragraph = section
2058            .blocks
2059            .iter()
2060            .find_map(|block| match block {
2061                Block::Paragraph { children, .. } => Some(children),
2062                _ => None,
2063            })
2064            .expect("commands paragraph");
2065        assert_eq!(
2066            inline_text(paragraph),
2067            "See btrfs-subvolume(8) and btrfs(5) for details."
2068        );
2069        let references = paragraph
2070            .iter()
2071            .filter_map(|inline| match inline {
2072                Inline::Link {
2073                    target:
2074                        mant_ir::LinkTarget::Manual {
2075                            name,
2076                            manual_section: Some(manual_section),
2077                        },
2078                    ..
2079                } => Some((name.as_str(), manual_section.as_str())),
2080                _ => None,
2081            })
2082            .collect::<Vec<_>>();
2083        assert_eq!(references, [("btrfs-subvolume", "8"), ("btrfs", "5")]);
2084
2085        let literal = section
2086            .blocks
2087            .iter()
2088            .find_map(|block| match block {
2089                Block::Preformatted { children, .. } => Some(children),
2090                _ => None,
2091            })
2092            .expect("literal display");
2093        assert_eq!(inline_text(literal), "btrfs-subvolume(8) <>");
2094        assert!(!literal.iter().any(|inline| matches!(
2095            inline,
2096            Inline::Link {
2097                target: mant_ir::LinkTarget::Manual { .. },
2098                ..
2099            }
2100        )));
2101    }
2102
2103    #[test]
2104    fn lowers_modern_groff_manual_uri_and_mail_macros() {
2105        let path = temporary_source(
2106            "man-modern-links",
2107            ".TH TOOL 1\n\
2108             .SH DESCRIPTION\n\
2109             .MR git-add 1 ,\n\
2110             .PP\n\
2111             Read\n\
2112             .UR https://example.test/docs\n\
2113             Documentation\n\
2114             .UE\n\
2115             now.\n\
2116             .PP\n\
2117             Mail comments, suggestions and bug reports to\n\
2118             .MT docs@example.test\n\
2119             Sean\n\
2120             .ME .\n",
2121        );
2122
2123        let document = parse_manual_source(&path).expect("lower modern man links");
2124        fs::remove_file(path).expect("remove temporary roff fixture");
2125        let section = &document.sections[0];
2126        let mut manual = false;
2127        let mut web = false;
2128        let mut mail = false;
2129        for children in section.blocks.iter().filter_map(|block| match block {
2130            Block::Paragraph { children, .. } => Some(children),
2131            _ => None,
2132        }) {
2133            for inline in children {
2134                match inline {
2135                    Inline::Link {
2136                        target:
2137                            mant_ir::LinkTarget::Manual {
2138                                name,
2139                                manual_section: Some(manual_section),
2140                            },
2141                        ..
2142                    } if name == "git-add" && manual_section == "1" => manual = true,
2143                    Inline::Link {
2144                        target: mant_ir::LinkTarget::External { uri },
2145                        ..
2146                    } if uri == "https://example.test/docs" => {
2147                        web = true;
2148                    }
2149                    Inline::Link {
2150                        target: mant_ir::LinkTarget::Email { address },
2151                        ..
2152                    } if address == "docs@example.test" => {
2153                        mail = true;
2154                    }
2155                    _ => {}
2156                }
2157            }
2158        }
2159
2160        assert!(manual && web && mail);
2161        assert!(section.blocks.iter().any(|block| match block {
2162            Block::Paragraph { children, .. } => inline_text(children).contains("git-add(1),"),
2163            _ => false,
2164        }));
2165        let linked_paragraphs = section
2166            .blocks
2167            .iter()
2168            .filter_map(|block| match block {
2169                Block::Paragraph { children, .. }
2170                    if children.iter().any(|inline| {
2171                        matches!(
2172                            inline,
2173                            Inline::Link {
2174                                target: mant_ir::LinkTarget::External { .. },
2175                                ..
2176                            } | Inline::Link {
2177                                target: mant_ir::LinkTarget::Email { .. },
2178                                ..
2179                            }
2180                        )
2181                    }) =>
2182                {
2183                    Some(inline_text(children))
2184                }
2185                _ => None,
2186            })
2187            .collect::<Vec<_>>();
2188        assert_eq!(
2189            linked_paragraphs,
2190            [
2191                "Read Documentation ⟨https://example.test/docs⟩ now.",
2192                "Mail comments, suggestions and bug reports to Sean ⟨docs@example.test⟩."
2193            ]
2194        );
2195    }
2196
2197    #[test]
2198    fn searches_across_man_link_labels_and_visible_targets() {
2199        let source = b".TH LINK-SEARCH 1\n\
2200.SH REPORTING BUGS\n\
2201Mail comments, suggestions and bug reports to\n\
2202.MT docs@example.test\n\
2203Sean\n\
2204.ME .\n";
2205
2206        for pattern in ["bug reports to Sean", "docs@example.test"] {
2207            let query = crate::query_roff_bytes(source).expect("query link fixture");
2208            let result = crate::project_query_view(
2209                query,
2210                &mant_protocol::QueryView::Search {
2211                    pattern: pattern.to_owned(),
2212                    syntax: mant_protocol::SearchSyntax::Literal,
2213                    case: mant_protocol::SearchCase::Sensitive,
2214                    scope: mant_protocol::SearchScope::Visible,
2215                    word: false,
2216                    context_lines: 0,
2217                    limit: 100,
2218                    offset: 0,
2219                },
2220            )
2221            .expect("search link fixture");
2222            let crate::QueryViewResult::Search(search) = result else {
2223                panic!("expected search result");
2224            };
2225            assert_eq!(search.total, 1, "pattern={pattern:?}");
2226        }
2227    }
2228
2229    #[test]
2230    fn resolves_mdoc_section_references_and_explicit_targets() {
2231        let path = temporary_source(
2232            "mdoc-navigation",
2233            ".Dd July 19, 2026\n\
2234             .Dt NAVIGATION 1\n\
2235             .Os\n\
2236             .Sh DESCRIPTION\n\
2237             Continue with\n\
2238             .Sx DETAILS\n\
2239             .Tg explicit-option\n\
2240             .Fl x\n\
2241             .Sh DETAILS\n\
2242             Target content.\n",
2243        );
2244
2245        let document = parse_manual_source(&path).expect("lower navigation mdoc source");
2246        fs::remove_file(path).expect("remove temporary roff fixture");
2247
2248        assert_eq!(document.sections[0].id, "description-1");
2249        assert_eq!(document.sections[1].id, "details-2");
2250        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2251            panic!("expected navigation paragraph");
2252        };
2253        assert!(children.iter().any(|inline| matches!(
2254            inline,
2255            Inline::Link {
2256                target: mant_ir::LinkTarget::Section { id },
2257                children,
2258                ..
2259            } if id == "details-2" && inline_text(children) == "DETAILS"
2260        )));
2261        assert!(children.iter().any(|inline| matches!(
2262            inline,
2263            Inline::Anchor { id } if id == "explicit-option"
2264        )));
2265    }
2266
2267    #[test]
2268    fn resolves_a_unique_parenthetically_qualified_mdoc_section_reference() {
2269        let path = temporary_source(
2270            "mdoc-qualified-navigation",
2271            ".Dd July 19, 2026\n\
2272             .Dt NAVIGATION 1\n\
2273             .Os\n\
2274             .Sh DESCRIPTION\n\
2275             See\n\
2276             .Sx White Space Splitting\n\
2277             .Sh \"White Space Splitting (Field Splitting)\"\n\
2278             Target content.\n",
2279        );
2280
2281        let document = parse_manual_source(&path).expect("lower qualified navigation source");
2282        fs::remove_file(path).expect("remove temporary roff fixture");
2283
2284        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2285            panic!("expected navigation paragraph");
2286        };
2287        assert!(children.iter().any(|inline| matches!(
2288            inline,
2289            Inline::Link {
2290                target: mant_ir::LinkTarget::Section { id },
2291                children,
2292                ..
2293            } if id == "white-space-splitting-field-splitting-2"
2294                && inline_text(children) == "White Space Splitting"
2295        )));
2296        assert!(document.diagnostics.iter().all(|diagnostic| {
2297            diagnostic.code.as_deref() != Some("unresolved-section-reference")
2298        }));
2299    }
2300
2301    #[test]
2302    fn degrades_unresolved_mdoc_section_references_to_text() {
2303        let path = temporary_source(
2304            "mdoc-missing-section",
2305            ".Dd July 19, 2026\n.Dt NAVIGATION 1\n.Os\n.Sh DESCRIPTION\n.Sx MISSING\n",
2306        );
2307
2308        let document = parse_manual_source(&path).expect("lower unresolved navigation source");
2309        fs::remove_file(path).expect("remove temporary roff fixture");
2310
2311        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2312            panic!("expected reference paragraph");
2313        };
2314        assert_eq!(inline_text(children), "MISSING");
2315        assert!(children.iter().all(|inline| !matches!(
2316            inline,
2317            Inline::Link {
2318                target: mant_ir::LinkTarget::Section { .. },
2319                ..
2320            }
2321        )));
2322        assert!(document.diagnostics.iter().any(|diagnostic| {
2323            diagnostic.code.as_deref() == Some("unresolved-section-reference")
2324        }));
2325    }
2326
2327    #[test]
2328    fn turns_captured_parser_findings_into_structured_diagnostics() {
2329        let path = temporary_source(
2330            "unsupported",
2331            ".Dd July 19, 2026\n.Dt BAD 1\n.Os\n.Sh NAME\n.Nm bad\n.ab\n",
2332        );
2333
2334        let document = parse_manual_source(&path).expect("best-effort parse");
2335        fs::remove_file(path).expect("remove temporary roff fixture");
2336
2337        assert!(
2338            document
2339                .diagnostics
2340                .iter()
2341                .any(|diagnostic| diagnostic.level == DiagnosticLevel::Unsupported)
2342        );
2343    }
2344
2345    #[test]
2346    fn masks_terminal_controls_before_native_parsing() {
2347        let path = temporary_source("controls", ".TH SAFE 1\n.SH NAME\nsafe \x1b[2J text\n");
2348
2349        let document = parse_manual_source(&path).expect("parse sanitized manual");
2350        fs::remove_file(path).expect("remove temporary roff fixture");
2351
2352        assert!(
2353            document.diagnostics.iter().any(|diagnostic| {
2354                diagnostic.code.as_deref() == Some("manual.control-characters")
2355            })
2356        );
2357    }
2358
2359    #[test]
2360    fn lowers_normalized_ordered_lists_and_literal_displays() {
2361        let path = temporary_source(
2362            "normalized",
2363            ".Dd July 19, 2026\n.Dt NORMALIZED 1\n.Os\n.Sh CONTENT\n\
2364             .Bl -enum -compact\n.It\nfirst\n.It\nsecond\n.El\n\
2365             .Bd -literal -offset 6n\nline one\nline two\n.Ed\n",
2366        );
2367
2368        let document = parse_manual_source(&path).expect("lower normalized mdoc");
2369        fs::remove_file(path).expect("remove temporary roff fixture");
2370
2371        assert!(matches!(
2372            document.sections[0].blocks[0],
2373            Block::List {
2374                kind: mant_ir::ListKind::Ordered,
2375                compact: true,
2376                ..
2377            }
2378        ));
2379        assert!(matches!(
2380            document.sections[0].blocks[1],
2381            Block::Preformatted { layout, .. } if layout.indent_columns == 6
2382        ));
2383    }
2384
2385    #[test]
2386    fn lowers_normalized_mdoc_font_and_author_layout() {
2387        let path = temporary_source(
2388            "normalized-mdoc-modes",
2389            ".Dd July 19, 2026\n\
2390             .Dt NORMALIZED-MODES 1\n\
2391             .Os\n\
2392             .Sh AUTHORS\n\
2393             .An -split\n\
2394             .An Alice Example\n\
2395             .An Bob Example\n\
2396             .An -nosplit\n\
2397             .An Carol Example\n\
2398             .An Dave Example\n\
2399             .Sh DESCRIPTION\n\
2400             .Bf -literal\n\
2401             literal text\n\
2402             .Ef\n",
2403        );
2404
2405        let document = parse_manual_source(&path).expect("lower normalized mdoc modes");
2406        fs::remove_file(path).expect("remove temporary roff fixture");
2407
2408        let authors = &document.sections[0];
2409        let Block::Paragraph { children, .. } = &authors.blocks[0] else {
2410            panic!("authors are one paragraph");
2411        };
2412        assert_eq!(
2413            inline_text(children),
2414            "Alice Example\nBob Example Carol Example Dave Example"
2415        );
2416
2417        let description = &document.sections[1];
2418        let Block::Paragraph { children, .. } = &description.blocks[0] else {
2419            panic!("font block is a paragraph");
2420        };
2421        assert!(matches!(
2422            children.as_slice(),
2423            [Inline::Code { value }] if value == "literal text"
2424        ));
2425    }
2426
2427    #[test]
2428    fn mdoc_definition_layout_uses_the_normalized_list_width() {
2429        let path = temporary_source(
2430            "mdoc-definition-widths",
2431            ".Dd July 23, 2026\n.Dt WIDTHS 1\n.Os\n.Sh ITEMS\n\
2432             .Bl -tag -width 20n\n.It tenletters\nwide description\n.El\n\
2433             .Bl -tag -width 3n\n.It short\nnarrow description\n.El\n",
2434        );
2435
2436        let document = parse_manual_source(&path).expect("lower mdoc definition widths");
2437        fs::remove_file(path).expect("remove temporary roff fixture");
2438
2439        let lists = document.sections[0]
2440            .blocks
2441            .iter()
2442            .filter_map(|block| match block {
2443                Block::DefinitionList { items, .. } => Some(items),
2444                _ => None,
2445            })
2446            .collect::<Vec<_>>();
2447        assert_eq!(lists.len(), 2);
2448        assert!(lists[0][0].inline_term);
2449        assert!(!lists[1][0].inline_term);
2450    }
2451
2452    #[test]
2453    fn lowers_the_pinned_large_mdoc_fixture_without_empty_sections() {
2454        let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
2455            .join("../libmandoc-rs/vendor/mandoc-1.14.6/mandoc.1");
2456
2457        let document = parse_manual_source(&source).expect("lower vendored mandoc manual");
2458
2459        assert!(document.sections.len() > 5);
2460        assert!(
2461            document
2462                .sections
2463                .iter()
2464                .any(|section| section.title == "DESCRIPTION")
2465        );
2466        assert!(
2467            document
2468                .sections
2469                .iter()
2470                .all(|section| !section.blocks.is_empty() || !section.children.is_empty())
2471        );
2472    }
2473
2474    #[test]
2475    fn lowers_tbl_and_eqn_payloads_into_structured_blocks() {
2476        let path = temporary_source(
2477            "table-equation",
2478            ".TH PAYLOAD 1\n.SH TABLE\n.TS\ntab(|);\nl r.\nleft|right\n.TE\n\
2479             .SH EQUATION\n.EQ\nx + {width over 2}\n.EN\n",
2480        );
2481
2482        let document = parse_manual_source(&path).expect("lower table and equation");
2483        fs::remove_file(path).expect("remove temporary roff fixture");
2484
2485        assert!(matches!(
2486            document.sections[0].blocks[0],
2487            Block::Table { ref rows, .. } if rows.len() == 1 && rows[0].cells.len() == 2
2488        ));
2489        assert!(matches!(
2490            document.sections[1].blocks[0],
2491            Block::Equation { ref value, .. } if value == "x + width / 2"
2492        ));
2493    }
2494
2495    #[test]
2496    fn large_tbl_rows_scale_without_changing_their_topology() {
2497        const ROW_COUNT: usize = 2_048;
2498        let mut source = String::from(".TH TABLE-SCALE 7\n.SH TABLE\n.TS\nl l.\n");
2499        for index in 0..ROW_COUNT {
2500            writeln!(source, "left {index}\tright {index}").expect("append table row");
2501        }
2502        source.push_str(".TE\n");
2503
2504        let document = parse_manual_bytes(std::path::Path::new("table-scale.7"), source.as_bytes())
2505            .expect("lower large table");
2506
2507        let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2508            panic!("large tbl input must remain one table");
2509        };
2510        assert_eq!(rows.len(), ROW_COUNT);
2511        assert!(matches!(
2512            rows.first().and_then(|row| row.cells.first()),
2513            Some(mant_ir::TableCell { blocks, .. })
2514                if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2515                    if inline_text(children) == "left 0")
2516        ));
2517        assert!(matches!(
2518            rows.last().and_then(|row| row.cells.get(1)),
2519            Some(mant_ir::TableCell { blocks, .. })
2520                if matches!(blocks.as_slice(), [Block::Paragraph { children, .. }]
2521                    if inline_text(children) == format!("right {}", ROW_COUNT - 1))
2522        ));
2523    }
2524
2525    #[test]
2526    fn keeps_inline_equations_in_macro_arguments_and_filled_prose() {
2527        let document = parse_manual_bytes(
2528            std::path::Path::new("inline-equation.7"),
2529            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",
2530        )
2531        .expect("lower inline equations");
2532
2533        let [Block::DefinitionList { items, .. }] = document.sections[0].blocks.as_slice() else {
2534            panic!(
2535                "expected one definition list: {:?}",
2536                document.sections[0].blocks
2537            );
2538        };
2539        let [item] = items.as_slice() else {
2540            panic!("expected one equation definition");
2541        };
2542        assert_eq!(inline_text(&item.terms[0]), "Dp dx _ 1 ... dx _ n");
2543        let [Block::Paragraph { children, .. }] = item.description.as_slice() else {
2544            panic!("expected one filled description: {:?}", item.description);
2545        };
2546        assert_eq!(
2547            inline_text(children),
2548            "Draw a polygon with, for i = 1 , ... , n + 1, its vertex."
2549        );
2550        assert!(children.iter().any(
2551            |child| matches!(child, Inline::Code { value } if value == "i = 1 , ... , n + 1")
2552        ));
2553    }
2554
2555    #[test]
2556    fn normalizes_inline_equations_retained_as_tbl_cell_text() {
2557        let document = parse_manual_bytes(
2558            std::path::Path::new("table-inline-equation.3"),
2559            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",
2560        )
2561        .expect("lower table equations");
2562
2563        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2564            panic!("expected equation table");
2565        };
2566        let [left, right] = rows[0].cells.as_slice() else {
2567            panic!("expected two cells");
2568        };
2569        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2570            panic!("expected left paragraph");
2571        };
2572        let [
2573            Block::Paragraph {
2574                children: right, ..
2575            },
2576        ] = right.blocks.as_slice()
2577        else {
2578            panic!("expected right paragraph");
2579        };
2580        assert!(matches!(left.as_slice(), [Inline::Code { value }] if value == "0"));
2581        assert_eq!(inline_text(right), "for values in [ 0 , π / 2 ]");
2582        assert!(
2583            right
2584                .iter()
2585                .any(|child| matches!(child, Inline::Code { .. }))
2586        );
2587    }
2588
2589    #[test]
2590    fn bounds_distinct_tbl_equation_normalization_work() {
2591        let mut source =
2592            String::from(".TH TABLE-EQN-BUDGET 3\n.SH DESCRIPTION\n.EQ\ndelim %%\n.EN\n.TS\nl.\n");
2593        for index in 0..=MAX_INLINE_EQUATION_NORMALIZATIONS {
2594            writeln!(source, "%x{index}%").expect("write fixture row");
2595        }
2596        source.push_str(".TE\n");
2597
2598        let document = parse_manual_bytes(
2599            std::path::Path::new("table-inline-equation-budget.3"),
2600            source.as_bytes(),
2601        )
2602        .expect("lower a bounded number of table equations");
2603
2604        assert!(document.diagnostics.iter().any(|diagnostic| {
2605            diagnostic.code.as_deref() == Some("manual.inline-equation-budget")
2606        }));
2607        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2608            panic!("expected equation table");
2609        };
2610        assert_eq!(rows.len(), MAX_INLINE_EQUATION_NORMALIZATIONS + 1);
2611    }
2612
2613    #[test]
2614    fn preserves_tbl_rows_across_interleaved_comments_and_text_blocks() {
2615        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";
2616        let document = parse_manual_bytes(std::path::Path::new("commented-table.1"), source)
2617            .expect("lower commented table");
2618
2619        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2620            panic!("expected a table");
2621        };
2622        assert_eq!(rows.len(), 5);
2623        let first_cells = rows
2624            .iter()
2625            .map(|row| match row.cells[0].blocks.as_slice() {
2626                [Block::Paragraph { children, .. }] => inline_text(children),
2627                cells => panic!("expected one paragraph per table cell: {cells:?}"),
2628            })
2629            .collect::<Vec<_>>();
2630        assert_eq!(first_cells, ["a", "b", "c", "d(1)", "e"]);
2631    }
2632
2633    #[test]
2634    fn keeps_tbl_vertical_span_markers_out_of_visible_cells() {
2635        let document = parse_manual_bytes(
2636            std::path::Path::new("vertical-table-span.1"),
2637            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",
2638        )
2639        .expect("lower vertical table span");
2640
2641        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2642            panic!("expected a table");
2643        };
2644        assert_eq!(rows.len(), 4);
2645        assert_eq!(rows[1].cells[0].row_span, 3);
2646        assert!(rows[2].cells[0].blocks.is_empty());
2647        assert!(rows[3].cells[0].blocks.is_empty());
2648    }
2649
2650    #[test]
2651    fn preserves_tbl_rows_nested_in_unfilled_mdoc_displays() {
2652        let document = parse_manual_bytes(
2653            std::path::Path::new("unfilled-table.7"),
2654            b".Dd August 19, 2026\n.Dt UNFILLED-TABLE 7\n.Os\n.Sh DESCRIPTION\n\
2655.Bd -unfilled -offset indent\n.TS\ntab(@);\nl l.\nleft@right\nnext@value\n.TE\n.Ed\n",
2656        )
2657        .expect("lower table nested in an unfilled display");
2658
2659        let table = document.sections[0]
2660            .blocks
2661            .iter()
2662            .find_map(|block| match block {
2663                Block::Table { rows, .. } => Some(rows),
2664                _ => None,
2665            })
2666            .expect("nested table must remain structured");
2667        assert_eq!(table.len(), 2);
2668        assert_eq!(table[0].cells.len(), 2);
2669        assert!(
2670            document.sections[0]
2671                .blocks
2672                .iter()
2673                .all(|block| !matches!(block, Block::Preformatted { children, .. } if children.is_empty())),
2674            "the surrounding display must not leave an empty placeholder"
2675        );
2676    }
2677
2678    #[test]
2679    fn keeps_unexpanded_tabular_cells_visible_with_a_diagnostic() {
2680        let document = parse_manual_bytes(
2681            std::path::Path::new("unexpanded-table-cell.7"),
2682            b".TH UNEXPANDED-TABLE-CELL 7\n.SH DESCRIPTION\n.TS\nl l.\n1\t\\*[unknown-label]\n.TE\n",
2683        )
2684        .expect("lower unresolved formatter string in a table cell");
2685
2686        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2687            panic!("expected a structured table");
2688        };
2689        assert_eq!(rows[0].cells.len(), 2);
2690        let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2691            panic!("expected one recovered table-cell paragraph");
2692        };
2693        assert_eq!(inline_text(children), r"\*[unknown-label]");
2694        assert!(document.diagnostics.iter().any(|diagnostic| {
2695            diagnostic.level == DiagnosticLevel::Unsupported
2696                && diagnostic.code.as_deref() == Some("manual.unexpanded-table-cell")
2697        }));
2698    }
2699
2700    #[test]
2701    fn restores_mdoc_names_inside_tbl_text_blocks() {
2702        let document = parse_manual_bytes(
2703            std::path::Path::new("table-text-block.3"),
2704            b".Dd August 19, 2026\n.Dt TABLE-TEXT-BLOCK 3\n.Os\n\
2705.Sh NAME\n.Nm table-text-block\n.Nd test tbl text blocks\n\
2706.Sh ATTRIBUTES\n.TS\nallbox;\nl l.\nInterface\tValue\n\
2707T{\n.Nm\nT}\tMT-Safe\n.TE\n",
2708        )
2709        .expect("lower tbl text blocks");
2710
2711        let Block::Table { rows, .. } = &document.sections[1].blocks[0] else {
2712            panic!("expected attributes table");
2713        };
2714        let [Block::Paragraph { children, .. }] = rows[1].cells[0].blocks.as_slice() else {
2715            panic!("expected recovered name cell");
2716        };
2717        assert_eq!(inline_text(children), "table-text-block");
2718        assert!(matches!(children.as_slice(), [Inline::Strong { .. }]));
2719    }
2720
2721    #[test]
2722    fn keeps_semantic_links_inside_tbl_text_blocks() {
2723        let document = parse_manual_bytes(
2724            std::path::Path::new("table-text-link.1"),
2725            b".TH TABLE-TEXT-LINK 1\n\
2726.nr do-fallback 0\n\
2727.if !\\n(.f .nr do-fallback 1\n\
2728.if \\n[do-fallback] \\{\\\n\
2729.  de MR\n\
2730.    ie \\\\n(.$=1 \\\n\
2731.      I \\%\\\\$1\n\
2732.    el \\\n\
2733.      IR \\%\\\\$1 (\\\\$2)\\\\$3\n\
2734.  .\n\
2735.\\}\n\
2736.rr do-fallback\n\
2737.SH DESCRIPTION\n\
2738.TS\ntab($);\nl l.\ngrn$T{\nrenders\n.MR gremlin 1\ndiagrams;\nT}\n\
2739gperl$T{\npopulates\n.I groff\nregisters using\n.MR perl 1 ;\nT}\n.TE\n",
2740        )
2741        .expect("lower semantic tbl text block");
2742
2743        let [Block::Table { rows, .. }] = document.sections[0].blocks.as_slice() else {
2744            panic!("semantic table content must not escape into a separate paragraph");
2745        };
2746        let [Block::Paragraph { children, .. }] = rows[0].cells[1].blocks.as_slice() else {
2747            panic!("expected semantic table cell paragraph");
2748        };
2749        assert_eq!(inline_text(children), "renders gremlin(1) diagrams;");
2750        assert!(children.iter().any(|child| matches!(
2751            child,
2752            Inline::Link {
2753                target: mant_ir::LinkTarget::Manual { name, manual_section },
2754                ..
2755            } if name == "gremlin" && manual_section.as_deref() == Some("1")
2756        )));
2757        let [Block::Paragraph { children, .. }] = rows[1].cells[1].blocks.as_slice() else {
2758            panic!("expected styled semantic table cell paragraph");
2759        };
2760        assert_eq!(
2761            inline_text(children),
2762            "populates groff registers using perl(1);"
2763        );
2764        assert!(
2765            children
2766                .iter()
2767                .any(|child| matches!(child, Inline::Emphasis { .. }))
2768        );
2769    }
2770
2771    #[test]
2772    fn restores_alternating_font_arguments_inside_tbl_text_blocks() {
2773        let document = parse_manual_bytes(
2774            std::path::Path::new("table-text-alternation.7"),
2775            b".TH TABLE-TEXT-ALTERNATION 7\n.SH DESCRIPTION\n.TS\nl l.\nT{\n\
2776.BI \\[aq] s1 \\[aq] s2 \\[aq]\nT}\tT{\n\
2777.I s1\nproduces the same formatted output as\n.IR s2 .\nT}\n.TE\n",
2778        )
2779        .expect("lower alternating man macros inside a tbl text block");
2780
2781        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2782            panic!("expected a structured table");
2783        };
2784        let [left, right] = rows[0].cells.as_slice() else {
2785            panic!("expected both reconstructed table cells");
2786        };
2787        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2788            panic!("expected a reconstructed left table-cell paragraph");
2789        };
2790        let [
2791            Block::Paragraph {
2792                children: right, ..
2793            },
2794        ] = right.blocks.as_slice()
2795        else {
2796            panic!("expected a reconstructed right table-cell paragraph");
2797        };
2798        assert_eq!(inline_text(left), "'s1's2'");
2799        assert_eq!(
2800            inline_text(right),
2801            "s1 produces the same formatted output as s2."
2802        );
2803        assert!(
2804            right
2805                .iter()
2806                .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2807        );
2808    }
2809
2810    #[test]
2811    fn restores_nested_mdoc_requests_inside_tbl_text_blocks() {
2812        let document = parse_manual_bytes(
2813            std::path::Path::new("table-mdoc-requests.8"),
2814            b".Dd August 19, 2026\n.Dt TABLE-MDOC-REQUESTS 8\n.Os\n.Sh DESCRIPTION\n\
2815.TS\ntab(@);\nl l.\nT{\n.Cm sip Ar addr Ns Op / Ns Ar mask\nT}@T{\n\
2816bitwise and of the address with\n.Ar mask\nequals\n.Ar addr .\n.Ar addr\n\
2817can be an IPv4 or IPv6 address.\nT}\n.TE\n",
2818        )
2819        .expect("lower nested mdoc requests in table text blocks");
2820
2821        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2822            panic!("expected a structured table");
2823        };
2824        let [left, right] = rows[0].cells.as_slice() else {
2825            panic!("expected two reconstructed table cells");
2826        };
2827        let [Block::Paragraph { children: left, .. }] = left.blocks.as_slice() else {
2828            panic!("expected reconstructed selector cell");
2829        };
2830        let [
2831            Block::Paragraph {
2832                children: right, ..
2833            },
2834        ] = right.blocks.as_slice()
2835        else {
2836            panic!("expected reconstructed description cell");
2837        };
2838        assert_eq!(inline_text(left), "sip addr[/mask]");
2839        assert_eq!(
2840            inline_text(right),
2841            "bitwise and of the address with mask equals addr. addr can be an IPv4 or IPv6 address."
2842        );
2843        assert!(
2844            left.iter()
2845                .any(|inline| matches!(inline, Inline::Strong { .. }))
2846        );
2847        assert!(
2848            right
2849                .iter()
2850                .any(|inline| matches!(inline, Inline::Emphasis { .. }))
2851        );
2852    }
2853
2854    #[test]
2855    fn keeps_command_names_in_extended_mdoc_synopsis_terms() {
2856        let document = parse_manual_bytes(
2857            std::path::Path::new("extended-synopsis.8"),
2858            b".Dd August 19, 2026\n.Dt EXTENDED-SYNOPSIS 8\n.Os\n.Sh NAME\n\
2859.Nm zinject\n.Nd inject faults\n.Sh SYNOPSIS\n.Bl -tag -width Ds\n\
2860.It Xo\n.Nm zinject\n.Xc\nList injections.\n\
2861.It Xo\n.Nm zinject\n.Fl b Ar bookmark\n.Xc\nInject a bookmark.\n.El\n",
2862        )
2863        .expect("lower extended mdoc synopsis terms");
2864
2865        let Block::DefinitionList { items, .. } = &document.sections[1].blocks[0] else {
2866            panic!("expected synopsis definition list");
2867        };
2868        assert_eq!(inline_text(&items[0].terms[0]), "zinject");
2869        assert_eq!(inline_text(&items[1].terms[0]), "zinject -b bookmark");
2870        assert!(matches!(
2871            items[0].terms[0].as_slice(),
2872            [Inline::Strong { .. }]
2873        ));
2874        assert!(
2875            items[1].terms[0]
2876                .iter()
2877                .any(|inline| matches!(inline, Inline::Strong { .. }))
2878        );
2879    }
2880
2881    #[test]
2882    fn decodes_named_characters_inside_equations() {
2883        let document = parse_manual_bytes(
2884            std::path::Path::new("equation-characters.1"),
2885            b".TH EQUATION-CHARACTERS 1\n.SH EQUATION\n.EQ\n\\[*p] \\[mi] x\n.EN\n",
2886        )
2887        .expect("lower equation characters");
2888
2889        assert!(matches!(
2890            document.sections[0].blocks[0],
2891            Block::Equation { ref value, .. } if value == "\u{03c0} \u{2212} x"
2892        ));
2893    }
2894
2895    #[test]
2896    fn lowers_every_mdoc_column_list_cell() {
2897        let document = parse_manual_bytes(
2898            std::path::Path::new("columns.3"),
2899            b".Dd August 19, 2026\n.Dt COLUMNS 3\n.Os\n.Sh DESCRIPTION\n\
2900.Bl -column name type description\n.It Dv CLSET_TIMEOUT Ta \"struct timeval *\" Ta \"set total timeout\"\n.El\n",
2901        )
2902        .expect("lower mdoc column list");
2903
2904        let Block::Table { rows, .. } = &document.sections[0].blocks[0] else {
2905            panic!("expected column list to lower as a table");
2906        };
2907        assert_eq!(rows.len(), 1);
2908        assert_eq!(rows[0].cells.len(), 3);
2909        let rendered = rows[0]
2910            .cells
2911            .iter()
2912            .map(|cell| match cell.blocks.as_slice() {
2913                [Block::Paragraph { children, .. }] => inline_text(children),
2914                blocks => panic!("expected one paragraph per cell, got {blocks:?}"),
2915            })
2916            .collect::<Vec<_>>();
2917        assert_eq!(
2918            rendered,
2919            ["CLSET_TIMEOUT", "struct timeval *", "set total timeout"]
2920        );
2921    }
2922
2923    #[test]
2924    fn preserves_nested_mdoc_spacing_state_in_definition_terms() {
2925        let document = parse_manual_bytes(
2926            std::path::Path::new("nested-spacing.1"),
2927            b".Dd August 19, 2026\n.Dt NESTED-SPACING 1\n.Os\n.Sh OPTIONS\n\
2928.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",
2929        )
2930        .expect("lower nested mdoc spacing controls");
2931
2932        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2933            panic!("expected an option definition list");
2934        };
2935        assert_eq!(
2936            inline_text(&items[0].terms[0]),
2937            "-L local_socket:host:hostport"
2938        );
2939    }
2940
2941    #[test]
2942    fn carries_mdoc_spacing_state_across_list_item_boundaries() {
2943        let document = parse_manual_bytes(
2944            std::path::Path::new("list-spacing.8"),
2945            b".Dd August 19, 2026\n.Dt LIST-SPACING 8\n.Os\n.Sh COMMANDS\n\
2946.Bl -tag -width Ds\n.Sm off\n.It Ic O Ar device\n.Sm on\n.It Ic done\nFinished.\n.El\n",
2947        )
2948        .expect("lower list-scoped mdoc spacing controls");
2949
2950        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2951            panic!("expected a command definition list");
2952        };
2953        assert_eq!(inline_text(&items[0].terms[0]), "Odevice");
2954        assert_eq!(inline_text(&items[1].terms[0]), "done");
2955    }
2956
2957    #[test]
2958    fn carries_mdoc_spacing_state_out_of_nested_synopsis_enclosures() {
2959        let document = parse_manual_bytes(
2960            std::path::Path::new("nested-synopsis-spacing.8"),
2961            b".Dd August 19, 2026\n.Dt NESTED-SYNOPSIS-SPACING 8\n.Os\n.Sh SYNOPSIS\n\
2962.Nm demo\n.Sm off\n.Oo Fl m\\~\n.Ar memory\n.Sm on\n.Oc\n\
2963.Op Fl o Ar variable Ns Cm = Ns Ar value\n.Ar name\n",
2964        )
2965        .expect("lower nested synopsis spacing transitions");
2966
2967        let Block::Paragraph { children, .. } = &document.sections[0].blocks[0] else {
2968            panic!("expected synopsis paragraph");
2969        };
2970        assert_eq!(
2971            inline_text(children),
2972            "demo [-m memory] [-o variable=value] name"
2973        );
2974    }
2975
2976    #[test]
2977    fn preserves_the_boundary_that_enters_a_compact_mdoc_term() {
2978        let document = parse_manual_bytes(
2979            std::path::Path::new("spacing-transition.5"),
2980            b".Dd August 19, 2026\n.Dt SPACING-TRANSITION 5\n.Os\n.Sh KEYWORDS\n\
2981.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",
2982        )
2983        .expect("lower an mdoc spacing transition inside a term");
2984
2985        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
2986            panic!("expected a keyword definition list");
2987        };
2988        assert_eq!(inline_text(&items[0].terms[0]), "@newuser name:uid:gid");
2989    }
2990
2991    #[test]
2992    fn separates_alternative_terms_in_an_extended_mdoc_definition_head() {
2993        let document = parse_manual_bytes(
2994            std::path::Path::new("extended-term-alternatives.8"),
2995            b".Dd August 19, 2026\n.Dt EXTENDED-TERM-ALTERNATIVES 8\n.Os\n.Sh OPTIONS\n\
2996.Bl -tag -width Ds\n.It Xo\n.Sm off\n.Ar ipaddr\n.Op / Ar masklen\n.Pp\n\
2997.Ar ipaddr\n.Op / Ar prefixlen\n.Sm on\n.Xc\nAccept this peer.\n.El\n",
2998        )
2999        .expect("lower alternative extended definition terms");
3000
3001        let Block::DefinitionList { items, .. } = &document.sections[0].blocks[0] else {
3002            panic!("expected a definition list");
3003        };
3004        assert_eq!(items.len(), 1);
3005        assert_eq!(items[0].terms.len(), 2);
3006        assert_eq!(inline_text(&items[0].terms[0]), "ipaddr[/masklen]");
3007        assert_eq!(inline_text(&items[0].terms[1]), "ipaddr[/prefixlen]");
3008    }
3009
3010    fn inline_text(children: &[Inline]) -> String {
3011        children
3012            .iter()
3013            .map(|child| match child {
3014                Inline::Text { value } | Inline::Code { value } => value.clone(),
3015                Inline::Strong { children }
3016                | Inline::Emphasis { children }
3017                | Inline::Link { children, .. } => inline_text(children),
3018                Inline::Anchor { .. } => String::new(),
3019                Inline::LineBreak => "\n".to_owned(),
3020            })
3021            .collect()
3022    }
3023}