use std::fmt::Write as _;
use anstyle::{AnsiColor, Style};
use mant_engine::QueryViewResult;
use mant_ir::{Block, DefinitionRole, Inline, ResolvedContent, Section, SourceFormat};
use mant_protocol::{
ExcerptSelection, OutlineNode, QueryExcerpt, QueryOutline, QuerySearch, ScopeQueryResponse,
ScopeQueryResult, sanitize_terminal_text,
};
use serde::Serialize;
use crate::{arguments::QueryFormat, error::Failure};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalRole {
Document,
Heading,
Option,
Command,
Environment,
Variable,
Match,
Coordinate,
Path,
TreeGuide,
Muted,
}
struct TerminalText {
value: String,
color: bool,
}
impl TerminalText {
fn new(color: bool) -> Self {
Self {
value: String::new(),
color,
}
}
fn plain(&mut self, value: &str) {
self.value.push_str(&sanitize_terminal_text(value));
}
fn styled(&mut self, role: TerminalRole, value: &str) {
let value = sanitize_terminal_text(value);
if !self.color || value.is_empty() {
self.plain(&value);
return;
}
let style = terminal_style(role);
let _ = write!(self.value, "{style}{value}{style:#}");
}
fn line(&mut self) {
self.value.push('\n');
}
fn finish(self) -> String {
self.value.trim_end().to_owned()
}
}
fn render_terminal_outline(outline: &QueryOutline, color: bool) -> String {
let mut output = TerminalText::new(color);
output.styled(
TerminalRole::Document,
&document_label(
&outline.label,
outline
.meta
.as_ref()
.and_then(|meta| meta.manual_section.as_deref()),
),
);
if !outline.nodes.is_empty() {
output.line();
render_outline_nodes(&outline.nodes, "", &mut output);
}
output.finish()
}
fn render_outline_nodes(nodes: &[OutlineNode], prefix: &str, output: &mut TerminalText) {
for (index, node) in nodes.iter().enumerate() {
let last = index + 1 == nodes.len();
output.styled(TerminalRole::TreeGuide, prefix);
output.styled(TerminalRole::TreeGuide, if last { "└─" } else { "├─" });
output.plain(" ");
output.styled(TerminalRole::Path, node.path());
output.styled(TerminalRole::TreeGuide, " [");
output.styled(TerminalRole::Coordinate, node.id());
output.styled(TerminalRole::TreeGuide, "] ");
output.styled(outline_node_role(node), node.title());
if index + 1 < nodes.len() || !node.children().is_empty() {
output.line();
}
let child_prefix = format!("{prefix}{}", if last { " " } else { "│ " });
render_outline_nodes(node.children(), &child_prefix, output);
if !node.children().is_empty() && index + 1 < nodes.len() {
output.line();
}
}
}
fn render_terminal_excerpt(excerpt: &QueryExcerpt, color: bool) -> String {
let excerpt = terminal_excerpt(excerpt);
let plain = mant_engine::render_excerpt_text(&excerpt);
if !color || plain.is_empty() {
return plain;
}
let mut headings = Vec::new();
let mut terms = Vec::new();
for selection in &excerpt.selections {
collect_excerpt_semantics(selection, &mut headings, &mut terms);
}
terms.sort_by_key(|term| std::cmp::Reverse(term.0.len()));
let mut output = TerminalText::new(true);
for (index, line) in plain.split('\n').enumerate() {
if index > 0 {
output.line();
}
render_excerpt_line(line, index == 0, &headings, &terms, &mut output);
}
output.finish()
}
fn render_terminal_search(search: &QuerySearch, color: bool) -> String {
mant_engine::render_search_text_with(search, |role, value| {
let value = sanitize_terminal_text(value);
if !color {
return value.into_owned();
}
let role = match role {
mant_engine::SearchTextRole::Plain => return value.into_owned(),
mant_engine::SearchTextRole::Document => TerminalRole::Document,
mant_engine::SearchTextRole::Coordinate => TerminalRole::Coordinate,
mant_engine::SearchTextRole::Path => TerminalRole::Path,
mant_engine::SearchTextRole::Heading => TerminalRole::Heading,
mant_engine::SearchTextRole::Definition(role) => definition_role(role),
mant_engine::SearchTextRole::Match => TerminalRole::Match,
mant_engine::SearchTextRole::Muted => TerminalRole::Muted,
};
let style = terminal_style(role);
format!("{style}{value}{style:#}")
})
}
fn render_excerpt_line(
line: &str,
document_line: bool,
headings: &[String],
terms: &[(String, DefinitionRole)],
output: &mut TerminalText,
) {
if document_line {
output.styled(TerminalRole::Document, line);
return;
}
if line == "TLDR" {
output.styled(TerminalRole::Heading, line);
return;
}
if let Some(rest) = line.strip_prefix("Outline ")
&& let Some((path, breadcrumb)) = rest.split_once(": ")
{
output.styled(TerminalRole::Muted, "Outline ");
output.styled(TerminalRole::Path, path);
output.styled(TerminalRole::Muted, ": ");
for (index, title) in breadcrumb.split(" > ").enumerate() {
if index > 0 {
output.styled(TerminalRole::TreeGuide, " > ");
}
output.styled(TerminalRole::Heading, title);
}
return;
}
let trimmed = line.trim_start();
let indent = line.len().saturating_sub(trimmed.len());
if headings.iter().any(|heading| heading == trimmed) {
output.plain(&line[..indent]);
output.styled(TerminalRole::Heading, trimmed);
return;
}
if let Some((term, role)) = terms
.iter()
.find(|(term, _)| !term.is_empty() && trimmed.starts_with(term))
{
output.plain(&line[..indent]);
output.styled(definition_role(*role), term);
output.plain(&trimmed[term.len()..]);
return;
}
output.plain(line);
}
fn collect_excerpt_semantics(
selection: &ExcerptSelection,
headings: &mut Vec<String>,
terms: &mut Vec<(String, DefinitionRole)>,
) {
match selection {
ExcerptSelection::Tldr { .. } | ExcerptSelection::DocumentRoot { .. } => {}
ExcerptSelection::DocumentSection { section, .. } => {
collect_section_semantics(section, headings, terms);
}
ExcerptSelection::DocumentEntry { entry, .. } => collect_definition(entry, terms),
}
}
fn collect_section_semantics(
section: &Section,
headings: &mut Vec<String>,
terms: &mut Vec<(String, DefinitionRole)>,
) {
headings.push(section.title.clone());
collect_block_semantics(§ion.blocks, terms);
for child in §ion.children {
collect_section_semantics(child, headings, terms);
}
}
fn collect_block_semantics(blocks: &[Block], terms: &mut Vec<(String, DefinitionRole)>) {
for block in blocks {
match block {
Block::DefinitionList { items, .. } => {
for item in items {
collect_definition(item, terms);
collect_block_semantics(&item.description, terms);
}
}
Block::List { items, .. } => {
for item in items {
collect_block_semantics(&item.blocks, terms);
}
}
Block::Table { rows, .. } => {
for cell in rows.iter().flat_map(|row| &row.cells) {
collect_block_semantics(&cell.blocks, terms);
}
}
Block::Paragraph { .. }
| Block::Preformatted { .. }
| Block::Equation { .. }
| Block::VerticalSpace { .. }
| Block::ThematicBreak { .. }
| Block::Unsupported { .. } => {}
}
}
}
fn collect_definition(item: &mant_ir::DefinitionItem, terms: &mut Vec<(String, DefinitionRole)>) {
let Some(identity) = &item.identity else {
return;
};
terms.extend(
item.terms
.iter()
.map(|term| (inline_text(term), identity.role)),
);
}
fn inline_text(inlines: &[Inline]) -> String {
let mut output = String::new();
for inline in inlines {
match inline {
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
}
const fn outline_node_role(node: &OutlineNode) -> TerminalRole {
match node {
OutlineNode::DocumentEntry { role, .. } => definition_role(*role),
OutlineNode::Tldr { .. }
| OutlineNode::DocumentRoot { .. }
| OutlineNode::DocumentSection { .. } => TerminalRole::Heading,
}
}
const fn definition_role(role: DefinitionRole) -> TerminalRole {
match role {
DefinitionRole::Option => TerminalRole::Option,
DefinitionRole::Command => TerminalRole::Command,
DefinitionRole::EnvironmentVariable => TerminalRole::Environment,
DefinitionRole::Variable => TerminalRole::Variable,
}
}
const fn terminal_style(role: TerminalRole) -> Style {
match role {
TerminalRole::Document => AnsiColor::BrightBlue.on_default().bold(),
TerminalRole::Heading => AnsiColor::BrightCyan.on_default().bold(),
TerminalRole::Option => AnsiColor::BrightGreen.on_default().bold(),
TerminalRole::Command | TerminalRole::Match => AnsiColor::BrightYellow.on_default().bold(),
TerminalRole::Environment => AnsiColor::BrightCyan.on_default(),
TerminalRole::Variable | TerminalRole::Path => AnsiColor::BrightMagenta.on_default(),
TerminalRole::Coordinate | TerminalRole::TreeGuide | TerminalRole::Muted => {
AnsiColor::BrightBlack.on_default()
}
}
}
fn document_label(document: &str, section: Option<&str>) -> String {
section.map_or_else(
|| document.to_owned(),
|section| format!("{document}({section})"),
)
}
fn terminal_content(query: &ResolvedContent) -> ResolvedContent {
let mut query = query.clone();
query.label = sanitize_terminal_text(&query.label).into_owned();
if let Some(document) = query.document.as_mut()
&& let Some(section) = document.meta.manual_section.as_mut()
{
*section = sanitize_terminal_text(section).into_owned();
}
query
}
fn terminal_excerpt(excerpt: &QueryExcerpt) -> QueryExcerpt {
let mut excerpt = excerpt.clone();
excerpt.label = sanitize_terminal_text(&excerpt.label).into_owned();
if let Some(meta) = excerpt.meta.as_mut()
&& let Some(section) = meta.manual_section.as_mut()
{
*section = sanitize_terminal_text(section).into_owned();
}
excerpt
}
pub(super) fn render_query_result(
result: &QueryViewResult,
format: QueryFormat,
pretty: bool,
preserve_anchors: bool,
color: bool,
) -> Result<String, Failure> {
match result {
QueryViewResult::Full(query) => render_full_query(query, format, pretty, preserve_anchors),
QueryViewResult::Outline(outline) => match format {
QueryFormat::Markdown => Ok(mant_engine::render_outline_markdown(outline)),
QueryFormat::Text => Ok(render_terminal_outline(outline, color)),
QueryFormat::Man => Err(Failure::usage(
"--format man applies only to full documents",
)),
QueryFormat::Json => {
mant_engine::render_outline_json(outline, pretty).map_err(Failure::operational)
}
},
QueryViewResult::Excerpt(excerpt) => {
render_excerpt(excerpt, format, pretty, preserve_anchors, color)
}
QueryViewResult::Search(search) => match format {
QueryFormat::Markdown => Ok(mant_engine::render_search_markdown(search)),
QueryFormat::Text => Ok(render_terminal_search(search, color)),
QueryFormat::Man => Err(Failure::usage(
"--format man applies only to full documents",
)),
QueryFormat::Json => {
mant_engine::render_search_json(search, pretty).map_err(Failure::operational)
}
},
}
}
pub(super) fn render_scope_query_result(
response: &ScopeQueryResponse,
format: QueryFormat,
pretty: bool,
preserve_anchors: bool,
color: bool,
) -> Result<String, Failure> {
if format == QueryFormat::Json {
return render_json(response, pretty);
}
if format == QueryFormat::Man {
return Err(Failure::usage(
"--format man applies only to one full native manual",
));
}
let mut output = String::new();
match &response.result {
ScopeQueryResult::Explain {
matches, failures, ..
} => {
for (index, found) in matches.iter().enumerate() {
if index > 0 {
output.push_str("\n\n");
}
write_scope_heading(&mut output, &found.address.catalog_path(), format, color);
output.push('\n');
let rendered = match format {
QueryFormat::Markdown => mant_engine::render_excerpt_markdown_with_options(
&found.excerpt,
mant_engine::MarkdownOptions { preserve_anchors },
),
QueryFormat::Text => render_terminal_excerpt(&found.excerpt, color),
QueryFormat::Json | QueryFormat::Man => unreachable!(),
};
output.push_str(rendered.trim());
}
for failure in failures {
if !output.is_empty() {
output.push_str("\n\n");
}
write_scope_heading(&mut output, &failure.address.catalog_path(), format, color);
output.push('\n');
output.push_str(&sanitize_terminal_text(&failure.reason));
}
}
ScopeQueryResult::Search { search } => {
for (index, found) in search.documents.iter().enumerate() {
if index > 0 {
output.push_str("\n\n");
}
write_scope_heading(&mut output, &found.address.catalog_path(), format, color);
output.push('\n');
let rendered = match format {
QueryFormat::Markdown => mant_engine::render_search_markdown(&found.search),
QueryFormat::Text => render_terminal_search(&found.search, color),
QueryFormat::Json | QueryFormat::Man => unreachable!(),
};
output.push_str(rendered.trim());
}
}
}
Ok(output)
}
fn write_scope_heading(output: &mut String, address: &str, format: QueryFormat, color: bool) {
let address = sanitize_terminal_text(address);
match format {
QueryFormat::Markdown => {
output.push_str("## ");
output.push_str(&address);
}
QueryFormat::Text if color => {
let style = terminal_style(TerminalRole::Document);
write!(output, "{style}{address}{style:#}").expect("writing to String cannot fail");
}
QueryFormat::Text => output.push_str(&address),
QueryFormat::Json | QueryFormat::Man => unreachable!(),
}
}
fn render_excerpt(
excerpt: &mant_protocol::QueryExcerpt,
format: QueryFormat,
pretty: bool,
preserve_anchors: bool,
color: bool,
) -> Result<String, Failure> {
match format {
QueryFormat::Markdown => Ok(mant_engine::render_excerpt_markdown_with_options(
excerpt,
mant_engine::MarkdownOptions { preserve_anchors },
)),
QueryFormat::Text => Ok(render_terminal_excerpt(excerpt, color)),
QueryFormat::Man => Err(Failure::usage(
"--format man applies only to full documents",
)),
QueryFormat::Json => {
mant_engine::render_excerpt_json(excerpt, pretty).map_err(Failure::operational)
}
}
}
fn render_full_query(
query: &ResolvedContent,
format: QueryFormat,
pretty: bool,
preserve_anchors: bool,
) -> Result<String, Failure> {
match format {
QueryFormat::Markdown => Ok(mant_engine::render_markdown_with_options(
query,
mant_engine::MarkdownOptions { preserve_anchors },
)),
QueryFormat::Text => {
let query = terminal_content(query);
Ok(mant_engine::render_query_text(&query))
}
QueryFormat::Man => {
let Some(document) = query.document.as_ref() else {
return Err(Failure::operational(
"manual page is unavailable; --format man cannot render tldr-only content",
));
};
if document.source.format == SourceFormat::Markdown {
return Err(Failure::usage(
"--format man applies only to roff manual pages",
));
}
let query = terminal_content(query);
Ok(mant_engine::render_query_man(&query))
}
QueryFormat::Json => {
mant_engine::render_query_json(query, pretty).map_err(Failure::operational)
}
}
}
pub(super) fn render_json(value: &impl Serialize, pretty: bool) -> Result<String, Failure> {
if pretty {
serde_json::to_string_pretty(value).map_err(Failure::operational)
} else {
serde_json::to_string(value).map_err(Failure::operational)
}
}
#[cfg(test)]
mod tests {
use mant_engine::{project_query_view, query_markdown_text};
use mant_protocol::{OutlineDetail, QueryView};
use super::{QueryFormat, render_query_result};
const PAGE: &str = r"# demo
## Options
<!-- mant:entries role=option -->
- `--color WHEN`: Select terminal colour behavior.
The selected color is visible in terminal output.
";
#[test]
fn terminal_styles_do_not_change_visible_query_text() {
for view in [
QueryView::Outline {
detail: OutlineDetail::Entries,
},
QueryView::Explain {
entry: "--color".to_owned(),
},
QueryView::Search {
pattern: "color".to_owned(),
syntax: mant_protocol::SearchSyntax::Literal,
case: mant_protocol::SearchCase::Insensitive,
scope: mant_protocol::SearchScope::Visible,
word: false,
context_lines: 1,
limit: 100,
offset: 0,
},
] {
let query = query_markdown_text(PAGE, None).expect("Markdown query");
let result = project_query_view(query, &view).expect("query projection");
let plain = render_query_result(&result, QueryFormat::Text, true, false, false)
.expect("plain terminal text");
let colored = render_query_result(&result, QueryFormat::Text, true, false, true)
.expect("colored terminal text");
assert!(colored.contains("\x1b["));
assert_eq!(strip_ansi(&colored), plain);
}
}
#[test]
fn structured_and_markdown_formats_never_receive_terminal_styles() {
let query = query_markdown_text(PAGE, None).expect("Markdown query");
let result = project_query_view(
query,
&QueryView::Explain {
entry: "--color".to_owned(),
},
)
.expect("explanation");
for format in [QueryFormat::Markdown, QueryFormat::Json] {
let rendered = render_query_result(&result, format, true, false, true)
.expect("deterministic output");
assert!(!rendered.contains("\x1b["));
}
}
#[test]
fn uncoloured_terminal_presentations_mask_controls_in_direct_input_labels() {
let source_path = "ev\u{1b}[31mil.md".to_owned();
let views = [
QueryView::Full {},
QueryView::Outline {
detail: OutlineDetail::Entries,
},
QueryView::Explain {
entry: "--color".to_owned(),
},
QueryView::Search {
pattern: "color".to_owned(),
syntax: mant_protocol::SearchSyntax::Literal,
case: mant_protocol::SearchCase::Insensitive,
scope: mant_protocol::SearchScope::Visible,
word: false,
context_lines: 1,
limit: 100,
offset: 0,
},
];
for view in views {
let query = query_markdown_text(PAGE, Some(source_path.clone()))
.expect("Markdown query with hostile label");
let result = project_query_view(query, &view).expect("query projection");
let rendered = render_query_result(&result, QueryFormat::Text, true, false, false)
.expect("plain terminal text");
assert!(!rendered.contains('\u{1b}'));
assert!(rendered.contains("ev�[31mil.md"));
}
}
fn strip_ansi(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == 0x1b && bytes.get(index + 1) == Some(&b'[') {
index += 2;
while index < bytes.len() {
let byte = bytes[index];
index += 1;
if byte.is_ascii_alphabetic() {
break;
}
}
} else {
let character = value[index..].chars().next().expect("UTF-8 character");
output.push(character);
index += character.len_utf8();
}
}
output
}
}