use mant_ir::{
Block, DefinitionItem, EntryKind, EntrySummary, Inline, ListItem, ListKind, ParameterKind,
Section, TableCell, TldrCommandPart, TldrDocument, TldrOrigin,
};
use mant_protocol::{ExcerptSelection, OutlineNode, QueryExcerpt, QueryOutline};
use crate::ResolvedContent;
#[must_use]
pub fn render_query_text(query: &ResolvedContent) -> String {
render_query_body(query, true)
}
#[must_use]
pub fn render_query_man(query: &ResolvedContent) -> String {
if query.document.is_none() {
return String::new();
}
render_query_body(query, false)
}
fn render_query_body(query: &ResolvedContent, include_tldr: bool) -> String {
let section = query
.document
.as_ref()
.and_then(|document| document.meta.manual_section.as_deref());
let mut parts = vec![document_label(&query.label, section)];
if include_tldr && let Some(tldr) = &query.tldr {
parts.push(render_tldr_text(tldr));
}
if let Some(document) = &query.document {
parts.push(render_blocks(&document.blocks, 0));
parts.push(render_sections(&document.sections, 0));
}
join_parts(parts)
}
#[must_use]
pub fn render_outline_text(outline: &QueryOutline) -> String {
let mut lines = vec![document_label(
&outline.label,
outline
.meta
.as_ref()
.and_then(|meta| meta.manual_section.as_deref()),
)];
if let Some(message) = super::outline_empty_message(outline) {
lines.push(message);
} else {
render_outline_nodes(&outline.nodes, "", &mut lines);
}
lines.join("\n").trim_end().to_owned()
}
#[must_use]
pub fn render_excerpt_text(excerpt: &QueryExcerpt) -> String {
let mut parts = vec![document_label(
&excerpt.label,
excerpt
.meta
.as_ref()
.and_then(|meta| meta.manual_section.as_deref()),
)];
for selection in &excerpt.selections {
parts.push(render_selection(selection));
}
join_parts(parts)
}
fn render_outline_nodes(nodes: &[OutlineNode], prefix: &str, output: &mut Vec<String>) {
for (index, node) in nodes.iter().enumerate() {
let last = index + 1 == nodes.len();
let connector = if last { "└─" } else { "├─" };
let summary = outline_summary(node).map_or_else(String::new, render_outline_entry_summary);
output.push(format!(
"{prefix}{connector} {} [{}] {}{summary}",
node.path(),
node.id(),
node.title()
));
let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " });
render_outline_nodes(node.children(), &child_prefix, output);
}
}
fn outline_summary(node: &OutlineNode) -> Option<&EntrySummary> {
match node {
OutlineNode::DocumentRoot { entry_summary, .. }
| OutlineNode::DocumentSection { entry_summary, .. }
| OutlineNode::DocumentEntry { entry_summary, .. } => entry_summary.as_ref(),
OutlineNode::Tldr { .. } => None,
}
}
#[must_use]
pub fn render_outline_entry_summary(summary: &EntrySummary) -> String {
if summary.is_empty() {
return String::new();
}
let mut counts = summary
.by_kind
.iter()
.map(|count| {
format!(
"{} {}",
count.count,
entry_kind_label(count.kind, count.count == 1)
)
})
.collect::<Vec<_>>();
counts.push(format!(
"{} {}",
summary.forms,
if summary.forms == 1 { "form" } else { "forms" }
));
format!(
" — {} direct, {} nested ({})",
summary.direct,
summary.descendants,
counts.join(", ")
)
}
pub(super) const fn entry_kind_label(kind: EntryKind, singular: bool) -> &'static str {
match (kind, singular) {
(EntryKind::Command, true) => "command",
(EntryKind::Command, false) => "commands",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Option,
},
true,
) => "option",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Option,
},
false,
) => "options",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Marker,
},
true,
) => "marker",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Marker,
},
false,
) => "markers",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Operand,
},
true,
) => "operand",
(
EntryKind::Parameter {
parameter_kind: ParameterKind::Operand,
},
false,
) => "operands",
(EntryKind::ConfigurationKey, true) => "configuration key",
(EntryKind::ConfigurationKey, false) => "configuration keys",
(EntryKind::EnvironmentVariable, true) => "environment variable",
(EntryKind::EnvironmentVariable, false) => "environment variables",
(EntryKind::Variable, true) => "variable",
(EntryKind::Variable, false) => "variables",
(EntryKind::Value, true) => "value",
(EntryKind::Value, false) => "values",
(EntryKind::Term, true) => "term",
(EntryKind::Term, false) => "terms",
}
}
fn render_selection(selection: &ExcerptSelection) -> String {
let context = render_outline_trail(selection.outline());
match selection {
ExcerptSelection::Tldr { document, .. } => {
join_parts(vec![context, render_tldr_text(document)])
}
ExcerptSelection::DocumentRoot { blocks, .. } => {
join_parts(vec![context, render_blocks(blocks, 0)])
}
ExcerptSelection::DocumentSection { section, .. } => {
join_parts(vec![context, render_section(section, 0)])
}
ExcerptSelection::DocumentEntry { entry, .. } => join_parts(vec![
context,
render_definitions(std::slice::from_ref(entry), true, 0),
]),
}
}
fn render_outline_trail(trail: &mant_protocol::OutlineTrail) -> String {
let breadcrumb = trail
.ancestors
.iter()
.map(|ancestor| ancestor.title.as_str())
.chain(std::iter::once(trail.title()))
.collect::<Vec<_>>()
.join(" > ");
format!("Outline {}: {breadcrumb}", trail.path())
}
fn render_tldr_text(tldr: &TldrDocument) -> String {
let mut lines = vec!["TLDR".to_owned()];
lines.extend(tldr.description.iter().map(|line| line.trim().to_owned()));
if let Some(information) = &tldr.more_information {
lines.push(format!("More information: {}", information.trim()));
}
for example in &tldr.examples {
if !example.description.trim().is_empty() {
lines.push(example.description.trim().to_owned());
}
let command = example
.command_parts
.iter()
.map(|part| match part {
TldrCommandPart::Text { value } | TldrCommandPart::Placeholder { value } => {
value.as_str()
}
})
.collect::<String>();
lines.push(if command.is_empty() {
example.command.clone()
} else {
command
});
}
if tldr.origin == TldrOrigin::TldrPages {
lines.push(format!(
"tldr-pages · CC BY 4.0 · {} · {}",
tldr.platform, tldr.language
));
}
lines.join("\n\n")
}
fn render_sections(sections: &[Section], depth: usize) -> String {
sections
.iter()
.map(|section| render_section(section, depth))
.filter(|section| !section.is_empty())
.collect::<Vec<_>>()
.join("\n\n")
}
fn render_section(section: &Section, depth: usize) -> String {
let heading_indent = " ".repeat(depth);
let mut parts = vec![format!("{heading_indent}{}", section.title)];
let blocks = render_blocks(§ion.blocks, depth.saturating_mul(2));
if !blocks.is_empty() {
parts.push(blocks);
}
let children = render_sections(§ion.children, depth + 1);
if !children.is_empty() {
parts.push(children);
}
join_parts(parts)
}
fn render_blocks(blocks: &[Block], base_indent: usize) -> String {
let mut output = String::new();
let mut has_content = false;
let mut pending_blank_lines: Option<usize> = None;
for block in blocks {
if let Block::VerticalSpace { lines, .. } = block {
if has_content {
let requested = usize::from(*lines);
pending_blank_lines = Some(pending_blank_lines.unwrap_or(0).max(requested));
}
continue;
}
let Some(text) = render_block(block, base_indent) else {
continue;
};
if has_content {
let blank_lines = pending_blank_lines.unwrap_or(1);
output.push_str(&"\n".repeat(blank_lines + 1));
}
output.push_str(&text);
has_content = true;
pending_blank_lines = None;
}
output
}
fn render_block(block: &Block, base_indent: usize) -> Option<String> {
let (value, layout_indent) = match block {
Block::Paragraph {
children, layout, ..
}
| Block::Preformatted {
children, layout, ..
} => (inline_text(children), usize::from(layout.indent_columns)),
Block::List {
kind,
start,
items,
layout,
..
} => (
render_list(*kind, *start, items, base_indent),
usize::from(layout.indent_columns),
),
Block::DefinitionList {
items,
compact,
layout,
..
} => (
render_definitions(items, *compact, base_indent),
usize::from(layout.indent_columns),
),
Block::Table { rows, layout, .. } => (
rows.iter()
.map(|row| {
row.cells
.iter()
.map(cell_text)
.collect::<Vec<_>>()
.join(" | ")
})
.collect::<Vec<_>>()
.join("\n"),
usize::from(layout.indent_columns),
),
Block::Equation { value, layout, .. }
| Block::Unsupported {
text: value,
layout,
..
} => (value.clone(), usize::from(layout.indent_columns)),
Block::VerticalSpace { .. } => return None,
Block::ThematicBreak { .. } => ("---".to_owned(), 0),
};
let value = value.trim_matches('\n');
(!value.trim().is_empty()).then(|| indent_lines(value, base_indent + layout_indent))
}
fn render_list(
kind: ListKind,
start: Option<u64>,
items: &[ListItem],
base_indent: usize,
) -> String {
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let marker = match kind {
ListKind::Ordered => format!(
"{}. ",
start
.unwrap_or(1)
.saturating_add(u64::try_from(index).unwrap_or(u64::MAX))
),
ListKind::Bullet => "- ".to_owned(),
ListKind::Plain => String::new(),
};
prefix_text_item(&render_blocks(&item.blocks, base_indent), &marker)
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_definitions(items: &[DefinitionItem], compact: bool, base_indent: usize) -> String {
let rendered = items
.iter()
.filter_map(|item| {
let terms = item
.terms
.iter()
.map(|term| inline_text(term))
.filter(|term| !term.trim().is_empty())
.collect::<Vec<_>>()
.join(", ");
let description = render_blocks(&item.description, base_indent);
let value = match (terms.is_empty(), description.is_empty()) {
(false, false) => {
if item.inline_term {
Some(format!("{terms} {}", description.trim_start()))
} else {
Some(format!("{terms}\n{}", indent_lines(&description, 2)))
}
}
(false, true) => Some(terms),
(true, false) => Some(description),
(true, true) => None,
}?;
Some((value, item.spacing_before_lines))
})
.collect::<Vec<_>>();
let Some((first, rest)) = rendered.split_first() else {
return String::new();
};
let mut output = first.0.clone();
for (item, spacing_before_lines) in rest {
let blank_lines = spacing_before_lines.unwrap_or(u16::from(!compact));
output.push_str(&"\n".repeat(usize::from(blank_lines) + 1));
output.push_str(item);
}
output
}
fn cell_text(cell: &TableCell) -> String {
render_blocks(&cell.blocks, 0).replace('\n', " ")
}
fn inline_text(children: &[Inline]) -> String {
let mut output = String::new();
for child in children {
match child {
Inline::Text { value } | Inline::Code { value } => output.push_str(value),
Inline::Strong { children }
| Inline::Emphasis { children }
| Inline::Link { children, .. } => output.push_str(&inline_text(children)),
Inline::Anchor { .. } => {}
Inline::LineBreak => output.push('\n'),
}
}
output
}
fn prefix_text_item(content: &str, marker: &str) -> Option<String> {
if content.trim().is_empty() {
return None;
}
let continuation = " ".repeat(marker.chars().count());
let mut lines = content.lines();
let mut output = format!("{marker}{}", lines.next()?);
for line in lines {
output.push('\n');
output.push_str(&continuation);
output.push_str(line);
}
Some(output)
}
fn indent_lines(value: &str, columns: usize) -> String {
if columns == 0 {
return value.to_owned();
}
let prefix = " ".repeat(columns);
value
.lines()
.map(|line| {
if line.is_empty() {
String::new()
} else {
format!("{prefix}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn document_label(document: &str, section: Option<&str>) -> String {
section.map_or_else(
|| document.to_owned(),
|section| format!("{document}({section})"),
)
}
fn join_parts(parts: Vec<String>) -> String {
parts
.into_iter()
.filter(|part| !part.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n")
.trim_end()
.to_owned()
}
#[cfg(test)]
mod tests {
use crate::ResolvedContent;
use mant_ir::{
Block, DefinitionItem, Document, DocumentMeta, DocumentSource, EntryKind, Inline,
LayoutHint, Section, SourceFormat, TldrDocument, TldrOrigin,
};
use mant_protocol::EntryProjection;
use super::{render_excerpt_text, render_outline_text, render_query_man, render_query_text};
use crate::{build_outline, build_outline_projection, render_outline_markdown, select_excerpt};
fn query() -> ResolvedContent {
ResolvedContent {
address: None,
label: "demo".to_owned(),
document: Some(Document {
parser: None,
source: DocumentSource {
format: SourceFormat::Man,
path: None,
},
meta: DocumentMeta {
manual_section: Some("1".to_owned()),
..DocumentMeta::default()
},
diagnostics: Vec::new(),
blocks: Vec::new(),
sections: vec![Section {
id: "options-1".to_owned().into(),
title: "OPTIONS".to_owned(),
spacing_before_lines: 0,
blocks: vec![paragraph("parent details", true)],
children: vec![Section {
id: "common-2".to_owned().into(),
title: "Common options".to_owned(),
spacing_before_lines: 1,
blocks: vec![paragraph("child details", false)],
children: Vec::new(),
source: None,
}],
source: None,
}],
}),
tldr: None,
}
}
fn paragraph(value: &str, strong: bool) -> Block {
let text = vec![Inline::Text {
value: value.to_owned(),
}];
Block::Paragraph {
children: if strong {
vec![Inline::Strong { children: text }]
} else {
text
},
layout: LayoutHint::default(),
source: None,
}
}
#[test]
fn renders_plain_queries_without_markup_and_uses_resolved_manual_sections() {
let output = render_query_text(&query());
assert!(output.starts_with("demo(1)\n\nOPTIONS"));
assert!(output.contains("parent details"));
assert!(output.contains("Common options"));
assert!(!output.contains("**"));
}
#[test]
fn renders_copyable_outline_trees_and_contextual_excerpts() {
let query = query();
let outline = build_outline(&query).expect("outline");
assert_eq!(
render_outline_text(&outline),
"demo(1)\n└─ 1 [options-1] OPTIONS\n └─ 1.1 [common-2] Common options"
);
let excerpt = select_excerpt(&query, &["1.1".to_owned()]).expect("excerpt");
let output = render_excerpt_text(&excerpt);
assert!(output.contains("Outline 1.1: OPTIONS > Common options"));
assert!(output.contains("child details"));
assert!(!output.contains("parent details"));
}
#[test]
fn renders_an_explicit_zero_for_an_empty_kind_projection() {
let outline = build_outline_projection(
&query(),
EntryProjection::Kinds {
kinds: vec![EntryKind::EnvironmentVariable],
},
None,
)
.expect("empty kind projection");
assert_eq!(
render_outline_text(&outline),
"demo(1)\n0 matching semantic entries for: environment variables"
);
assert!(
render_outline_markdown(&outline)
.contains("0 matching semantic entries for: environment variables")
);
}
#[test]
fn renders_tldr_as_zero_in_outlines_and_standalone_excerpts() {
let mut query = query();
query.tldr = Some(TldrDocument {
title: "demo".to_owned(),
description: vec!["A small demonstration.".to_owned()],
more_information: None,
examples: Vec::new(),
platform: "common".to_owned(),
language: "en".to_owned(),
source_path: "/cache/tldr/demo.md".to_owned(),
origin: TldrOrigin::TldrPages,
});
let outline = render_outline_text(&build_outline(&query).expect("combined outline"));
assert!(outline.contains("├─ 0 [tldr] TLDR QUICK REFERENCE"));
assert!(outline.contains("└─ 1 [options-1] OPTIONS"));
let excerpt = select_excerpt(&query, &["tldr".to_owned()]).expect("tldr excerpt");
assert_eq!(
render_excerpt_text(&excerpt),
"demo\n\nOutline 0: TLDR QUICK REFERENCE\n\nTLDR\n\nA small demonstration.\n\ntldr-pages · CC BY 4.0 · common · en"
);
}
#[test]
fn attributes_only_community_tldr_in_plain_text() {
let mut community = query();
community.tldr = Some(TldrDocument {
title: "demo".to_owned(),
description: vec!["A small demonstration.".to_owned()],
more_information: None,
examples: Vec::new(),
platform: "common".to_owned(),
language: "en".to_owned(),
source_path: "/cache/tldr/demo.md".to_owned(),
origin: TldrOrigin::TldrPages,
});
assert!(render_query_text(&community).contains("tldr-pages · CC BY 4.0 · common · en"));
let mut embedded = community;
embedded.tldr.as_mut().expect("tldr").origin = TldrOrigin::Embedded;
assert!(!render_query_text(&embedded).contains("CC BY 4.0"));
}
#[test]
fn man_format_renders_the_manual_but_omits_the_prepended_tldr() {
let mut query = query();
query.tldr = Some(TldrDocument {
title: "demo".to_owned(),
description: vec!["A small demonstration.".to_owned()],
more_information: None,
examples: Vec::new(),
platform: "common".to_owned(),
language: "en".to_owned(),
source_path: "/cache/tldr/demo.md".to_owned(),
origin: TldrOrigin::TldrPages,
});
let text = render_query_text(&query);
let man = render_query_man(&query);
assert!(text.contains("TLDR"));
assert!(text.contains("A small demonstration."));
assert!(!man.contains("TLDR"));
assert!(!man.contains("A small demonstration."));
assert!(man.starts_with("demo(1)\n\nOPTIONS"));
assert!(man.contains("parent details"));
assert!(man.contains("Common options"));
assert!(!man.contains("**"));
}
#[test]
fn man_format_does_not_invent_a_document_for_tldr_only_queries() {
let mut query = query();
query.document = None;
query.tldr = Some(TldrDocument {
title: "demo".to_owned(),
description: vec!["A small demonstration.".to_owned()],
more_information: None,
examples: Vec::new(),
platform: "common".to_owned(),
language: "en".to_owned(),
source_path: "/cache/tldr/demo.md".to_owned(),
origin: TldrOrigin::TldrPages,
});
assert!(render_query_man(&query).is_empty());
}
#[test]
fn vertical_space_sets_the_gap_instead_of_stacking_blank_lines() {
fn document_with(blocks: Vec<Block>) -> ResolvedContent {
ResolvedContent {
address: None,
label: "demo".to_owned(),
document: Some(Document {
parser: None,
source: DocumentSource {
format: SourceFormat::Man,
path: None,
},
meta: DocumentMeta {
manual_section: Some("1".to_owned()),
..DocumentMeta::default()
},
diagnostics: Vec::new(),
blocks: Vec::new(),
sections: vec![Section {
id: "s-1".to_owned().into(),
title: "S".to_owned(),
spacing_before_lines: 0,
blocks,
children: Vec::new(),
source: None,
}],
}),
tldr: None,
}
}
fn para(value: &str) -> Block {
Block::Paragraph {
children: vec![Inline::Text {
value: value.to_owned(),
}],
layout: LayoutHint::default(),
source: None,
}
}
let vspace = |lines: u16| Block::VerticalSpace {
lines,
source: None,
};
let one = render_query_text(&document_with(vec![
para("first"),
vspace(1),
para("second"),
]));
assert!(one.contains("first\n\nsecond"), "got: {one:?}");
assert!(!one.contains("first\n\n\nsecond"), "got: {one:?}");
let wide = render_query_text(&document_with(vec![
para("first"),
vspace(2),
para("second"),
]));
assert!(wide.contains("first\n\n\nsecond"), "got: {wide:?}");
let edges = render_query_text(&document_with(vec![vspace(2), para("only"), vspace(3)]));
assert!(edges.ends_with("only"), "got: {edges:?}");
assert!(edges.contains("S\n\nonly"), "got: {edges:?}");
}
#[test]
fn inline_definition_descriptions_are_tight_against_their_terms() {
let bundle = ResolvedContent {
address: None,
label: "demo".to_owned(),
document: Some(Document {
parser: None,
source: DocumentSource {
format: SourceFormat::Man,
path: None,
},
meta: DocumentMeta {
manual_section: Some("1".to_owned()),
..DocumentMeta::default()
},
diagnostics: Vec::new(),
blocks: Vec::new(),
sections: vec![Section {
id: "ops".to_owned().into(),
title: "OPERATORS".to_owned(),
spacing_before_lines: 0,
blocks: vec![Block::DefinitionList {
compact: false,
layout: LayoutHint::default(),
source: None,
items: vec![
DefinitionItem {
identity: None,
inline_term: true,
terms: vec![vec![Inline::Text {
value: "* / %".to_owned(),
}]],
description: vec![Block::Paragraph {
children: vec![Inline::Text {
value: "Multiplication, division, and modulus.".to_owned(),
}],
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: Some(1),
},
DefinitionItem {
identity: None,
inline_term: true,
terms: vec![vec![Inline::Text {
value: "space".to_owned(),
}]],
description: vec![Block::Paragraph {
children: vec![Inline::Text {
value: "String concatenation.".to_owned(),
}],
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: Some(1),
},
],
}],
children: Vec::new(),
source: None,
}],
}),
tldr: None,
};
let output = render_query_text(&bundle);
assert!(
output.contains("* / % Multiplication, division, and modulus."),
"got: {output:?}"
);
assert!(
output.contains("space String concatenation."),
"got: {output:?}"
);
assert!(!output.contains("* / % "), "got: {output:?}");
assert!(!output.contains("space "), "got: {output:?}");
}
#[test]
fn man_format_keeps_inline_definitions_tight() {
let bundle = ResolvedContent {
address: None,
label: "demo".to_owned(),
document: Some(Document {
parser: None,
source: DocumentSource {
format: SourceFormat::Man,
path: None,
},
meta: DocumentMeta {
manual_section: Some("1".to_owned()),
..DocumentMeta::default()
},
diagnostics: Vec::new(),
blocks: Vec::new(),
sections: vec![Section {
id: "ops".to_owned().into(),
title: "OPERATORS".to_owned(),
spacing_before_lines: 0,
blocks: vec![Block::DefinitionList {
compact: false,
layout: LayoutHint::default(),
source: None,
items: vec![
DefinitionItem {
identity: None,
inline_term: true,
terms: vec![vec![Inline::Text {
value: "&&".to_owned(),
}]],
description: vec![Block::Paragraph {
children: vec![Inline::Text {
value: "Logical AND.".to_owned(),
}],
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: Some(1),
},
DefinitionItem {
identity: None,
inline_term: false,
terms: vec![vec![Inline::Text {
value: "--long-option-name".to_owned(),
}]],
description: vec![Block::Paragraph {
children: vec![Inline::Text {
value: "A lengthy flag.".to_owned(),
}],
layout: LayoutHint::default(),
source: None,
}],
spacing_before_lines: Some(1),
},
],
}],
children: Vec::new(),
source: None,
}],
}),
tldr: None,
};
let man = render_query_man(&bundle);
assert!(man.contains("&& Logical AND."), "got: {man:?}");
assert!(
man.contains("--long-option-name\n A lengthy flag."),
"got: {man:?}"
);
}
}