1use std::{
4 collections::{BTreeMap, BTreeSet, HashMap, HashSet},
5 error::Error,
6 fmt,
7};
8
9use mant_ir::{
10 Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
11 DiagnosticLevel, EntryKindCount, EntrySummary, OutlinePath, Section, SemanticEntry,
12 SemanticIndex, SourceSpan,
13};
14use mant_protocol::{
15 EntryProjection, ExcerptSchema, ExcerptSelection, NodeSelector, OutlineDetail, OutlineNode,
16 OutlineNodeReference, OutlineReference, OutlineSchema, OutlineTrail, QueryExcerpt,
17 QueryOutline,
18};
19
20use crate::{
21 ResolvedContent,
22 definitions::{definition_entries, environment_variable_body},
23 inline::plain_text,
24};
25
26pub(crate) const TLDR_ID: &str = "tldr";
27const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
28pub(crate) use mant_ir::DOCUMENT_ROOT_ID;
29pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
30
31pub(crate) fn is_reserved_selector(value: &str) -> bool {
39 matches!(value, TLDR_ID | DOCUMENT_ROOT_ID)
40 || value.parse::<OutlinePath>().is_ok()
41 || [
42 "option-",
43 "marker-",
44 "operand-",
45 "command-",
46 "configuration-",
47 "environment-",
48 "variable-",
49 "value-",
50 "term-",
51 ]
52 .iter()
53 .any(|prefix| value.starts_with(prefix))
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ProjectionError {
59 MissingContent {
61 document: String,
63 },
64 EmptySelection,
66 EmptySelector,
68 UnknownSelector {
70 document: String,
72 selector: String,
74 },
75 SelectorFoundOnlyInText {
78 document: String,
80 selector: String,
82 path: String,
84 title: String,
86 line: u32,
88 },
89 AmbiguousSelector {
91 document: String,
93 selector: String,
95 candidates: Vec<SelectorCandidate>,
97 },
98 ExplanationRequiresEntry {
100 document: String,
102 selector: String,
104 },
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct SelectorCandidate {
110 pub path: String,
112 pub id: String,
114}
115
116impl fmt::Display for ProjectionError {
117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118 match self {
119 Self::MissingContent { document } => {
120 write!(formatter, "document '{document}' has no available content")
121 }
122 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
123 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
124 Self::UnknownSelector { document, selector } => write!(
125 formatter,
126 "document '{document}' has no outline node '{selector}'; inspect its entries outline for available selectors and diagnostics"
127 ),
128 Self::SelectorFoundOnlyInText {
129 document,
130 selector,
131 path,
132 title,
133 line,
134 } => write!(
135 formatter,
136 "document '{document}' has no semantic entry '{selector}', but that text appears in outline node {path} ({title}) at line {line}"
137 ),
138 Self::AmbiguousSelector {
139 document,
140 selector,
141 candidates,
142 } => {
143 write!(
144 formatter,
145 "document '{document}' has multiple semantic entries named '{selector}': "
146 )?;
147 for (index, candidate) in candidates.iter().enumerate() {
148 if index > 0 {
149 formatter.write_str(", ")?;
150 }
151 write!(formatter, "{} ({})", candidate.path, candidate.id)?;
152 }
153 formatter.write_str("; select one by path or ID")
154 }
155 Self::ExplanationRequiresEntry { document, selector } => write!(
156 formatter,
157 "document '{document}' outline node '{selector}' is not a semantic entry; select a semantic entry instead"
158 ),
159 }
160 }
161}
162
163impl Error for ProjectionError {}
164
165pub fn build_outline(query: &ResolvedContent) -> Result<QueryOutline, ProjectionError> {
172 build_outline_projection(query, EntryProjection::Summary, None)
173}
174
175pub fn build_outline_with_detail(
182 query: &ResolvedContent,
183 detail: OutlineDetail,
184) -> Result<QueryOutline, ProjectionError> {
185 build_outline_projection(query, detail.into(), None)
186}
187
188pub fn build_outline_projection(
195 query: &ResolvedContent,
196 entries: EntryProjection,
197 root: Option<NodeSelector>,
198) -> Result<QueryOutline, ProjectionError> {
199 if query.tldr.is_none() && query.document.is_none() {
200 return Err(ProjectionError::MissingContent {
201 document: query.label.clone(),
202 });
203 }
204 let diagnostics = query
205 .document
206 .as_ref()
207 .map_or_else(Vec::new, |document| document.diagnostics.clone());
208 let entries_complete = diagnostics.iter().all(|diagnostic| {
209 !diagnostic.code.as_deref().is_some_and(|code| {
210 crate::markdown::is_semantic_entry_rejection_code(code)
211 || code == "manual.semantic-entry.unclassified-definition"
212 })
213 });
214 let materialized_entries = if root.is_some() {
215 EntryProjection::All
216 } else {
217 entries.clone()
218 };
219 let mut nodes = Vec::new();
220 if query.tldr.is_some() && !matches!(&materialized_entries, EntryProjection::Kinds { .. }) {
221 nodes.push(OutlineNode::Tldr {
222 path: OutlinePath::Tldr.to_string().into(),
223 id: TLDR_ID.into(),
224 title: TLDR_TITLE.to_owned(),
225 });
226 }
227 if let Some(manual) = &query.document {
228 let index = SemanticIndex::build(manual);
229 if !manual.blocks.is_empty() {
230 let root_entries = index.root();
231 let children = project_entries(root_entries, None, &[], &materialized_entries);
232 let root = OutlineNode::DocumentRoot {
233 path: OutlinePath::DocumentRoot.to_string().into(),
234 id: DOCUMENT_ROOT_ID.into(),
235 title: DOCUMENT_ROOT_TITLE.to_owned(),
236 entry_summary: projected_summary(root_entries, &materialized_entries),
237 children,
238 };
239 if !matches!(&materialized_entries, EntryProjection::Kinds { .. })
240 || !root.children().is_empty()
241 {
242 nodes.push(root);
243 }
244 }
245 nodes.extend(outline_nodes(
246 &manual.sections,
247 &[],
248 &index,
249 &materialized_entries,
250 ));
251 }
252 if let Some(selector) = root.as_ref() {
253 let mut selected = resolve_outline_root(query, &nodes, selector.as_str())?.clone();
254 reproject_selected_node(&mut selected, &entries, true);
255 nodes = vec![selected];
256 }
257 Ok(QueryOutline {
258 schema: OutlineSchema::V0Dot10,
259 entries,
260 root,
261 label: query.label.clone(),
262 source: query
263 .document
264 .as_ref()
265 .map(|document| document.source.clone()),
266 meta: query
267 .document
268 .as_ref()
269 .map(|document| document.meta.clone()),
270 diagnostics,
271 entries_complete,
272 nodes,
273 })
274}
275
276pub fn select_excerpt<S: AsRef<str>>(
285 query: &ResolvedContent,
286 selectors: &[S],
287) -> Result<QueryExcerpt, ProjectionError> {
288 if selectors.is_empty() {
289 return Err(ProjectionError::EmptySelection);
290 }
291 if query.tldr.is_none() && query.document.is_none() {
292 return Err(ProjectionError::MissingContent {
293 document: query.label.clone(),
294 });
295 }
296 let mut located = Vec::new();
297 if let Some(manual) = &query.document {
298 collect_root_entries(&manual.blocks, &mut located);
299 collect_sections(&manual.sections, &[], &[], &mut located);
300 }
301
302 let (tldr_selected, document_root_selected, mut selected) =
303 resolve_excerpt_candidates(query, selectors, &located)?;
304 let selected_sections = selected
305 .iter()
306 .filter(|candidate| candidate.is_section())
307 .map(|candidate| candidate.coordinates().to_vec())
308 .collect::<Vec<_>>();
309 selected.retain(|candidate| {
310 if document_root_selected && candidate.path().is_document_root_entry() {
311 return false;
312 }
313 !selected_sections.iter().any(|ancestor| {
314 if candidate.is_section() {
315 ancestor != candidate.coordinates()
316 && is_ancestor(ancestor, candidate.coordinates())
317 } else {
318 ancestor == candidate.coordinates()
319 || is_ancestor(ancestor, candidate.coordinates())
320 }
321 })
322 });
323 selected.sort_by_key(|candidate| candidate.order());
324
325 let document = if selected.is_empty() && !document_root_selected {
326 None
327 } else {
328 query.document.as_ref()
329 };
330 let mut selections = Vec::new();
331 if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
332 selections.push(ExcerptSelection::Tldr {
333 outline: OutlineTrail {
334 ancestors: Vec::new(),
335 node: OutlineNodeReference::Tldr {
336 path: OutlinePath::Tldr.to_string().into(),
337 id: TLDR_ID.into(),
338 title: TLDR_TITLE.to_owned(),
339 },
340 },
341 document,
342 });
343 }
344 if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
345 selections.push(ExcerptSelection::DocumentRoot {
346 outline: OutlineTrail {
347 ancestors: Vec::new(),
348 node: OutlineNodeReference::DocumentRoot {
349 path: OutlinePath::DocumentRoot.to_string().into(),
350 id: DOCUMENT_ROOT_ID.into(),
351 title: DOCUMENT_ROOT_TITLE.to_owned(),
352 },
353 },
354 blocks: document.blocks.clone(),
355 });
356 }
357 selections.extend(selected.into_iter().map(LocatedNode::selection));
358
359 Ok(QueryExcerpt {
360 schema: ExcerptSchema::V0Dot10,
361 label: query.label.clone(),
362 producer: document.map(mant_protocol::Producer::for_document),
363 source: document.map(|document| document.source.clone()),
364 meta: document.map(|document| document.meta.clone()),
365 diagnostics: document
366 .map(|document| document.diagnostics.clone())
367 .unwrap_or_default(),
368 selections,
369 })
370}
371
372fn resolve_excerpt_candidates<'a, S: AsRef<str>>(
373 query: &ResolvedContent,
374 selectors: &[S],
375 located: &'a [LocatedNode<'a>],
376) -> Result<(bool, bool, Vec<&'a LocatedNode<'a>>), ProjectionError> {
377 let mut tldr_selected = false;
378 let mut document_root_selected = false;
379 let mut selected_ids = HashSet::new();
380 let mut selected = Vec::new();
381 for raw_selector in selectors {
382 let selector = raw_selector.as_ref().trim();
383 if selector.is_empty() {
384 return Err(ProjectionError::EmptySelector);
385 }
386 if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr))
387 && query.tldr.is_some()
388 {
389 tldr_selected = true;
390 continue;
391 }
392 if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
393 && query
394 .document
395 .as_ref()
396 .is_some_and(|document| !document.blocks.is_empty())
397 {
398 document_root_selected = true;
399 continue;
400 }
401 let candidate = resolve_candidate(query, located, selector)?;
402 if selected_ids.insert(candidate.id()) {
403 selected.push(candidate);
404 }
405 }
406 Ok((tldr_selected, document_root_selected, selected))
407}
408
409pub fn select_explanation(
420 query: &ResolvedContent,
421 selector: &str,
422) -> Result<QueryExcerpt, ProjectionError> {
423 if query.tldr.is_none() && query.document.is_none() {
424 return Err(ProjectionError::MissingContent {
425 document: query.label.clone(),
426 });
427 }
428 let selector = selector.trim();
429 if selector.is_empty() {
430 return Err(ProjectionError::EmptySelector);
431 }
432 let mut located = Vec::new();
433 if let Some(manual) = &query.document {
434 collect_root_entries(&manual.blocks, &mut located);
435 collect_sections(&manual.sections, &[], &[], &mut located);
436 }
437 let candidate = resolve_explanation_candidate(query, &located, selector)?;
438 select_excerpt(query, &[candidate.path().to_string()])
439}
440
441fn resolve_explanation_candidate<'a>(
442 query: &ResolvedContent,
443 located: &'a [LocatedNode<'a>],
444 selector: &str,
445) -> Result<&'a LocatedNode<'a>, ProjectionError> {
446 let selects_tldr =
447 (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
448 let selects_root = (selector == DOCUMENT_ROOT_ID
449 || selector.parse() == Ok(OutlinePath::DocumentRoot))
450 && query
451 .document
452 .as_ref()
453 .is_some_and(|document| !document.blocks.is_empty());
454 if selects_tldr || selects_root {
455 return Err(ProjectionError::ExplanationRequiresEntry {
456 document: query.label.clone(),
457 selector: selector.to_owned(),
458 });
459 }
460 let candidate = resolve_candidate(query, located, selector)?;
461 if candidate.is_section() {
462 return Err(ProjectionError::ExplanationRequiresEntry {
463 document: query.label.clone(),
464 selector: selector.to_owned(),
465 });
466 }
467 Ok(candidate)
468}
469
470fn resolve_candidate<'a>(
471 query: &ResolvedContent,
472 located: &'a [LocatedNode<'a>],
473 selector: &str,
474) -> Result<&'a LocatedNode<'a>, ProjectionError> {
475 if let Some(candidate) = located
476 .iter()
477 .find(|candidate| candidate.matches_path(selector))
478 {
479 return Ok(candidate);
480 }
481 let ids = located
482 .iter()
483 .filter(|candidate| candidate.id() == selector)
484 .collect::<Vec<_>>();
485 match ids.as_slice() {
486 [candidate] => return Ok(candidate),
487 [] => {}
488 _ => return Err(ambiguous_selector(query, selector, ids)),
489 }
490
491 let matches = matching_aliases(located, selector).1;
492 match matches.as_slice() {
493 [] => Err(ProjectionError::UnknownSelector {
494 document: query.label.clone(),
495 selector: selector.to_owned(),
496 }),
497 [candidate] => Ok(candidate),
498 _ => Err(ambiguous_selector(query, selector, matches)),
499 }
500}
501
502fn ambiguous_selector(
503 query: &ResolvedContent,
504 selector: &str,
505 matches: Vec<&LocatedNode<'_>>,
506) -> ProjectionError {
507 ProjectionError::AmbiguousSelector {
508 document: query.label.clone(),
509 selector: selector.to_owned(),
510 candidates: matches
511 .into_iter()
512 .map(|candidate| SelectorCandidate {
513 path: candidate.path().to_string(),
514 id: candidate.id().into(),
515 })
516 .collect(),
517 }
518}
519
520fn outline_nodes(
521 sections: &[Section],
522 parent: &[usize],
523 index: &SemanticIndex,
524 entries: &EntryProjection,
525) -> Vec<OutlineNode> {
526 sections
527 .iter()
528 .enumerate()
529 .filter_map(|(section_index, section)| {
530 let mut coordinates = parent.to_vec();
531 coordinates.push(section_index + 1);
532 let path =
533 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
534 let semantic_entries = index.section(§ion.id);
535 let mut children = project_entries(semantic_entries, Some(&coordinates), &[], entries);
536 children.extend(outline_nodes(
537 §ion.children,
538 &coordinates,
539 index,
540 entries,
541 ));
542 let node = OutlineNode::DocumentSection {
543 path: path.to_string().into(),
544 id: section.id.clone(),
545 title: section.title.clone(),
546 entry_summary: projected_summary(semantic_entries, entries),
547 children,
548 };
549 (!matches!(entries, EntryProjection::Kinds { .. }) || !node.children().is_empty())
550 .then_some(node)
551 })
552 .collect()
553}
554
555fn projected_summary(
556 entries: &[SemanticEntry],
557 projection: &EntryProjection,
558) -> Option<EntrySummary> {
559 let summary = match projection {
560 EntryProjection::None => return None,
561 EntryProjection::Summary | EntryProjection::All => EntrySummary::for_entries(entries),
562 EntryProjection::Kinds { kinds } => filtered_entry_summary(entries, kinds),
563 };
564 (!summary.is_empty()).then_some(summary)
565}
566
567fn filtered_entry_summary(entries: &[SemanticEntry], kinds: &[mant_ir::EntryKind]) -> EntrySummary {
568 let mut summary = EntrySummary::default();
569 for entry in entries {
570 summarize_filtered_entry(entry, kinds, &mut summary, true);
571 }
572 summary
573}
574
575fn summarize_filtered_entry(
576 entry: &SemanticEntry,
577 kinds: &[mant_ir::EntryKind],
578 summary: &mut EntrySummary,
579 direct: bool,
580) {
581 if kinds.contains(&entry.kind) {
582 record_projected_summary(summary, entry.kind, entry.forms.len(), direct);
583 }
584 for child in &entry.children {
585 summarize_filtered_entry(child, kinds, summary, false);
586 }
587}
588
589fn record_projected_summary(
590 summary: &mut EntrySummary,
591 kind: mant_ir::EntryKind,
592 forms: usize,
593 direct: bool,
594) {
595 if direct {
596 summary.direct = summary.direct.saturating_add(1);
597 } else {
598 summary.descendants = summary.descendants.saturating_add(1);
599 }
600 summary.forms = summary
601 .forms
602 .saturating_add(u32::try_from(forms).unwrap_or(u32::MAX));
603 if let Some(count) = summary.by_kind.iter_mut().find(|count| count.kind == kind) {
604 count.count = count.count.saturating_add(1);
605 } else {
606 summary.by_kind.push(EntryKindCount { kind, count: 1 });
607 summary.by_kind.sort_by_key(|count| count.kind);
608 }
609}
610
611fn project_entries(
612 entries: &[SemanticEntry],
613 section: Option<&[usize]>,
614 parent: &[usize],
615 projection: &EntryProjection,
616) -> Vec<OutlineNode> {
617 if matches!(projection, EntryProjection::None | EntryProjection::Summary) {
618 return Vec::new();
619 }
620 entries
621 .iter()
622 .enumerate()
623 .filter_map(|(index, entry)| {
624 let mut coordinates = parent.to_vec();
625 coordinates.push(index + 1);
626 let children = project_entries(&entry.children, section, &coordinates, projection);
627 let selected = match projection {
628 EntryProjection::All => true,
629 EntryProjection::Kinds { kinds } => kinds.contains(&entry.kind),
630 EntryProjection::None | EntryProjection::Summary => false,
631 };
632 if !selected && children.is_empty() {
633 return None;
634 }
635 let title = (!entry.forms.is_empty())
636 .then(|| entry.forms.join(" | "))
637 .or_else(|| entry.aliases.first().cloned())
638 .unwrap_or_else(|| entry.id.to_string());
639 Some(OutlineNode::DocumentEntry {
640 path: OutlinePath::nested_entry(section, &coordinates)?
641 .to_string()
642 .into(),
643 id: entry.id.clone(),
644 title,
645 entry_kind: entry.kind,
646 case: entry.case,
647 aliases: entry.aliases.clone(),
648 forms: entry.forms.clone(),
649 targets: entry.targets.clone(),
650 value_domain: entry.value_domain.clone(),
651 entry_summary: projected_summary(&entry.children, projection),
652 children,
653 })
654 })
655 .collect()
656}
657
658fn find_outline_node<'a>(
659 nodes: &'a [OutlineNode],
660 predicate: &impl Fn(&OutlineNode) -> bool,
661) -> Option<&'a OutlineNode> {
662 for node in nodes {
663 if predicate(node) {
664 return Some(node);
665 }
666 if let Some(found) = find_outline_node(node.children(), predicate) {
667 return Some(found);
668 }
669 }
670 None
671}
672
673fn resolve_outline_root<'a>(
674 query: &ResolvedContent,
675 nodes: &'a [OutlineNode],
676 selector: &str,
677) -> Result<&'a OutlineNode, ProjectionError> {
678 if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some() {
679 return find_outline_node(nodes, &|node| node.path() == OutlinePath::Tldr.to_string())
680 .ok_or_else(|| ProjectionError::UnknownSelector {
681 document: query.label.clone(),
682 selector: selector.to_owned(),
683 });
684 }
685 if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
686 && query
687 .document
688 .as_ref()
689 .is_some_and(|document| !document.blocks.is_empty())
690 {
691 return find_outline_node(nodes, &|node| {
692 node.path() == OutlinePath::DocumentRoot.to_string()
693 })
694 .ok_or_else(|| ProjectionError::UnknownSelector {
695 document: query.label.clone(),
696 selector: selector.to_owned(),
697 });
698 }
699
700 let mut located = Vec::new();
701 if let Some(manual) = &query.document {
702 collect_root_entries(&manual.blocks, &mut located);
703 collect_sections(&manual.sections, &[], &[], &mut located);
704 }
705 let path = resolve_candidate(query, &located, selector)?
706 .path()
707 .to_string();
708 find_outline_node(nodes, &|node| node.path() == path).ok_or_else(|| {
709 ProjectionError::UnknownSelector {
710 document: query.label.clone(),
711 selector: selector.to_owned(),
712 }
713 })
714}
715
716fn reproject_selected_node(
717 node: &mut OutlineNode,
718 projection: &EntryProjection,
719 keep_self: bool,
720) -> bool {
721 match node {
722 OutlineNode::Tldr { .. } => true,
723 OutlineNode::DocumentRoot {
724 entry_summary,
725 children,
726 ..
727 }
728 | OutlineNode::DocumentSection {
729 entry_summary,
730 children,
731 ..
732 } => {
733 if matches!(projection, EntryProjection::None) {
734 *entry_summary = None;
735 }
736 children.retain_mut(|child| reproject_selected_node(child, projection, false));
737 if let EntryProjection::Kinds { kinds } = projection {
738 *entry_summary = projected_outline_summary(children, kinds);
739 }
740 true
741 }
742 OutlineNode::DocumentEntry {
743 entry_kind,
744 entry_summary,
745 children,
746 ..
747 } => {
748 if matches!(projection, EntryProjection::None) {
749 *entry_summary = None;
750 }
751 if matches!(projection, EntryProjection::None | EntryProjection::Summary) {
752 children.clear();
753 } else {
754 children.retain_mut(|child| reproject_selected_node(child, projection, false));
755 }
756 if let EntryProjection::Kinds { kinds } = projection {
757 *entry_summary = projected_outline_summary(children, kinds);
758 }
759 keep_self
760 || match projection {
761 EntryProjection::All => true,
762 EntryProjection::Kinds { kinds } => {
763 kinds.contains(entry_kind) || !children.is_empty()
764 }
765 EntryProjection::None | EntryProjection::Summary => false,
766 }
767 }
768 }
769}
770
771fn projected_outline_summary(
772 nodes: &[OutlineNode],
773 kinds: &[mant_ir::EntryKind],
774) -> Option<EntrySummary> {
775 fn visit(
776 node: &OutlineNode,
777 kinds: &[mant_ir::EntryKind],
778 summary: &mut EntrySummary,
779 direct: bool,
780 ) {
781 let OutlineNode::DocumentEntry {
782 entry_kind,
783 forms,
784 children,
785 ..
786 } = node
787 else {
788 return;
789 };
790 if kinds.contains(entry_kind) {
791 record_projected_summary(summary, *entry_kind, forms.len(), direct);
792 }
793 for child in children {
794 visit(child, kinds, summary, false);
795 }
796 }
797
798 let mut summary = EntrySummary::default();
799 for node in nodes {
800 visit(node, kinds, &mut summary, true);
801 }
802 (!summary.is_empty()).then_some(summary)
803}
804
805enum LocatedNode<'a> {
806 Section {
807 order: usize,
808 coordinates: Vec<usize>,
809 path: OutlinePath,
810 breadcrumbs: Vec<OutlineReference>,
811 section: &'a Section,
812 },
813 Entry {
814 order: usize,
815 coordinates: Vec<usize>,
816 path: OutlinePath,
817 title: String,
818 breadcrumbs: Vec<OutlineReference>,
819 entry: &'a DefinitionItem,
820 source: Option<SourceSpan>,
821 },
822}
823
824impl LocatedNode<'_> {
825 fn order(&self) -> usize {
826 match self {
827 Self::Section { order, .. } | Self::Entry { order, .. } => *order,
828 }
829 }
830
831 fn coordinates(&self) -> &[usize] {
832 match self {
833 Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
834 }
835 }
836
837 fn path(&self) -> &OutlinePath {
838 match self {
839 Self::Section { path, .. } | Self::Entry { path, .. } => path,
840 }
841 }
842
843 fn matches_path(&self, selector: &str) -> bool {
844 selector
845 .parse::<OutlinePath>()
846 .is_ok_and(|path| path == *self.path())
847 }
848
849 fn id(&self) -> &str {
850 match self {
851 Self::Section { section, .. } => §ion.id,
852 Self::Entry { entry, .. } => {
853 &entry
854 .identity
855 .as_ref()
856 .expect("located entries have identities")
857 .id
858 }
859 }
860 }
861
862 fn matches_exact_alias(&self, selector: &str) -> bool {
863 match self {
864 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
865 identity
866 .names
867 .iter()
868 .any(|name| semantic_name_equivalent(identity.case, name, selector))
869 }),
870 Self::Section { .. } => false,
871 }
872 }
873
874 fn matches_shorthand_alias(&self, selector: &str) -> bool {
875 match self {
876 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
877 identity.names.iter().any(|name| {
878 semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
879 semantic_name_equivalent(identity.case, shorthand, selector)
880 })
881 })
882 }),
883 Self::Section { .. } => false,
884 }
885 }
886
887 fn identity(&self) -> Option<&DefinitionIdentity> {
888 match self {
889 Self::Entry { entry, .. } => entry.identity.as_ref(),
890 Self::Section { .. } => None,
891 }
892 }
893
894 fn source(&self) -> Option<SourceSpan> {
895 match self {
896 Self::Entry { source, .. } => *source,
897 Self::Section { section, .. } => section.source,
898 }
899 }
900
901 const fn is_section(&self) -> bool {
902 matches!(self, Self::Section { .. })
903 }
904
905 fn selection(&self) -> ExcerptSelection {
906 match self {
907 Self::Section {
908 path,
909 breadcrumbs,
910 section,
911 ..
912 } => ExcerptSelection::DocumentSection {
913 outline: OutlineTrail {
914 ancestors: breadcrumbs.clone(),
915 node: OutlineNodeReference::DocumentSection {
916 path: path.to_string().into(),
917 id: section.id.clone(),
918 title: section.title.clone(),
919 },
920 },
921 section: (*section).clone(),
922 },
923 Self::Entry {
924 path,
925 title,
926 breadcrumbs,
927 entry,
928 ..
929 } => ExcerptSelection::DocumentEntry {
930 outline: OutlineTrail {
931 ancestors: breadcrumbs.clone(),
932 node: {
933 let identity = entry
934 .identity
935 .as_ref()
936 .expect("located entries have identities");
937 OutlineNodeReference::DocumentEntry {
938 path: path.to_string().into(),
939 id: identity.id.clone(),
940 title: title.clone(),
941 role: identity.role,
942 case: identity.case,
943 names: identity.names.clone(),
944 }
945 },
946 },
947 entry: (*entry).clone(),
948 },
949 }
950 }
951}
952
953fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
954 match case {
955 DefinitionCase::Sensitive => left == right,
956 DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
957 }
958}
959
960fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
961 match role {
962 DefinitionRole::Option => {
963 let shorthand = name.trim_start_matches('-');
964 (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
965 }
966 DefinitionRole::EnvironmentVariable => {
967 environment_variable_body(name).filter(|body| *body != name)
968 }
969 DefinitionRole::Command
970 | DefinitionRole::ConfigurationKey
971 | DefinitionRole::Marker
972 | DefinitionRole::Operand
973 | DefinitionRole::Variable
974 | DefinitionRole::Value
975 | DefinitionRole::Term => None,
976 }
977}
978
979#[derive(Clone, Copy)]
980enum AliasMatchKind {
981 Exact,
982 Shorthand,
983}
984
985impl AliasMatchKind {
986 const fn label(self) -> &'static str {
987 match self {
988 Self::Exact => "exact alias",
989 Self::Shorthand => "normalized shorthand",
990 }
991 }
992}
993
994fn matching_aliases<'a>(
995 located: &'a [LocatedNode<'a>],
996 selector: &str,
997) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
998 let exact = located
999 .iter()
1000 .filter(|candidate| candidate.matches_exact_alias(selector))
1001 .collect::<Vec<_>>();
1002 if !exact.is_empty() {
1003 return (AliasMatchKind::Exact, exact);
1004 }
1005 (
1006 AliasMatchKind::Shorthand,
1007 located
1008 .iter()
1009 .filter(|candidate| candidate.matches_shorthand_alias(selector))
1010 .collect(),
1011 )
1012}
1013
1014pub(crate) fn semantic_selector_diagnostics(
1020 blocks: &[Block],
1021 sections: &[Section],
1022 source_family: &str,
1023) -> Vec<Diagnostic> {
1024 let mut located = Vec::new();
1025 collect_root_entries(blocks, &mut located);
1026 collect_sections(sections, &[], &[], &mut located);
1027 let index = SelectorDiagnosticsIndex::new(&located);
1028 let mut selectors = BTreeSet::new();
1029 for candidate in &located {
1030 let Some(identity) = candidate.identity() else {
1031 continue;
1032 };
1033 for name in &identity.names {
1034 selectors.insert(name.clone());
1035 if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
1036 selectors.insert(shorthand.to_owned());
1037 }
1038 }
1039 }
1040
1041 let mut diagnostics = selector_alias_diagnostics(&index, selectors, source_family);
1042 diagnostics.extend(duplicate_id_diagnostics(&index.ids, source_family));
1043 diagnostics
1044}
1045
1046#[derive(Default)]
1047struct AliasIndex<'a> {
1048 sensitive: HashMap<&'a str, Vec<&'a LocatedNode<'a>>>,
1049 insensitive: HashMap<String, Vec<&'a LocatedNode<'a>>>,
1050}
1051
1052impl<'a> AliasIndex<'a> {
1053 fn insert(&mut self, case: DefinitionCase, alias: &'a str, candidate: &'a LocatedNode<'a>) {
1054 let bucket = match case {
1055 DefinitionCase::Sensitive => self.sensitive.entry(alias).or_default(),
1056 DefinitionCase::Insensitive => self
1057 .insensitive
1058 .entry(alias.to_ascii_lowercase())
1059 .or_default(),
1060 };
1061 if bucket
1062 .last()
1063 .is_none_or(|existing| existing.order() != candidate.order())
1064 {
1065 bucket.push(candidate);
1066 }
1067 }
1068
1069 fn matches(&self, selector: &str) -> Vec<&'a LocatedNode<'a>> {
1070 let mut matches = self.sensitive.get(selector).cloned().unwrap_or_default();
1071 if let Some(insensitive) = self.insensitive.get(&selector.to_ascii_lowercase()) {
1072 matches.extend(insensitive.iter().copied());
1073 }
1074 matches.sort_unstable_by_key(|candidate| candidate.order());
1075 matches.dedup_by_key(|candidate| candidate.order());
1076 matches
1077 }
1078}
1079
1080struct SelectorDiagnosticsIndex<'a> {
1081 exact_aliases: AliasIndex<'a>,
1082 shorthand_aliases: AliasIndex<'a>,
1083 ids: BTreeMap<&'a str, Vec<&'a LocatedNode<'a>>>,
1084}
1085
1086impl<'a> SelectorDiagnosticsIndex<'a> {
1087 fn new(located: &'a [LocatedNode<'a>]) -> Self {
1088 let mut index = Self {
1089 exact_aliases: AliasIndex::default(),
1090 shorthand_aliases: AliasIndex::default(),
1091 ids: BTreeMap::new(),
1092 };
1093 for candidate in located {
1094 index.ids.entry(candidate.id()).or_default().push(candidate);
1095 let Some(identity) = candidate.identity() else {
1096 continue;
1097 };
1098 for name in &identity.names {
1099 index.exact_aliases.insert(identity.case, name, candidate);
1100 if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
1101 index
1102 .shorthand_aliases
1103 .insert(identity.case, shorthand, candidate);
1104 }
1105 }
1106 }
1107 index
1108 }
1109
1110 fn matching_aliases(&self, selector: &str) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
1111 let exact = self.exact_aliases.matches(selector);
1112 if !exact.is_empty() {
1113 return (AliasMatchKind::Exact, exact);
1114 }
1115 (
1116 AliasMatchKind::Shorthand,
1117 self.shorthand_aliases.matches(selector),
1118 )
1119 }
1120}
1121
1122fn selector_alias_diagnostics(
1123 index: &SelectorDiagnosticsIndex<'_>,
1124 selectors: BTreeSet<String>,
1125 source_family: &str,
1126) -> Vec<Diagnostic> {
1127 let mut reported = HashSet::new();
1128 let mut diagnostics = Vec::new();
1129 for selector in selectors {
1130 let (kind, matches) = index.matching_aliases(&selector);
1131 let exact_ids = index
1132 .ids
1133 .get(selector.as_str())
1134 .map_or(&[][..], Vec::as_slice);
1135 let shadowed_matches = matches
1136 .iter()
1137 .copied()
1138 .filter(|candidate| {
1139 !exact_ids
1140 .iter()
1141 .any(|owner| owner.path() == candidate.path())
1142 })
1143 .collect::<Vec<_>>();
1144 if !shadowed_matches.is_empty() && !exact_ids.is_empty() {
1145 let key = format!("shadowed\u{1f}{selector}");
1146 if reported.insert(key) {
1147 let owners = exact_ids
1148 .iter()
1149 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1150 .collect::<Vec<_>>()
1151 .join(", ");
1152 let entries = shadowed_matches
1153 .iter()
1154 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1155 .collect::<Vec<_>>()
1156 .join(", ");
1157 diagnostics.push(Diagnostic {
1158 level: DiagnosticLevel::Warning,
1159 code: Some(format!(
1160 "{source_family}.semantic-entry.shadowed-selector"
1161 )),
1162 message: format!(
1163 "semantic selector '{selector}' is owned by exact outline ID {owners}; matching {} entries {entries} require their path or ID",
1164 kind.label()
1165 ),
1166 source: shadowed_matches
1167 .first()
1168 .and_then(|candidate| candidate.source()),
1169 });
1170 }
1171 }
1172 if matches.len() < 2 {
1173 continue;
1174 }
1175 let key = matches
1176 .iter()
1177 .map(|candidate| candidate.id())
1178 .collect::<Vec<_>>()
1179 .join("\u{1f}");
1180 if !reported.insert(key) {
1181 continue;
1182 }
1183 let candidates = matches
1184 .iter()
1185 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1186 .collect::<Vec<_>>()
1187 .join(", ");
1188 diagnostics.push(Diagnostic {
1189 level: DiagnosticLevel::Warning,
1190 code: Some(format!(
1191 "{source_family}.semantic-entry.ambiguous-selector"
1192 )),
1193 message: format!(
1194 "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
1195 kind.label()
1196 ),
1197 source: matches.first().and_then(|candidate| candidate.source()),
1198 });
1199 }
1200 diagnostics
1201}
1202
1203fn duplicate_id_diagnostics(
1204 ids: &BTreeMap<&str, Vec<&LocatedNode<'_>>>,
1205 source_family: &str,
1206) -> Vec<Diagnostic> {
1207 let mut diagnostics = Vec::new();
1208 for (id, matches) in ids {
1209 if matches.len() < 2 {
1210 continue;
1211 }
1212 let candidates = matches
1213 .iter()
1214 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1215 .collect::<Vec<_>>()
1216 .join(", ");
1217 diagnostics.push(Diagnostic {
1218 level: DiagnosticLevel::Warning,
1219 code: Some(format!("{source_family}.outline.duplicate-id")),
1220 message: format!(
1221 "outline ID '{id}' belongs to multiple nodes: {candidates}; select by path"
1222 ),
1223 source: matches.first().and_then(|candidate| candidate.source()),
1224 });
1225 }
1226 diagnostics
1227}
1228
1229fn collect_sections<'a>(
1230 sections: &'a [Section],
1231 parent_coordinates: &[usize],
1232 breadcrumbs: &[OutlineReference],
1233 output: &mut Vec<LocatedNode<'a>>,
1234) {
1235 for (index, section) in sections.iter().enumerate() {
1236 let mut coordinates = parent_coordinates.to_vec();
1237 coordinates.push(index + 1);
1238 let path =
1239 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
1240 let order = output.len();
1241 output.push(LocatedNode::Section {
1242 order,
1243 coordinates: coordinates.clone(),
1244 path: path.clone(),
1245 breadcrumbs: breadcrumbs.to_vec(),
1246 section,
1247 });
1248 let mut child_breadcrumbs = breadcrumbs.to_vec();
1249 child_breadcrumbs.push(OutlineReference {
1250 path: path.to_string().into(),
1251 id: section.id.clone(),
1252 title: section.title.clone(),
1253 });
1254 for located in definition_entries(§ion.blocks) {
1255 let entry = located.item;
1256 let mut entry_breadcrumbs = child_breadcrumbs.clone();
1257 append_entry_breadcrumbs(
1258 &mut entry_breadcrumbs,
1259 Some(&coordinates),
1260 &located.indices,
1261 &located.ancestors,
1262 );
1263 output.push(LocatedNode::Entry {
1264 order: output.len(),
1265 coordinates: coordinates.clone(),
1266 path: OutlinePath::nested_entry(Some(&coordinates), &located.indices)
1267 .expect("enumerated entry paths are one-based"),
1268 title: definition_title(entry),
1269 breadcrumbs: entry_breadcrumbs,
1270 entry,
1271 source: located.source,
1272 });
1273 }
1274 collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
1275 }
1276}
1277
1278fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
1279 let breadcrumbs = vec![OutlineReference {
1280 path: OutlinePath::DocumentRoot.to_string().into(),
1281 id: DOCUMENT_ROOT_ID.into(),
1282 title: DOCUMENT_ROOT_TITLE.to_owned(),
1283 }];
1284 for located in definition_entries(blocks) {
1285 let entry = located.item;
1286 let mut entry_breadcrumbs = breadcrumbs.clone();
1287 append_entry_breadcrumbs(
1288 &mut entry_breadcrumbs,
1289 None,
1290 &located.indices,
1291 &located.ancestors,
1292 );
1293 output.push(LocatedNode::Entry {
1294 order: output.len(),
1295 coordinates: Vec::new(),
1296 path: OutlinePath::nested_entry(None, &located.indices)
1297 .expect("enumerated entry paths are one-based"),
1298 title: definition_title(entry),
1299 breadcrumbs: entry_breadcrumbs,
1300 entry,
1301 source: located.source,
1302 });
1303 }
1304}
1305
1306fn append_entry_breadcrumbs(
1307 breadcrumbs: &mut Vec<OutlineReference>,
1308 section: Option<&[usize]>,
1309 indices: &[usize],
1310 ancestors: &[&DefinitionItem],
1311) {
1312 for (depth, ancestor) in ancestors.iter().enumerate() {
1313 let path = OutlinePath::nested_entry(section, &indices[..=depth])
1314 .expect("ancestor entry paths are one-based");
1315 let identity = ancestor
1316 .identity
1317 .as_ref()
1318 .expect("semantic entry ancestors have identities");
1319 breadcrumbs.push(OutlineReference {
1320 path: path.to_string().into(),
1321 id: identity.id.clone(),
1322 title: definition_title(ancestor),
1323 });
1324 }
1325}
1326
1327fn definition_title(entry: &DefinitionItem) -> String {
1328 let identity = entry
1329 .identity
1330 .as_ref()
1331 .expect("semantic entries have identities");
1332 if !identity.names.is_empty() {
1333 return identity.names.join(", ");
1334 }
1335 let forms = entry
1336 .terms
1337 .iter()
1338 .map(|term| plain_text(term))
1339 .filter(|form| !form.is_empty())
1340 .collect::<Vec<_>>();
1341 if !forms.is_empty() {
1342 return forms.join(" | ");
1343 }
1344 identity.id.to_string()
1345}
1346
1347fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
1348 ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353 use crate::ResolvedContent;
1354 use mant_ir::{
1355 Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
1356 DiagnosticLevel, Document, DocumentMeta, DocumentSource, EntryKind, Inline, LayoutHint,
1357 ParameterKind, Section, SourceFormat, TldrDocument, TldrOrigin,
1358 };
1359 use mant_protocol::{EntryProjection, ExcerptSelection, NodeSelector, OutlineNode};
1360
1361 use super::{
1362 ProjectionError, build_outline, build_outline_projection, select_excerpt,
1363 semantic_selector_diagnostics,
1364 };
1365
1366 fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
1367 Section {
1368 id: id.to_owned().into(),
1369 title: title.to_owned(),
1370 spacing_before_lines: 0,
1371 blocks: Vec::new(),
1372 children,
1373 source: None,
1374 }
1375 }
1376
1377 fn query() -> ResolvedContent {
1378 ResolvedContent {
1379 address: None,
1380 label: "demo".to_owned(),
1381 document: Some(Document {
1382 parser: None,
1383 source: DocumentSource {
1384 format: SourceFormat::Man,
1385 path: Some("/man/demo.1".to_owned()),
1386 },
1387 meta: DocumentMeta {
1388 manual_section: Some("1".to_owned()),
1389 ..DocumentMeta::default()
1390 },
1391 diagnostics: Vec::new(),
1392 blocks: Vec::new(),
1393 sections: vec![
1394 section("name-1", "NAME", Vec::new()),
1395 section(
1396 "options-2",
1397 "OPTIONS",
1398 vec![
1399 section("common-3", "Common options", Vec::new()),
1400 section("other-4", "Other options", Vec::new()),
1401 ],
1402 ),
1403 section("files-5", "FILES", Vec::new()),
1404 ],
1405 }),
1406 tldr: None,
1407 }
1408 }
1409
1410 fn tldr() -> TldrDocument {
1411 TldrDocument {
1412 title: "demo".to_owned(),
1413 description: vec!["A small demonstration.".to_owned()],
1414 more_information: Some("https://example.com/demo".to_owned()),
1415 examples: Vec::new(),
1416 platform: "common".to_owned(),
1417 language: "en".to_owned(),
1418 source_path: "/tldr/pages/common/demo.md".to_owned(),
1419 origin: TldrOrigin::TldrPages,
1420 }
1421 }
1422
1423 fn definition(
1424 id: &str,
1425 role: DefinitionRole,
1426 aliases: &[&str],
1427 forms: &[&str],
1428 description: Vec<Block>,
1429 ) -> DefinitionItem {
1430 DefinitionItem {
1431 identity: Some(DefinitionIdentity {
1432 id: id.into(),
1433 role,
1434 case: DefinitionCase::Sensitive,
1435 names: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
1436 }),
1437 terms: forms
1438 .iter()
1439 .map(|form| {
1440 vec![Inline::Code {
1441 value: (*form).to_owned(),
1442 }]
1443 })
1444 .collect(),
1445 description,
1446 inline_term: false,
1447 spacing_before_lines: None,
1448 }
1449 }
1450
1451 fn query_with_semantic_entries() -> ResolvedContent {
1452 let value = definition(
1453 "value-yes",
1454 DefinitionRole::Value,
1455 &["yes"],
1456 &["yes"],
1457 Vec::new(),
1458 );
1459 let local_forward = definition(
1460 "option-local-forward",
1461 DefinitionRole::Option,
1462 &["-L"],
1463 &["-L port:host:hostport", "-L socket:remote_socket"],
1464 vec![Block::DefinitionList {
1465 items: vec![value],
1466 compact: true,
1467 layout: LayoutHint::default(),
1468 source: None,
1469 }],
1470 );
1471 let marker = definition(
1472 "marker-end-options",
1473 DefinitionRole::Marker,
1474 &["--"],
1475 &["--"],
1476 Vec::new(),
1477 );
1478 let mut query = query();
1479 query.document.as_mut().expect("document").sections[1]
1480 .blocks
1481 .push(Block::DefinitionList {
1482 items: vec![local_forward, marker],
1483 compact: true,
1484 layout: LayoutHint::default(),
1485 source: None,
1486 });
1487 query
1488 }
1489
1490 #[test]
1491 fn indexed_selector_diagnostics_preserve_case_policy_and_deduplicate_aliases() {
1492 let sensitive = definition(
1493 "command-sensitive-mode",
1494 DefinitionRole::Command,
1495 &["Mode"],
1496 &["Mode"],
1497 Vec::new(),
1498 );
1499 let mut insensitive = definition(
1500 "command-insensitive-mode",
1501 DefinitionRole::Command,
1502 &["MODE", "mode"],
1503 &["MODE"],
1504 Vec::new(),
1505 );
1506 insensitive.identity.as_mut().expect("identity").case = DefinitionCase::Insensitive;
1507 let blocks = vec![Block::DefinitionList {
1508 items: vec![sensitive, insensitive],
1509 compact: true,
1510 layout: LayoutHint::default(),
1511 source: None,
1512 }];
1513 let sections = vec![section("mode", "Mode", Vec::new())];
1514
1515 let diagnostics = semantic_selector_diagnostics(&blocks, §ions, "manual");
1516 assert_eq!(
1517 diagnostics
1518 .iter()
1519 .filter(|diagnostic| {
1520 diagnostic.code.as_deref() == Some("manual.semantic-entry.ambiguous-selector")
1521 })
1522 .count(),
1523 1
1524 );
1525 assert!(diagnostics.iter().any(|diagnostic| {
1526 diagnostic.code.as_deref() == Some("manual.semantic-entry.shadowed-selector")
1527 && diagnostic.message.contains("semantic selector 'mode'")
1528 && diagnostic
1529 .message
1530 .matches("command-insensitive-mode")
1531 .count()
1532 == 1
1533 }));
1534 }
1535
1536 #[test]
1537 fn builds_one_based_tree_paths_without_copying_blocks() {
1538 let outline = build_outline(&query()).expect("outline");
1539
1540 assert_eq!(
1541 outline
1542 .meta
1543 .as_ref()
1544 .and_then(|meta| meta.manual_section.as_deref()),
1545 Some("1")
1546 );
1547 assert_eq!(outline.nodes[1].path(), "2");
1548 assert_eq!(outline.nodes[1].id(), "options-2");
1549 assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
1550 assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
1551 }
1552
1553 #[test]
1554 fn default_outline_summarizes_entries_without_materializing_them() {
1555 let outline = build_outline(&query_with_semantic_entries()).expect("summary outline");
1556 let OutlineNode::DocumentSection {
1557 entry_summary,
1558 children,
1559 ..
1560 } = &outline.nodes[1]
1561 else {
1562 panic!("expected options section");
1563 };
1564 let summary = entry_summary.as_ref().expect("non-empty entry summary");
1565 assert_eq!(
1566 (summary.direct, summary.descendants, summary.forms),
1567 (2, 1, 4)
1568 );
1569 assert!(
1570 children
1571 .iter()
1572 .all(|child| !matches!(child, OutlineNode::DocumentEntry { .. }))
1573 );
1574 assert!(matches!(
1575 &outline.nodes[0],
1576 OutlineNode::DocumentSection {
1577 entry_summary: None,
1578 ..
1579 }
1580 ));
1581 }
1582
1583 #[test]
1584 fn full_and_filtered_outlines_preserve_forms_nesting_and_paths() {
1585 let query = query_with_semantic_entries();
1586 let full = build_outline_projection(&query, EntryProjection::All, None)
1587 .expect("full semantic outline");
1588 let OutlineNode::DocumentEntry {
1589 path,
1590 title,
1591 forms,
1592 children,
1593 ..
1594 } = &full.nodes[1].children()[0]
1595 else {
1596 panic!("expected option entry");
1597 };
1598 assert_eq!(path.as_str(), "2/e1");
1599 assert_eq!(title, "-L port:host:hostport | -L socket:remote_socket");
1600 assert_eq!(forms.len(), 2);
1601 assert_eq!(children[0].path(), "2/e1/e1");
1602
1603 let filtered = build_outline_projection(
1604 &query,
1605 EntryProjection::Kinds {
1606 kinds: vec![EntryKind::Value],
1607 },
1608 None,
1609 )
1610 .expect("value outline");
1611 assert_eq!(filtered.nodes.len(), 1);
1612 let option_section = filtered
1613 .nodes
1614 .iter()
1615 .find(|node| node.id() == "options-2")
1616 .expect("filtered ancestor section");
1617 let OutlineNode::DocumentSection {
1618 entry_summary: Some(summary),
1619 ..
1620 } = option_section
1621 else {
1622 panic!("filtered value summary");
1623 };
1624 assert_eq!((summary.direct, summary.descendants), (0, 1));
1625 assert_eq!(summary.by_kind.len(), 1);
1626 assert_eq!(summary.by_kind[0].kind, EntryKind::Value);
1627 let option = &option_section.children()[0];
1628 assert!(matches!(
1629 option,
1630 OutlineNode::DocumentEntry {
1631 entry_kind: EntryKind::Parameter {
1632 parameter_kind: ParameterKind::Option
1633 },
1634 ..
1635 }
1636 ));
1637 assert!(matches!(
1638 option.children(),
1639 [OutlineNode::DocumentEntry {
1640 entry_kind: EntryKind::Value,
1641 ..
1642 }]
1643 ));
1644 }
1645
1646 #[test]
1647 fn kind_filter_with_no_matches_returns_an_explicitly_empty_projection() {
1648 let outline = build_outline_projection(
1649 &query_with_semantic_entries(),
1650 EntryProjection::Kinds {
1651 kinds: vec![EntryKind::EnvironmentVariable],
1652 },
1653 None,
1654 )
1655 .expect("empty environment projection");
1656
1657 assert!(outline.nodes.is_empty());
1658 }
1659
1660 #[test]
1661 fn every_projected_entry_path_round_trips_through_read_and_explain() {
1662 fn collect_entry_paths(nodes: &[OutlineNode], output: &mut Vec<String>) {
1663 for node in nodes {
1664 if matches!(node, OutlineNode::DocumentEntry { .. }) {
1665 output.push(node.path().to_owned());
1666 }
1667 collect_entry_paths(node.children(), output);
1668 }
1669 }
1670
1671 let mut query = query_with_semantic_entries();
1672 query.document.as_mut().expect("document").sections[1].children[0]
1673 .blocks
1674 .push(Block::DefinitionList {
1675 items: vec![definition(
1676 "generic-readline-term",
1677 DefinitionRole::Term,
1678 &[],
1679 &["operate-and-get-next (C-o)"],
1680 vec![Block::Paragraph {
1681 children: vec![Inline::Text {
1682 value: "Accept the current line and fetch the next history entry."
1683 .to_owned(),
1684 }],
1685 layout: LayoutHint::default(),
1686 source: None,
1687 }],
1688 )],
1689 compact: true,
1690 layout: LayoutHint::default(),
1691 source: None,
1692 });
1693
1694 let outline = build_outline_projection(&query, EntryProjection::All, None)
1695 .expect("complete semantic outline");
1696 let mut paths = Vec::new();
1697 collect_entry_paths(&outline.nodes, &mut paths);
1698 assert_eq!(paths, ["2/e1", "2/e1/e1", "2/e2", "2.1/e1"]);
1699
1700 for path in paths {
1701 let excerpt = select_excerpt(&query, std::slice::from_ref(&path))
1702 .unwrap_or_else(|error| panic!("read must accept projected path {path}: {error}"));
1703 assert!(matches!(
1704 excerpt.selections.as_slice(),
1705 [ExcerptSelection::DocumentEntry { outline, .. }] if outline.path() == path
1706 ));
1707 let explanation = super::select_explanation(&query, &path).unwrap_or_else(|error| {
1708 panic!("explain must accept projected path {path}: {error}")
1709 });
1710 assert!(matches!(
1711 explanation.selections.as_slice(),
1712 [ExcerptSelection::DocumentEntry { outline, .. }] if outline.path() == path
1713 ));
1714 }
1715 }
1716
1717 #[test]
1718 fn outline_root_preserves_identity_excludes_siblings_and_rejects_ambiguous_aliases() {
1719 let mut query = query_with_semantic_entries();
1720 let section_rooted = build_outline_projection(
1721 &query,
1722 EntryProjection::All,
1723 Some(NodeSelector::new("options-2")),
1724 )
1725 .expect("section-rooted outline");
1726 let [
1727 OutlineNode::DocumentSection {
1728 path, id, children, ..
1729 },
1730 ] = section_rooted.nodes.as_slice()
1731 else {
1732 panic!("expected one rooted section");
1733 };
1734 assert_eq!(path.as_str(), "2", "rooting must not rebase paths");
1735 assert_eq!(id.as_str(), "options-2");
1736 assert!(
1737 children
1738 .iter()
1739 .any(|node| node.id() == "option-local-forward")
1740 );
1741 assert!(children.iter().any(|node| node.id() == "common-3"));
1742 assert!(children.iter().any(|node| node.id() == "other-4"));
1743 assert!(
1744 !section_rooted
1745 .nodes
1746 .iter()
1747 .any(|node| node.id() == "name-1" || node.id() == "files-5"),
1748 "unrelated siblings must not leak into a rooted projection"
1749 );
1750
1751 let rooted = build_outline_projection(
1752 &query,
1753 EntryProjection::Summary,
1754 Some(NodeSelector::new("option-local-forward")),
1755 )
1756 .expect("entry-rooted outline");
1757 assert!(matches!(
1758 rooted.nodes.as_slice(),
1759 [OutlineNode::DocumentEntry { id, children, .. }]
1760 if id == "option-local-forward" && children.is_empty()
1761 ));
1762
1763 query.document.as_mut().expect("document").sections[2]
1764 .blocks
1765 .push(Block::DefinitionList {
1766 items: vec![definition(
1767 "option-other-local-forward",
1768 DefinitionRole::Option,
1769 &["-L"],
1770 &["-L path"],
1771 Vec::new(),
1772 )],
1773 compact: true,
1774 layout: LayoutHint::default(),
1775 source: None,
1776 });
1777 let error =
1778 build_outline_projection(&query, EntryProjection::All, Some(NodeSelector::new("-L")))
1779 .expect_err("ambiguous aliases must require qualification");
1780 let ProjectionError::AmbiguousSelector { candidates, .. } = error else {
1781 panic!("expected ambiguous selector");
1782 };
1783 assert_eq!(candidates.len(), 2);
1784 assert_eq!(candidates[0].path, "2/e1");
1785 assert_eq!(candidates[1].path, "3/e1");
1786 }
1787
1788 #[test]
1789 fn section_ids_win_consistently_before_entry_aliases() {
1790 let mut query = query();
1791 query.document.as_mut().expect("document").sections[0] = Section {
1792 id: "force".into(),
1793 title: "Force".to_owned(),
1794 spacing_before_lines: 0,
1795 blocks: Vec::new(),
1796 children: Vec::new(),
1797 source: None,
1798 };
1799 query.document.as_mut().expect("document").sections[1]
1800 .blocks
1801 .push(Block::DefinitionList {
1802 items: vec![definition(
1803 "command-force",
1804 DefinitionRole::Command,
1805 &["force"],
1806 &["force"],
1807 Vec::new(),
1808 )],
1809 compact: true,
1810 layout: LayoutHint::default(),
1811 source: None,
1812 });
1813
1814 let excerpt = select_excerpt(&query, &["force"]).expect("exact section ID");
1815 assert!(matches!(
1816 excerpt.selections.as_slice(),
1817 [ExcerptSelection::DocumentSection { outline, .. }] if outline.path() == "1"
1818 ));
1819 assert!(matches!(
1820 super::select_explanation(&query, "force"),
1821 Err(ProjectionError::ExplanationRequiresEntry { .. })
1822 ));
1823 let outline = build_outline_projection(
1824 &query,
1825 EntryProjection::All,
1826 Some(NodeSelector::new("force")),
1827 )
1828 .expect("outline root uses the same exact-ID precedence");
1829 assert!(matches!(
1830 outline.nodes.as_slice(),
1831 [OutlineNode::DocumentSection { path, .. }] if path == "1"
1832 ));
1833
1834 assert!(super::select_explanation(&query, "command-force").is_ok());
1835 }
1836
1837 #[test]
1838 fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
1839 let mut query = query();
1840 query.tldr = Some(tldr());
1841
1842 let outline = build_outline(&query).expect("combined outline");
1843
1844 assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
1845 assert_eq!(outline.nodes[0].path(), "0");
1846 assert_eq!(outline.nodes[0].id(), "tldr");
1847 assert_eq!(outline.nodes[1].path(), "1");
1848 assert_eq!(outline.nodes[2].path(), "2");
1849 }
1850
1851 #[test]
1852 fn entry_completeness_distinguishes_rejections_from_author_warnings() {
1853 let mut query = query();
1854 {
1855 let document = query.document.as_mut().expect("document");
1856 for code in [
1857 "markdown.semantic-entry.ambiguous-selector",
1858 "markdown.semantic-entry-list",
1859 ] {
1860 document.diagnostics.push(Diagnostic {
1861 level: DiagnosticLevel::Warning,
1862 code: Some(code.to_owned()),
1863 message: "author warning".to_owned(),
1864 source: None,
1865 });
1866 }
1867 }
1868 assert!(
1869 build_outline(&query)
1870 .expect("complete outline")
1871 .entries_complete
1872 );
1873
1874 query
1875 .document
1876 .as_mut()
1877 .expect("document")
1878 .diagnostics
1879 .push(Diagnostic {
1880 level: DiagnosticLevel::Warning,
1881 code: Some("markdown.semantic-entry.invalid-entry-name".to_owned()),
1882 message: "rejected declaration".to_owned(),
1883 source: None,
1884 });
1885 assert!(
1886 !build_outline(&query)
1887 .expect("partial outline")
1888 .entries_complete
1889 );
1890 }
1891
1892 #[test]
1893 fn addresses_document_content_before_the_first_heading_as_root() {
1894 let mut query = query();
1895 let document = query.document.as_mut().expect("document");
1896 document.source.format = SourceFormat::Markdown;
1897 document.blocks.push(Block::Paragraph {
1898 children: vec![Inline::Text {
1899 value: "Document preface.".to_owned(),
1900 }],
1901 layout: LayoutHint::default(),
1902 source: None,
1903 });
1904
1905 let outline = build_outline(&query).expect("Markdown outline");
1906 assert!(matches!(
1907 &outline.nodes[0],
1908 OutlineNode::DocumentRoot { path, id, title, .. }
1909 if path == "root" && id == "document-overview" && title == "OVERVIEW"
1910 ));
1911 assert_eq!(outline.nodes[1].path(), "1");
1913
1914 let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
1915 .expect("root excerpt");
1916 assert!(matches!(
1917 excerpt.selections.as_slice(),
1918 [ExcerptSelection::DocumentRoot { outline, blocks, .. }]
1919 if outline.path() == "root" && blocks.len() == 1
1920 ));
1921 assert_eq!(
1922 excerpt.source.as_ref().map(|source| source.format),
1923 Some(SourceFormat::Markdown)
1924 );
1925 }
1926
1927 #[test]
1928 fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
1929 let excerpt = select_excerpt(
1930 &query(),
1931 &[
1932 "files-5".to_owned(),
1933 "2.1".to_owned(),
1934 "2".to_owned(),
1935 "options-2".to_owned(),
1936 ],
1937 )
1938 .expect("excerpt");
1939
1940 let paths = excerpt
1941 .selections
1942 .iter()
1943 .map(|selection| selection.outline().path())
1944 .collect::<Vec<_>>();
1945 assert_eq!(paths, ["2", "3"]);
1946 let ExcerptSelection::DocumentSection {
1947 section, outline, ..
1948 } = &excerpt.selections[0]
1949 else {
1950 panic!("expected manual selection");
1951 };
1952 assert_eq!(section.children.len(), 2);
1953 assert!(outline.ancestors.is_empty());
1954 }
1955
1956 #[test]
1957 fn child_selection_retains_ancestor_breadcrumbs() {
1958 let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
1959
1960 let ExcerptSelection::DocumentSection { outline, .. } = &excerpt.selections[0] else {
1961 panic!("expected manual selection");
1962 };
1963 assert_eq!(outline.title(), "Other options");
1964 assert_eq!(outline.ancestors[0].path, "2");
1965 assert_eq!(outline.ancestors[0].title, "OPTIONS");
1966 }
1967
1968 #[test]
1969 fn structural_paths_take_precedence_over_colliding_entry_ids() {
1970 let mut query = query();
1971 query.document.as_mut().expect("document").sections[1]
1972 .blocks
1973 .push(Block::DefinitionList {
1974 items: vec![DefinitionItem {
1975 identity: Some(DefinitionIdentity {
1976 id: "3".into(),
1977 role: DefinitionRole::Option,
1978 case: DefinitionCase::Sensitive,
1979 names: vec!["-3".to_owned()],
1980 }),
1981 terms: vec![vec![Inline::Code {
1982 value: "-3".to_owned(),
1983 }]],
1984 description: Vec::new(),
1985 inline_term: false,
1986 spacing_before_lines: None,
1987 }],
1988 compact: true,
1989 layout: LayoutHint::default(),
1990 source: None,
1991 });
1992
1993 let excerpt = select_excerpt(&query, &["3"]).expect("section path wins");
1994 assert!(matches!(
1995 excerpt.selections.as_slice(),
1996 [ExcerptSelection::DocumentSection { outline, .. }] if outline.path() == "3"
1997 ));
1998 assert!(matches!(
1999 super::select_explanation(&query, "3"),
2000 Err(ProjectionError::ExplanationRequiresEntry { .. })
2001 ));
2002 }
2003
2004 #[test]
2005 fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
2006 let mut combined = query();
2007 combined.tldr = Some(tldr());
2008 let excerpt = select_excerpt(
2009 &combined,
2010 &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
2011 )
2012 .expect("combined excerpt");
2013 assert!(matches!(
2014 excerpt.selections.as_slice(),
2015 [ExcerptSelection::Tldr { outline, .. }, ExcerptSelection::DocumentSection { .. }]
2016 if outline.path() == "0"
2017 ));
2018
2019 let mut tldr_only = combined;
2020 tldr_only.document = None;
2021 let outline = build_outline(&tldr_only).expect("tldr-only outline");
2022 assert_eq!(outline.nodes.len(), 1);
2023 assert_eq!(outline.nodes[0].path(), "0");
2024 assert!(outline.source.is_none());
2025 assert!(outline.meta.is_none());
2026 }
2027
2028 #[test]
2029 fn reports_missing_content_and_unknown_or_empty_selectors() {
2030 let mut empty = query();
2031 empty.document = None;
2032 assert!(matches!(
2033 build_outline(&empty),
2034 Err(ProjectionError::MissingContent { .. })
2035 ));
2036 assert_eq!(
2037 select_excerpt(&query(), &[] as &[String]),
2038 Err(ProjectionError::EmptySelection)
2039 );
2040 assert_eq!(
2041 select_excerpt(&query(), &[" ".to_owned()]),
2042 Err(ProjectionError::EmptySelector)
2043 );
2044 assert!(matches!(
2045 select_excerpt(&query(), &["9".to_owned()]),
2046 Err(ProjectionError::UnknownSelector { .. })
2047 ));
2048 }
2049}