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