use std::{collections::HashSet, error::Error, fmt};
use mant_ast::{
Block, DefinitionCase, DefinitionItem, DefinitionRole, ExcerptSchema, ExcerptSelection,
OutlineDetail, OutlineNode, OutlineReference, OutlineSchema, QueryBundle, QueryExcerpt,
QueryOutline, Section,
};
use crate::definitions::definition_entries;
const TLDR_PATH: &str = "0";
pub(crate) const TLDR_ID: &str = "tldr";
const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
pub(crate) const DOCUMENT_ROOT_PATH: &str = "root";
pub(crate) const DOCUMENT_ROOT_ID: &str = "document-overview";
pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
pub(crate) fn is_reserved_selector(value: &str) -> bool {
matches!(
value,
TLDR_PATH | TLDR_ID | DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID
) || is_outline_path(value)
}
fn is_outline_path(value: &str) -> bool {
if let Some(entry) = value.strip_prefix("root/o") {
return !entry.is_empty() && entry.bytes().all(|byte| byte.is_ascii_digit());
}
let (sections, entry) = value
.split_once("/o")
.map_or((value, None), |(sections, entry)| (sections, Some(entry)));
let section_path = !sections.is_empty()
&& sections
.split('.')
.all(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
let entry_path = entry
.is_none_or(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
section_path && entry_path
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProjectionError {
MissingContent {
document: String,
},
EmptySelection,
EmptySelector,
UnknownSelector {
document: String,
selector: String,
},
AmbiguousSelector {
document: String,
selector: String,
candidates: Vec<SelectorCandidate>,
},
ExplanationRequiresEntry {
document: String,
selector: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectorCandidate {
pub path: String,
pub id: String,
}
impl fmt::Display for ProjectionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingContent { document } => {
write!(formatter, "document '{document}' has no available content")
}
Self::EmptySelection => formatter.write_str("at least one outline node is required"),
Self::EmptySelector => formatter.write_str("outline node must not be empty"),
Self::UnknownSelector { document, selector } => write!(
formatter,
"document '{document}' has no outline node '{selector}'; inspect its entries outline as JSON for available selectors and diagnostics"
),
Self::AmbiguousSelector {
document,
selector,
candidates,
} => {
write!(
formatter,
"document '{document}' has multiple semantic entries named '{selector}': "
)?;
for (index, candidate) in candidates.iter().enumerate() {
if index > 0 {
formatter.write_str(", ")?;
}
write!(formatter, "{} ({})", candidate.path, candidate.id)?;
}
formatter.write_str("; select one by path or ID")
}
Self::ExplanationRequiresEntry { document, selector } => write!(
formatter,
"document '{document}' outline node '{selector}' is not a semantic entry; use --node for sections"
),
}
}
}
impl Error for ProjectionError {}
pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
build_outline_with_detail(query, OutlineDetail::Sections)
}
pub fn build_outline_with_detail(
query: &QueryBundle,
detail: OutlineDetail,
) -> Result<QueryOutline, ProjectionError> {
if query.tldr.is_none() && query.document.is_none() {
return Err(ProjectionError::MissingContent {
document: query.label.clone(),
});
}
let diagnostics = query
.document
.as_ref()
.map_or_else(Vec::new, |document| document.diagnostics.clone());
let entries_complete = diagnostics.iter().all(|diagnostic| {
!diagnostic
.code
.as_deref()
.is_some_and(|code| code.starts_with("markdown.semantic-entry"))
});
let mut nodes = Vec::new();
if query.tldr.is_some() {
nodes.push(OutlineNode::Tldr {
path: TLDR_PATH.to_owned(),
id: TLDR_ID.to_owned(),
title: TLDR_TITLE.to_owned(),
});
}
if let Some(manual) = &query.document {
if !manual.blocks.is_empty() {
nodes.push(OutlineNode::DocumentRoot {
path: DOCUMENT_ROOT_PATH.to_owned(),
id: DOCUMENT_ROOT_ID.to_owned(),
title: DOCUMENT_ROOT_TITLE.to_owned(),
});
if detail == OutlineDetail::Entries {
nodes.extend(
definition_entries(&manual.blocks)
.into_iter()
.enumerate()
.filter_map(|(index, (entry, _))| {
let identity = entry.identity.as_ref()?;
Some(OutlineNode::DocumentEntry {
path: format!("{DOCUMENT_ROOT_PATH}/o{}", index + 1),
id: identity.id.clone(),
title: identity.names.join(", "),
role: identity.role,
case: identity.case,
names: identity.names.clone(),
})
}),
);
}
}
nodes.extend(outline_nodes(&manual.sections, &[], detail));
}
Ok(QueryOutline {
schema: OutlineSchema::V6,
detail,
label: query.label.clone(),
source: query
.document
.as_ref()
.map(|document| document.source.clone()),
meta: query
.document
.as_ref()
.map(|document| document.meta.clone()),
diagnostics,
entries_complete,
nodes,
})
}
pub fn select_excerpt(
query: &QueryBundle,
selectors: &[String],
) -> Result<QueryExcerpt, ProjectionError> {
if selectors.is_empty() {
return Err(ProjectionError::EmptySelection);
}
if query.tldr.is_none() && query.document.is_none() {
return Err(ProjectionError::MissingContent {
document: query.label.clone(),
});
}
let mut located = Vec::new();
if let Some(manual) = &query.document {
collect_root_entries(&manual.blocks, &mut located);
collect_sections(&manual.sections, &[], &[], &mut located);
}
let mut tldr_selected = false;
let mut document_root_selected = false;
let mut selected_ids = HashSet::new();
let mut selected = Vec::new();
for raw_selector in selectors {
let selector = raw_selector.trim();
if selector.is_empty() {
return Err(ProjectionError::EmptySelector);
}
if matches!(selector, TLDR_PATH | TLDR_ID) && query.tldr.is_some() {
tldr_selected = true;
continue;
}
if matches!(selector, DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID)
&& query
.document
.as_ref()
.is_some_and(|document| !document.blocks.is_empty())
{
document_root_selected = true;
continue;
}
let candidate = resolve_candidate(query, &located, selector)?;
if selected_ids.insert(candidate.id()) {
selected.push(candidate);
}
}
let selected_sections = selected
.iter()
.filter(|candidate| candidate.is_section())
.map(|candidate| candidate.coordinates().to_vec())
.collect::<Vec<_>>();
selected.retain(|candidate| {
if document_root_selected && candidate.path().starts_with("root/o") {
return false;
}
!selected_sections.iter().any(|ancestor| {
if candidate.is_section() {
ancestor != candidate.coordinates()
&& is_ancestor(ancestor, candidate.coordinates())
} else {
ancestor == candidate.coordinates()
|| is_ancestor(ancestor, candidate.coordinates())
}
})
});
selected.sort_by_key(|candidate| candidate.order());
let document = if selected.is_empty() && !document_root_selected {
None
} else {
query.document.as_ref()
};
let mut selections = Vec::new();
if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
selections.push(ExcerptSelection::Tldr {
path: TLDR_PATH.to_owned(),
id: TLDR_ID.to_owned(),
title: TLDR_TITLE.to_owned(),
document,
});
}
if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
selections.push(ExcerptSelection::DocumentRoot {
path: DOCUMENT_ROOT_PATH.to_owned(),
id: DOCUMENT_ROOT_ID.to_owned(),
title: DOCUMENT_ROOT_TITLE.to_owned(),
blocks: document.blocks.clone(),
});
}
selections.extend(selected.into_iter().map(LocatedNode::selection));
Ok(QueryExcerpt {
schema: ExcerptSchema::V6,
label: query.label.clone(),
producer: document.map(|document| document.producer.clone()),
source: document.map(|document| document.source.clone()),
meta: document.map(|document| document.meta.clone()),
diagnostics: document
.map(|document| document.diagnostics.clone())
.unwrap_or_default(),
selections,
})
}
pub fn select_explanation(
query: &QueryBundle,
selector: &str,
) -> Result<QueryExcerpt, ProjectionError> {
if query.tldr.is_none() && query.document.is_none() {
return Err(ProjectionError::MissingContent {
document: query.label.clone(),
});
}
let selector = selector.trim();
if selector.is_empty() {
return Err(ProjectionError::EmptySelector);
}
let mut located = Vec::new();
if let Some(manual) = &query.document {
collect_root_entries(&manual.blocks, &mut located);
collect_sections(&manual.sections, &[], &[], &mut located);
}
let candidate = resolve_explanation_candidate(query, &located, selector)?;
select_excerpt(query, &[candidate.path().to_owned()])
}
fn resolve_explanation_candidate<'a>(
query: &QueryBundle,
located: &'a [LocatedNode<'a>],
selector: &str,
) -> Result<&'a LocatedNode<'a>, ProjectionError> {
if let Some(candidate) = located.iter().find(|candidate| {
!candidate.is_section() && (candidate.path() == selector || candidate.id() == selector)
}) {
return Ok(candidate);
}
let matches = located
.iter()
.filter(|candidate| candidate.matches_alias(selector))
.collect::<Vec<_>>();
match matches.as_slice() {
[candidate] => return Ok(candidate),
[] => {}
_ => {
return Err(ProjectionError::AmbiguousSelector {
document: query.label.clone(),
selector: selector.to_owned(),
candidates: matches
.into_iter()
.map(|candidate| SelectorCandidate {
path: candidate.path().to_owned(),
id: candidate.id().to_owned(),
})
.collect(),
});
}
}
let selects_tldr = matches!(selector, TLDR_PATH | TLDR_ID) && query.tldr.is_some();
let selects_root = matches!(selector, DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID)
&& query
.document
.as_ref()
.is_some_and(|document| !document.blocks.is_empty());
let selects_section = located.iter().any(|candidate| {
candidate.is_section() && (candidate.path() == selector || candidate.id() == selector)
});
if selects_tldr || selects_root || selects_section {
return Err(ProjectionError::ExplanationRequiresEntry {
document: query.label.clone(),
selector: selector.to_owned(),
});
}
Err(ProjectionError::UnknownSelector {
document: query.label.clone(),
selector: selector.to_owned(),
})
}
fn resolve_candidate<'a>(
query: &QueryBundle,
located: &'a [LocatedNode<'a>],
selector: &str,
) -> Result<&'a LocatedNode<'a>, ProjectionError> {
if let Some(candidate) = located
.iter()
.find(|candidate| candidate.path() == selector || candidate.id() == selector)
{
return Ok(candidate);
}
let matches = located
.iter()
.filter(|candidate| candidate.matches_alias(selector))
.collect::<Vec<_>>();
match matches.as_slice() {
[] => Err(ProjectionError::UnknownSelector {
document: query.label.clone(),
selector: selector.to_owned(),
}),
[candidate] => Ok(candidate),
_ => Err(ProjectionError::AmbiguousSelector {
document: query.label.clone(),
selector: selector.to_owned(),
candidates: matches
.into_iter()
.map(|candidate| SelectorCandidate {
path: candidate.path().to_owned(),
id: candidate.id().to_owned(),
})
.collect(),
}),
}
}
fn outline_nodes(
sections: &[Section],
parent: &[usize],
detail: OutlineDetail,
) -> Vec<OutlineNode> {
sections
.iter()
.enumerate()
.map(|(index, section)| {
let mut coordinates = parent.to_vec();
coordinates.push(index + 1);
let path = format_path(&coordinates);
let mut children = Vec::new();
if detail == OutlineDetail::Entries {
children.extend(
definition_entries(§ion.blocks)
.into_iter()
.enumerate()
.filter_map(|(index, (entry, _))| {
let identity = entry.identity.as_ref()?;
Some(OutlineNode::DocumentEntry {
path: format!("{path}/o{}", index + 1),
id: identity.id.clone(),
title: identity.names.join(", "),
role: identity.role,
case: identity.case,
names: identity.names.clone(),
})
}),
);
}
children.extend(outline_nodes(§ion.children, &coordinates, detail));
OutlineNode::DocumentSection {
path,
id: section.id.clone(),
title: section.title.clone(),
children,
}
})
.collect()
}
enum LocatedNode<'a> {
Section {
order: usize,
coordinates: Vec<usize>,
path: String,
breadcrumbs: Vec<OutlineReference>,
section: &'a Section,
},
Entry {
order: usize,
coordinates: Vec<usize>,
path: String,
title: String,
breadcrumbs: Vec<OutlineReference>,
entry: &'a DefinitionItem,
},
}
impl LocatedNode<'_> {
fn order(&self) -> usize {
match self {
Self::Section { order, .. } | Self::Entry { order, .. } => *order,
}
}
fn coordinates(&self) -> &[usize] {
match self {
Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
}
}
fn path(&self) -> &str {
match self {
Self::Section { path, .. } | Self::Entry { path, .. } => path,
}
}
fn id(&self) -> &str {
match self {
Self::Section { section, .. } => §ion.id,
Self::Entry { entry, .. } => {
&entry
.identity
.as_ref()
.expect("located entries have identities")
.id
}
}
}
fn matches_alias(&self, selector: &str) -> bool {
match self {
Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
identity
.names
.iter()
.any(|name| semantic_name_matches(identity.role, identity.case, name, selector))
}),
Self::Section { .. } => false,
}
}
const fn is_section(&self) -> bool {
matches!(self, Self::Section { .. })
}
fn selection(&self) -> ExcerptSelection {
match self {
Self::Section {
path,
breadcrumbs,
section,
..
} => ExcerptSelection::DocumentSection {
path: path.clone(),
id: section.id.clone(),
title: section.title.clone(),
breadcrumbs: breadcrumbs.clone(),
section: (*section).clone(),
},
Self::Entry {
path,
title,
breadcrumbs,
entry,
..
} => ExcerptSelection::DocumentEntry {
path: path.clone(),
id: entry
.identity
.as_ref()
.expect("located entries have identities")
.id
.clone(),
title: title.clone(),
breadcrumbs: breadcrumbs.clone(),
entry: (*entry).clone(),
},
}
}
}
fn semantic_name_matches(
role: DefinitionRole,
case: DefinitionCase,
name: &str,
selector: &str,
) -> bool {
let equivalent = |left: &str, right: &str| match case {
DefinitionCase::Sensitive => left == right,
DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
};
if equivalent(name, selector) {
return true;
}
match role {
DefinitionRole::Option => equivalent(name.trim_start_matches('-'), selector),
DefinitionRole::EnvironmentVariable => {
let normalized = name
.strip_prefix("$env:")
.or_else(|| name.strip_prefix("$ENV:"))
.unwrap_or(name);
equivalent(normalized, selector)
}
DefinitionRole::Command | DefinitionRole::Variable => false,
}
}
fn collect_sections<'a>(
sections: &'a [Section],
parent_coordinates: &[usize],
breadcrumbs: &[OutlineReference],
output: &mut Vec<LocatedNode<'a>>,
) {
for (index, section) in sections.iter().enumerate() {
let mut coordinates = parent_coordinates.to_vec();
coordinates.push(index + 1);
let path = format_path(&coordinates);
let order = output.len();
output.push(LocatedNode::Section {
order,
coordinates: coordinates.clone(),
path: path.clone(),
breadcrumbs: breadcrumbs.to_vec(),
section,
});
let mut child_breadcrumbs = breadcrumbs.to_vec();
child_breadcrumbs.push(OutlineReference {
path: path.clone(),
id: section.id.clone(),
title: section.title.clone(),
});
for (index, (entry, _)) in definition_entries(§ion.blocks).into_iter().enumerate() {
let Some(identity) = &entry.identity else {
continue;
};
output.push(LocatedNode::Entry {
order: output.len(),
coordinates: coordinates.clone(),
path: format!("{path}/o{}", index + 1),
title: identity.names.join(", "),
breadcrumbs: child_breadcrumbs.clone(),
entry,
});
}
collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
}
}
fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
let breadcrumbs = vec![OutlineReference {
path: DOCUMENT_ROOT_PATH.to_owned(),
id: DOCUMENT_ROOT_ID.to_owned(),
title: DOCUMENT_ROOT_TITLE.to_owned(),
}];
for (index, (entry, _)) in definition_entries(blocks).into_iter().enumerate() {
let Some(identity) = &entry.identity else {
continue;
};
output.push(LocatedNode::Entry {
order: output.len(),
coordinates: Vec::new(),
path: format!("{DOCUMENT_ROOT_PATH}/o{}", index + 1),
title: identity.names.join(", "),
breadcrumbs: breadcrumbs.clone(),
entry,
});
}
}
fn format_path(coordinates: &[usize]) -> String {
coordinates
.iter()
.map(usize::to_string)
.collect::<Vec<_>>()
.join(".")
}
fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
}
#[cfg(test)]
mod tests {
use mant_ast::{
Block, DocumentMeta, DocumentSchema, DocumentSource, ExcerptSelection, Inline, LayoutHint,
MantDocument, OutlineNode, Producer, QueryBundle, QuerySchema, Section, SourceFormat,
TldrDocument, TldrOrigin,
};
use super::{ProjectionError, build_outline, select_excerpt};
fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
Section {
id: id.to_owned(),
title: title.to_owned(),
spacing_before_lines: 0,
blocks: Vec::new(),
children,
source: None,
}
}
fn query() -> QueryBundle {
QueryBundle {
schema: QuerySchema::V6,
label: "demo".to_owned(),
document: Some(MantDocument {
schema: DocumentSchema::V6,
producer: Producer {
name: "test".to_owned(),
version: "1".to_owned(),
engine: None,
},
source: DocumentSource {
format: SourceFormat::Man,
path: Some("/man/demo.1".to_owned()),
},
meta: DocumentMeta {
section: Some("1".to_owned()),
..DocumentMeta::default()
},
diagnostics: Vec::new(),
blocks: Vec::new(),
sections: vec![
section("name-1", "NAME", Vec::new()),
section(
"options-2",
"OPTIONS",
vec![
section("common-3", "Common options", Vec::new()),
section("other-4", "Other options", Vec::new()),
],
),
section("files-5", "FILES", Vec::new()),
],
}),
tldr: None,
}
}
fn tldr() -> TldrDocument {
TldrDocument {
title: "demo".to_owned(),
description: vec!["A small demonstration.".to_owned()],
more_information: Some("https://example.com/demo".to_owned()),
examples: Vec::new(),
platform: "common".to_owned(),
language: "en".to_owned(),
source_path: "/tldr/pages/common/demo.md".to_owned(),
origin: TldrOrigin::TldrPages,
}
}
#[test]
fn builds_one_based_tree_paths_without_copying_blocks() {
let outline = build_outline(&query()).expect("outline");
assert_eq!(
outline
.meta
.as_ref()
.and_then(|meta| meta.section.as_deref()),
Some("1")
);
assert_eq!(outline.nodes[1].path(), "2");
assert_eq!(outline.nodes[1].id(), "options-2");
assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
}
#[test]
fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
let mut query = query();
query.tldr = Some(tldr());
let outline = build_outline(&query).expect("combined outline");
assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
assert_eq!(outline.nodes[0].path(), "0");
assert_eq!(outline.nodes[0].id(), "tldr");
assert_eq!(outline.nodes[1].path(), "1");
assert_eq!(outline.nodes[2].path(), "2");
}
#[test]
fn addresses_document_content_before_the_first_heading_as_root() {
let mut query = query();
let document = query.document.as_mut().expect("document");
document.source.format = SourceFormat::Markdown;
document.blocks.push(Block::Paragraph {
children: vec![Inline::Text {
value: "Document preface.".to_owned(),
}],
layout: LayoutHint::default(),
source: None,
});
let outline = build_outline(&query).expect("Markdown outline");
assert!(matches!(
&outline.nodes[0],
OutlineNode::DocumentRoot { path, id, title }
if path == "root" && id == "document-overview" && title == "OVERVIEW"
));
assert_eq!(outline.nodes[1].path(), "1");
let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
.expect("root excerpt");
assert!(matches!(
excerpt.selections.as_slice(),
[ExcerptSelection::DocumentRoot { path, blocks, .. }]
if path == "root" && blocks.len() == 1
));
assert_eq!(
excerpt.source.as_ref().map(|source| source.format),
Some(SourceFormat::Markdown)
);
}
#[test]
fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
let excerpt = select_excerpt(
&query(),
&[
"files-5".to_owned(),
"2.1".to_owned(),
"2".to_owned(),
"options-2".to_owned(),
],
)
.expect("excerpt");
let paths = excerpt
.selections
.iter()
.map(|selection| match selection {
ExcerptSelection::Tldr { path, .. }
| ExcerptSelection::DocumentRoot { path, .. }
| ExcerptSelection::DocumentSection { path, .. }
| ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
})
.collect::<Vec<_>>();
assert_eq!(paths, ["2", "3"]);
let ExcerptSelection::DocumentSection {
section,
breadcrumbs,
..
} = &excerpt.selections[0]
else {
panic!("expected manual selection");
};
assert_eq!(section.children.len(), 2);
assert!(breadcrumbs.is_empty());
}
#[test]
fn child_selection_retains_ancestor_breadcrumbs() {
let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
let ExcerptSelection::DocumentSection {
title, breadcrumbs, ..
} = &excerpt.selections[0]
else {
panic!("expected manual selection");
};
assert_eq!(title, "Other options");
assert_eq!(breadcrumbs[0].path, "2");
assert_eq!(breadcrumbs[0].title, "OPTIONS");
}
#[test]
fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
let mut combined = query();
combined.tldr = Some(tldr());
let excerpt = select_excerpt(
&combined,
&["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
)
.expect("combined excerpt");
assert!(matches!(
excerpt.selections.as_slice(),
[ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
if path == "0"
));
let mut tldr_only = combined;
tldr_only.document = None;
let outline = build_outline(&tldr_only).expect("tldr-only outline");
assert_eq!(outline.nodes.len(), 1);
assert_eq!(outline.nodes[0].path(), "0");
assert!(outline.source.is_none());
assert!(outline.meta.is_none());
}
#[test]
fn reports_missing_content_and_unknown_or_empty_selectors() {
let mut empty = query();
empty.document = None;
assert!(matches!(
build_outline(&empty),
Err(ProjectionError::MissingContent { .. })
));
assert_eq!(
select_excerpt(&query(), &[]),
Err(ProjectionError::EmptySelection)
);
assert_eq!(
select_excerpt(&query(), &[" ".to_owned()]),
Err(ProjectionError::EmptySelector)
);
assert!(matches!(
select_excerpt(&query(), &["9".to_owned()]),
Err(ProjectionError::UnknownSelector { .. })
));
}
}