1use std::{
4 collections::{BTreeSet, HashSet},
5 error::Error,
6 fmt,
7};
8
9use mant_ir::{
10 Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
11 DiagnosticLevel, OutlinePath, Section, SourceSpan,
12};
13use mant_protocol::{
14 ExcerptSchema, ExcerptSelection, OutlineDetail, OutlineNode, OutlineNodeReference,
15 OutlineReference, OutlineSchema, OutlineTrail, QueryExcerpt, QueryOutline,
16};
17
18use crate::{ResolvedContent, definitions::definition_entries};
19
20pub(crate) const TLDR_ID: &str = "tldr";
21const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
22pub(crate) use mant_ir::DOCUMENT_ROOT_ID;
23pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
24
25pub(crate) fn is_reserved_selector(value: &str) -> bool {
33 matches!(value, TLDR_ID | DOCUMENT_ROOT_ID) || value.parse::<OutlinePath>().is_ok()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ProjectionError {
39 MissingContent {
41 document: String,
43 },
44 EmptySelection,
46 EmptySelector,
48 UnknownSelector {
50 document: String,
52 selector: String,
54 },
55 SelectorFoundOnlyInText {
58 document: String,
60 selector: String,
62 path: String,
64 title: String,
66 line: u32,
68 },
69 AmbiguousSelector {
71 document: String,
73 selector: String,
75 candidates: Vec<SelectorCandidate>,
77 },
78 ExplanationRequiresEntry {
80 document: String,
82 selector: String,
84 },
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct SelectorCandidate {
90 pub path: String,
92 pub id: String,
94}
95
96impl fmt::Display for ProjectionError {
97 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98 match self {
99 Self::MissingContent { document } => {
100 write!(formatter, "document '{document}' has no available content")
101 }
102 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
103 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
104 Self::UnknownSelector { document, selector } => write!(
105 formatter,
106 "document '{document}' has no outline node '{selector}'; inspect its entries outline for available selectors and diagnostics"
107 ),
108 Self::SelectorFoundOnlyInText {
109 document,
110 selector,
111 path,
112 title,
113 line,
114 } => write!(
115 formatter,
116 "document '{document}' has no semantic entry '{selector}', but that text appears in outline node {path} ({title}) at line {line}"
117 ),
118 Self::AmbiguousSelector {
119 document,
120 selector,
121 candidates,
122 } => {
123 write!(
124 formatter,
125 "document '{document}' has multiple semantic entries named '{selector}': "
126 )?;
127 for (index, candidate) in candidates.iter().enumerate() {
128 if index > 0 {
129 formatter.write_str(", ")?;
130 }
131 write!(formatter, "{} ({})", candidate.path, candidate.id)?;
132 }
133 formatter.write_str("; select one by path or ID")
134 }
135 Self::ExplanationRequiresEntry { document, selector } => write!(
136 formatter,
137 "document '{document}' outline node '{selector}' is not a semantic entry; select a semantic entry instead"
138 ),
139 }
140 }
141}
142
143impl Error for ProjectionError {}
144
145pub fn build_outline(query: &ResolvedContent) -> Result<QueryOutline, ProjectionError> {
152 build_outline_with_detail(query, OutlineDetail::Sections)
153}
154
155pub fn build_outline_with_detail(
162 query: &ResolvedContent,
163 detail: OutlineDetail,
164) -> Result<QueryOutline, ProjectionError> {
165 if query.tldr.is_none() && query.document.is_none() {
166 return Err(ProjectionError::MissingContent {
167 document: query.label.clone(),
168 });
169 }
170 let diagnostics = query
171 .document
172 .as_ref()
173 .map_or_else(Vec::new, |document| document.diagnostics.clone());
174 let entries_complete = diagnostics.iter().all(|diagnostic| {
175 !diagnostic
176 .code
177 .as_deref()
178 .is_some_and(crate::markdown::is_semantic_entry_rejection_code)
179 });
180 let mut nodes = Vec::new();
181 if query.tldr.is_some() {
182 nodes.push(OutlineNode::Tldr {
183 path: OutlinePath::Tldr.to_string().into(),
184 id: TLDR_ID.into(),
185 title: TLDR_TITLE.to_owned(),
186 });
187 }
188 if let Some(manual) = &query.document {
189 if !manual.blocks.is_empty() {
190 nodes.push(OutlineNode::DocumentRoot {
191 path: OutlinePath::DocumentRoot.to_string().into(),
192 id: DOCUMENT_ROOT_ID.into(),
193 title: DOCUMENT_ROOT_TITLE.to_owned(),
194 });
195 if detail == OutlineDetail::Entries {
196 nodes.extend(
197 definition_entries(&manual.blocks)
198 .into_iter()
199 .enumerate()
200 .filter_map(|(index, (entry, _))| {
201 let identity = entry.identity.as_ref()?;
202 Some(OutlineNode::DocumentEntry {
203 path: OutlinePath::entry(None, index + 1)?.to_string().into(),
204 id: identity.id.clone(),
205 title: identity.names.join(", "),
206 role: identity.role,
207 case: identity.case,
208 names: identity.names.clone(),
209 })
210 }),
211 );
212 }
213 }
214 nodes.extend(outline_nodes(&manual.sections, &[], detail));
215 }
216 Ok(QueryOutline {
217 schema: OutlineSchema::V0Dot9,
218 detail,
219 label: query.label.clone(),
220 source: query
221 .document
222 .as_ref()
223 .map(|document| document.source.clone()),
224 meta: query
225 .document
226 .as_ref()
227 .map(|document| document.meta.clone()),
228 diagnostics,
229 entries_complete,
230 nodes,
231 })
232}
233
234pub fn select_excerpt<S: AsRef<str>>(
243 query: &ResolvedContent,
244 selectors: &[S],
245) -> Result<QueryExcerpt, ProjectionError> {
246 if selectors.is_empty() {
247 return Err(ProjectionError::EmptySelection);
248 }
249 if query.tldr.is_none() && query.document.is_none() {
250 return Err(ProjectionError::MissingContent {
251 document: query.label.clone(),
252 });
253 }
254 let mut located = Vec::new();
255 if let Some(manual) = &query.document {
256 collect_root_entries(&manual.blocks, &mut located);
257 collect_sections(&manual.sections, &[], &[], &mut located);
258 }
259
260 let (tldr_selected, document_root_selected, mut selected) =
261 resolve_excerpt_candidates(query, selectors, &located)?;
262 let selected_sections = selected
263 .iter()
264 .filter(|candidate| candidate.is_section())
265 .map(|candidate| candidate.coordinates().to_vec())
266 .collect::<Vec<_>>();
267 selected.retain(|candidate| {
268 if document_root_selected && candidate.path().is_document_root_entry() {
269 return false;
270 }
271 !selected_sections.iter().any(|ancestor| {
272 if candidate.is_section() {
273 ancestor != candidate.coordinates()
274 && is_ancestor(ancestor, candidate.coordinates())
275 } else {
276 ancestor == candidate.coordinates()
277 || is_ancestor(ancestor, candidate.coordinates())
278 }
279 })
280 });
281 selected.sort_by_key(|candidate| candidate.order());
282
283 let document = if selected.is_empty() && !document_root_selected {
284 None
285 } else {
286 query.document.as_ref()
287 };
288 let mut selections = Vec::new();
289 if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
290 selections.push(ExcerptSelection::Tldr {
291 outline: OutlineTrail {
292 ancestors: Vec::new(),
293 node: OutlineNodeReference::Tldr {
294 path: OutlinePath::Tldr.to_string().into(),
295 id: TLDR_ID.into(),
296 title: TLDR_TITLE.to_owned(),
297 },
298 },
299 document,
300 });
301 }
302 if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
303 selections.push(ExcerptSelection::DocumentRoot {
304 outline: OutlineTrail {
305 ancestors: Vec::new(),
306 node: OutlineNodeReference::DocumentRoot {
307 path: OutlinePath::DocumentRoot.to_string().into(),
308 id: DOCUMENT_ROOT_ID.into(),
309 title: DOCUMENT_ROOT_TITLE.to_owned(),
310 },
311 },
312 blocks: document.blocks.clone(),
313 });
314 }
315 selections.extend(selected.into_iter().map(LocatedNode::selection));
316
317 Ok(QueryExcerpt {
318 schema: ExcerptSchema::V0Dot9,
319 label: query.label.clone(),
320 producer: document.map(mant_protocol::Producer::for_document),
321 source: document.map(|document| document.source.clone()),
322 meta: document.map(|document| document.meta.clone()),
323 diagnostics: document
324 .map(|document| document.diagnostics.clone())
325 .unwrap_or_default(),
326 selections,
327 })
328}
329
330fn resolve_excerpt_candidates<'a, S: AsRef<str>>(
331 query: &ResolvedContent,
332 selectors: &[S],
333 located: &'a [LocatedNode<'a>],
334) -> Result<(bool, bool, Vec<&'a LocatedNode<'a>>), ProjectionError> {
335 let mut tldr_selected = false;
336 let mut document_root_selected = false;
337 let mut selected_ids = HashSet::new();
338 let mut selected = Vec::new();
339 for raw_selector in selectors {
340 let selector = raw_selector.as_ref().trim();
341 if selector.is_empty() {
342 return Err(ProjectionError::EmptySelector);
343 }
344 if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr))
345 && query.tldr.is_some()
346 {
347 tldr_selected = true;
348 continue;
349 }
350 if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
351 && query
352 .document
353 .as_ref()
354 .is_some_and(|document| !document.blocks.is_empty())
355 {
356 document_root_selected = true;
357 continue;
358 }
359 let candidate = resolve_candidate(query, located, selector)?;
360 if selected_ids.insert(candidate.id()) {
361 selected.push(candidate);
362 }
363 }
364 Ok((tldr_selected, document_root_selected, selected))
365}
366
367pub fn select_explanation(
378 query: &ResolvedContent,
379 selector: &str,
380) -> Result<QueryExcerpt, ProjectionError> {
381 if query.tldr.is_none() && query.document.is_none() {
382 return Err(ProjectionError::MissingContent {
383 document: query.label.clone(),
384 });
385 }
386 let selector = selector.trim();
387 if selector.is_empty() {
388 return Err(ProjectionError::EmptySelector);
389 }
390 let mut located = Vec::new();
391 if let Some(manual) = &query.document {
392 collect_root_entries(&manual.blocks, &mut located);
393 collect_sections(&manual.sections, &[], &[], &mut located);
394 }
395 let candidate = resolve_explanation_candidate(query, &located, selector)?;
396 select_excerpt(query, &[candidate.path().to_string()])
397}
398
399fn resolve_explanation_candidate<'a>(
400 query: &ResolvedContent,
401 located: &'a [LocatedNode<'a>],
402 selector: &str,
403) -> Result<&'a LocatedNode<'a>, ProjectionError> {
404 if let Some(candidate) = located
405 .iter()
406 .find(|candidate| candidate.matches_path(selector))
407 {
408 if !candidate.is_section() {
409 return Ok(candidate);
410 }
411 return Err(ProjectionError::ExplanationRequiresEntry {
412 document: query.label.clone(),
413 selector: selector.to_owned(),
414 });
415 }
416 if let Some(candidate) = located
417 .iter()
418 .find(|candidate| !candidate.is_section() && candidate.id() == selector)
419 {
420 return Ok(candidate);
421 }
422
423 let matches = matching_aliases(located, selector).1;
424 match matches.as_slice() {
425 [candidate] => return Ok(candidate),
426 [] => {}
427 _ => {
428 return Err(ProjectionError::AmbiguousSelector {
429 document: query.label.clone(),
430 selector: selector.to_owned(),
431 candidates: matches
432 .into_iter()
433 .map(|candidate| SelectorCandidate {
434 path: candidate.path().to_string(),
435 id: candidate.id().into(),
436 })
437 .collect(),
438 });
439 }
440 }
441
442 let selects_tldr =
443 (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
444 let selects_root = (selector == DOCUMENT_ROOT_ID
445 || selector.parse() == Ok(OutlinePath::DocumentRoot))
446 && query
447 .document
448 .as_ref()
449 .is_some_and(|document| !document.blocks.is_empty());
450 let selects_section = located.iter().any(|candidate| {
451 candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
452 });
453 if selects_tldr || selects_root || selects_section {
454 return Err(ProjectionError::ExplanationRequiresEntry {
455 document: query.label.clone(),
456 selector: selector.to_owned(),
457 });
458 }
459
460 Err(ProjectionError::UnknownSelector {
461 document: query.label.clone(),
462 selector: selector.to_owned(),
463 })
464}
465
466fn resolve_candidate<'a>(
467 query: &ResolvedContent,
468 located: &'a [LocatedNode<'a>],
469 selector: &str,
470) -> Result<&'a LocatedNode<'a>, ProjectionError> {
471 if let Some(candidate) = located
472 .iter()
473 .find(|candidate| candidate.matches_path(selector))
474 {
475 return Ok(candidate);
476 }
477 if let Some(candidate) = located.iter().find(|candidate| candidate.id() == selector) {
478 return Ok(candidate);
479 }
480
481 let matches = matching_aliases(located, selector).1;
482 match matches.as_slice() {
483 [] => Err(ProjectionError::UnknownSelector {
484 document: query.label.clone(),
485 selector: selector.to_owned(),
486 }),
487 [candidate] => Ok(candidate),
488 _ => Err(ProjectionError::AmbiguousSelector {
489 document: query.label.clone(),
490 selector: selector.to_owned(),
491 candidates: matches
492 .into_iter()
493 .map(|candidate| SelectorCandidate {
494 path: candidate.path().to_string(),
495 id: candidate.id().into(),
496 })
497 .collect(),
498 }),
499 }
500}
501
502fn outline_nodes(
503 sections: &[Section],
504 parent: &[usize],
505 detail: OutlineDetail,
506) -> Vec<OutlineNode> {
507 sections
508 .iter()
509 .enumerate()
510 .map(|(index, section)| {
511 let mut coordinates = parent.to_vec();
512 coordinates.push(index + 1);
513 let path =
514 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
515 let mut children = Vec::new();
516 if detail == OutlineDetail::Entries {
517 children.extend(
518 definition_entries(§ion.blocks)
519 .into_iter()
520 .enumerate()
521 .filter_map(|(index, (entry, _))| {
522 let identity = entry.identity.as_ref()?;
523 Some(OutlineNode::DocumentEntry {
524 path: OutlinePath::entry(Some(&coordinates), index + 1)
525 .expect("enumerated entry paths are one-based")
526 .to_string()
527 .into(),
528 id: identity.id.clone(),
529 title: identity.names.join(", "),
530 role: identity.role,
531 case: identity.case,
532 names: identity.names.clone(),
533 })
534 }),
535 );
536 }
537 children.extend(outline_nodes(§ion.children, &coordinates, detail));
538 OutlineNode::DocumentSection {
539 path: path.to_string().into(),
540 id: section.id.clone(),
541 title: section.title.clone(),
542 children,
543 }
544 })
545 .collect()
546}
547
548enum LocatedNode<'a> {
549 Section {
550 order: usize,
551 coordinates: Vec<usize>,
552 path: OutlinePath,
553 breadcrumbs: Vec<OutlineReference>,
554 section: &'a Section,
555 },
556 Entry {
557 order: usize,
558 coordinates: Vec<usize>,
559 path: OutlinePath,
560 title: String,
561 breadcrumbs: Vec<OutlineReference>,
562 entry: &'a DefinitionItem,
563 source: Option<SourceSpan>,
564 },
565}
566
567impl LocatedNode<'_> {
568 fn order(&self) -> usize {
569 match self {
570 Self::Section { order, .. } | Self::Entry { order, .. } => *order,
571 }
572 }
573
574 fn coordinates(&self) -> &[usize] {
575 match self {
576 Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
577 }
578 }
579
580 fn path(&self) -> &OutlinePath {
581 match self {
582 Self::Section { path, .. } | Self::Entry { path, .. } => path,
583 }
584 }
585
586 fn matches_path(&self, selector: &str) -> bool {
587 selector
588 .parse::<OutlinePath>()
589 .is_ok_and(|path| path == *self.path())
590 }
591
592 fn id(&self) -> &str {
593 match self {
594 Self::Section { section, .. } => §ion.id,
595 Self::Entry { entry, .. } => {
596 &entry
597 .identity
598 .as_ref()
599 .expect("located entries have identities")
600 .id
601 }
602 }
603 }
604
605 fn matches_exact_alias(&self, selector: &str) -> bool {
606 match self {
607 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
608 identity
609 .names
610 .iter()
611 .any(|name| semantic_name_equivalent(identity.case, name, selector))
612 }),
613 Self::Section { .. } => false,
614 }
615 }
616
617 fn matches_shorthand_alias(&self, selector: &str) -> bool {
618 match self {
619 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
620 identity.names.iter().any(|name| {
621 semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
622 semantic_name_equivalent(identity.case, shorthand, selector)
623 })
624 })
625 }),
626 Self::Section { .. } => false,
627 }
628 }
629
630 fn identity(&self) -> Option<&DefinitionIdentity> {
631 match self {
632 Self::Entry { entry, .. } => entry.identity.as_ref(),
633 Self::Section { .. } => None,
634 }
635 }
636
637 fn source(&self) -> Option<SourceSpan> {
638 match self {
639 Self::Entry { source, .. } => *source,
640 Self::Section { section, .. } => section.source,
641 }
642 }
643
644 const fn is_section(&self) -> bool {
645 matches!(self, Self::Section { .. })
646 }
647
648 fn selection(&self) -> ExcerptSelection {
649 match self {
650 Self::Section {
651 path,
652 breadcrumbs,
653 section,
654 ..
655 } => ExcerptSelection::DocumentSection {
656 outline: OutlineTrail {
657 ancestors: breadcrumbs.clone(),
658 node: OutlineNodeReference::DocumentSection {
659 path: path.to_string().into(),
660 id: section.id.clone(),
661 title: section.title.clone(),
662 },
663 },
664 section: (*section).clone(),
665 },
666 Self::Entry {
667 path,
668 title,
669 breadcrumbs,
670 entry,
671 ..
672 } => ExcerptSelection::DocumentEntry {
673 outline: OutlineTrail {
674 ancestors: breadcrumbs.clone(),
675 node: {
676 let identity = entry
677 .identity
678 .as_ref()
679 .expect("located entries have identities");
680 OutlineNodeReference::DocumentEntry {
681 path: path.to_string().into(),
682 id: identity.id.clone(),
683 title: title.clone(),
684 role: identity.role,
685 case: identity.case,
686 names: identity.names.clone(),
687 }
688 },
689 },
690 entry: (*entry).clone(),
691 },
692 }
693 }
694}
695
696fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
697 match case {
698 DefinitionCase::Sensitive => left == right,
699 DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
700 }
701}
702
703fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
704 match role {
705 DefinitionRole::Option => {
706 let shorthand = name.trim_start_matches('-');
707 (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
708 }
709 DefinitionRole::EnvironmentVariable => name
710 .strip_prefix("$env:")
711 .or_else(|| name.strip_prefix("$ENV:")),
712 DefinitionRole::Command | DefinitionRole::Variable => None,
713 }
714}
715
716#[derive(Clone, Copy)]
717enum AliasMatchKind {
718 Exact,
719 Shorthand,
720}
721
722impl AliasMatchKind {
723 const fn label(self) -> &'static str {
724 match self {
725 Self::Exact => "exact alias",
726 Self::Shorthand => "normalized shorthand",
727 }
728 }
729}
730
731fn matching_aliases<'a>(
732 located: &'a [LocatedNode<'a>],
733 selector: &str,
734) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
735 let exact = located
736 .iter()
737 .filter(|candidate| candidate.matches_exact_alias(selector))
738 .collect::<Vec<_>>();
739 if !exact.is_empty() {
740 return (AliasMatchKind::Exact, exact);
741 }
742 (
743 AliasMatchKind::Shorthand,
744 located
745 .iter()
746 .filter(|candidate| candidate.matches_shorthand_alias(selector))
747 .collect(),
748 )
749}
750
751pub(crate) fn semantic_selector_diagnostics(
757 blocks: &[Block],
758 sections: &[Section],
759) -> Vec<Diagnostic> {
760 let mut located = Vec::new();
761 collect_root_entries(blocks, &mut located);
762 collect_sections(sections, &[], &[], &mut located);
763 let mut selectors = BTreeSet::new();
764 for candidate in &located {
765 let Some(identity) = candidate.identity() else {
766 continue;
767 };
768 for name in &identity.names {
769 selectors.insert(name.clone());
770 if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
771 selectors.insert(shorthand.to_owned());
772 }
773 }
774 }
775
776 let mut reported = HashSet::new();
777 let mut diagnostics = Vec::new();
778 for selector in selectors {
779 let (kind, matches) = matching_aliases(&located, &selector);
780 if matches.len() < 2 {
781 continue;
782 }
783 let key = matches
784 .iter()
785 .map(|candidate| candidate.id())
786 .collect::<Vec<_>>()
787 .join("\u{1f}");
788 if !reported.insert(key) {
789 continue;
790 }
791 let candidates = matches
792 .iter()
793 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
794 .collect::<Vec<_>>()
795 .join(", ");
796 diagnostics.push(Diagnostic {
797 level: DiagnosticLevel::Warning,
798 code: Some("markdown.semantic-entry.ambiguous-selector".to_owned()),
799 message: format!(
800 "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
801 kind.label()
802 ),
803 source: matches.first().and_then(|candidate| candidate.source()),
804 });
805 }
806 diagnostics
807}
808
809fn collect_sections<'a>(
810 sections: &'a [Section],
811 parent_coordinates: &[usize],
812 breadcrumbs: &[OutlineReference],
813 output: &mut Vec<LocatedNode<'a>>,
814) {
815 for (index, section) in sections.iter().enumerate() {
816 let mut coordinates = parent_coordinates.to_vec();
817 coordinates.push(index + 1);
818 let path =
819 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
820 let order = output.len();
821 output.push(LocatedNode::Section {
822 order,
823 coordinates: coordinates.clone(),
824 path: path.clone(),
825 breadcrumbs: breadcrumbs.to_vec(),
826 section,
827 });
828 let mut child_breadcrumbs = breadcrumbs.to_vec();
829 child_breadcrumbs.push(OutlineReference {
830 path: path.to_string().into(),
831 id: section.id.clone(),
832 title: section.title.clone(),
833 });
834 for (index, (entry, source)) in definition_entries(§ion.blocks).into_iter().enumerate()
835 {
836 let Some(identity) = &entry.identity else {
837 continue;
838 };
839 output.push(LocatedNode::Entry {
840 order: output.len(),
841 coordinates: coordinates.clone(),
842 path: OutlinePath::entry(Some(&coordinates), index + 1)
843 .expect("enumerated entry paths are one-based"),
844 title: identity.names.join(", "),
845 breadcrumbs: child_breadcrumbs.clone(),
846 entry,
847 source,
848 });
849 }
850 collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
851 }
852}
853
854fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
855 let breadcrumbs = vec![OutlineReference {
856 path: OutlinePath::DocumentRoot.to_string().into(),
857 id: DOCUMENT_ROOT_ID.into(),
858 title: DOCUMENT_ROOT_TITLE.to_owned(),
859 }];
860 for (index, (entry, source)) in definition_entries(blocks).into_iter().enumerate() {
861 let Some(identity) = &entry.identity else {
862 continue;
863 };
864 output.push(LocatedNode::Entry {
865 order: output.len(),
866 coordinates: Vec::new(),
867 path: OutlinePath::entry(None, index + 1)
868 .expect("enumerated entry paths are one-based"),
869 title: identity.names.join(", "),
870 breadcrumbs: breadcrumbs.clone(),
871 entry,
872 source,
873 });
874 }
875}
876
877fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
878 ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
879}
880
881#[cfg(test)]
882mod tests {
883 use crate::ResolvedContent;
884 use mant_ir::{
885 Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
886 DiagnosticLevel, Document, DocumentMeta, DocumentSource, Inline, LayoutHint, Section,
887 SourceFormat, TldrDocument, TldrOrigin,
888 };
889 use mant_protocol::{ExcerptSelection, OutlineNode};
890
891 use super::{ProjectionError, build_outline, select_excerpt};
892
893 fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
894 Section {
895 id: id.to_owned().into(),
896 title: title.to_owned(),
897 spacing_before_lines: 0,
898 blocks: Vec::new(),
899 children,
900 source: None,
901 }
902 }
903
904 fn query() -> ResolvedContent {
905 ResolvedContent {
906 address: None,
907 label: "demo".to_owned(),
908 document: Some(Document {
909 parser: None,
910 source: DocumentSource {
911 format: SourceFormat::Man,
912 path: Some("/man/demo.1".to_owned()),
913 },
914 meta: DocumentMeta {
915 manual_section: Some("1".to_owned()),
916 ..DocumentMeta::default()
917 },
918 diagnostics: Vec::new(),
919 blocks: Vec::new(),
920 sections: vec![
921 section("name-1", "NAME", Vec::new()),
922 section(
923 "options-2",
924 "OPTIONS",
925 vec![
926 section("common-3", "Common options", Vec::new()),
927 section("other-4", "Other options", Vec::new()),
928 ],
929 ),
930 section("files-5", "FILES", Vec::new()),
931 ],
932 }),
933 tldr: None,
934 }
935 }
936
937 fn tldr() -> TldrDocument {
938 TldrDocument {
939 title: "demo".to_owned(),
940 description: vec!["A small demonstration.".to_owned()],
941 more_information: Some("https://example.com/demo".to_owned()),
942 examples: Vec::new(),
943 platform: "common".to_owned(),
944 language: "en".to_owned(),
945 source_path: "/tldr/pages/common/demo.md".to_owned(),
946 origin: TldrOrigin::TldrPages,
947 }
948 }
949
950 #[test]
951 fn builds_one_based_tree_paths_without_copying_blocks() {
952 let outline = build_outline(&query()).expect("outline");
953
954 assert_eq!(
955 outline
956 .meta
957 .as_ref()
958 .and_then(|meta| meta.manual_section.as_deref()),
959 Some("1")
960 );
961 assert_eq!(outline.nodes[1].path(), "2");
962 assert_eq!(outline.nodes[1].id(), "options-2");
963 assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
964 assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
965 }
966
967 #[test]
968 fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
969 let mut query = query();
970 query.tldr = Some(tldr());
971
972 let outline = build_outline(&query).expect("combined outline");
973
974 assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
975 assert_eq!(outline.nodes[0].path(), "0");
976 assert_eq!(outline.nodes[0].id(), "tldr");
977 assert_eq!(outline.nodes[1].path(), "1");
978 assert_eq!(outline.nodes[2].path(), "2");
979 }
980
981 #[test]
982 fn entry_completeness_distinguishes_rejections_from_author_warnings() {
983 let mut query = query();
984 {
985 let document = query.document.as_mut().expect("document");
986 for code in [
987 "markdown.semantic-entry.ambiguous-selector",
988 "markdown.semantic-entry-list",
989 ] {
990 document.diagnostics.push(Diagnostic {
991 level: DiagnosticLevel::Warning,
992 code: Some(code.to_owned()),
993 message: "author warning".to_owned(),
994 source: None,
995 });
996 }
997 }
998 assert!(
999 build_outline(&query)
1000 .expect("complete outline")
1001 .entries_complete
1002 );
1003
1004 query
1005 .document
1006 .as_mut()
1007 .expect("document")
1008 .diagnostics
1009 .push(Diagnostic {
1010 level: DiagnosticLevel::Warning,
1011 code: Some("markdown.semantic-entry.invalid-entry-name".to_owned()),
1012 message: "rejected declaration".to_owned(),
1013 source: None,
1014 });
1015 assert!(
1016 !build_outline(&query)
1017 .expect("partial outline")
1018 .entries_complete
1019 );
1020 }
1021
1022 #[test]
1023 fn addresses_document_content_before_the_first_heading_as_root() {
1024 let mut query = query();
1025 let document = query.document.as_mut().expect("document");
1026 document.source.format = SourceFormat::Markdown;
1027 document.blocks.push(Block::Paragraph {
1028 children: vec![Inline::Text {
1029 value: "Document preface.".to_owned(),
1030 }],
1031 layout: LayoutHint::default(),
1032 source: None,
1033 });
1034
1035 let outline = build_outline(&query).expect("Markdown outline");
1036 assert!(matches!(
1037 &outline.nodes[0],
1038 OutlineNode::DocumentRoot { path, id, title }
1039 if path == "root" && id == "document-overview" && title == "OVERVIEW"
1040 ));
1041 assert_eq!(outline.nodes[1].path(), "1");
1043
1044 let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
1045 .expect("root excerpt");
1046 assert!(matches!(
1047 excerpt.selections.as_slice(),
1048 [ExcerptSelection::DocumentRoot { outline, blocks, .. }]
1049 if outline.path() == "root" && blocks.len() == 1
1050 ));
1051 assert_eq!(
1052 excerpt.source.as_ref().map(|source| source.format),
1053 Some(SourceFormat::Markdown)
1054 );
1055 }
1056
1057 #[test]
1058 fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
1059 let excerpt = select_excerpt(
1060 &query(),
1061 &[
1062 "files-5".to_owned(),
1063 "2.1".to_owned(),
1064 "2".to_owned(),
1065 "options-2".to_owned(),
1066 ],
1067 )
1068 .expect("excerpt");
1069
1070 let paths = excerpt
1071 .selections
1072 .iter()
1073 .map(|selection| selection.outline().path())
1074 .collect::<Vec<_>>();
1075 assert_eq!(paths, ["2", "3"]);
1076 let ExcerptSelection::DocumentSection {
1077 section, outline, ..
1078 } = &excerpt.selections[0]
1079 else {
1080 panic!("expected manual selection");
1081 };
1082 assert_eq!(section.children.len(), 2);
1083 assert!(outline.ancestors.is_empty());
1084 }
1085
1086 #[test]
1087 fn child_selection_retains_ancestor_breadcrumbs() {
1088 let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
1089
1090 let ExcerptSelection::DocumentSection { outline, .. } = &excerpt.selections[0] else {
1091 panic!("expected manual selection");
1092 };
1093 assert_eq!(outline.title(), "Other options");
1094 assert_eq!(outline.ancestors[0].path, "2");
1095 assert_eq!(outline.ancestors[0].title, "OPTIONS");
1096 }
1097
1098 #[test]
1099 fn structural_paths_take_precedence_over_colliding_entry_ids() {
1100 let mut query = query();
1101 query.document.as_mut().expect("document").sections[1]
1102 .blocks
1103 .push(Block::DefinitionList {
1104 items: vec![DefinitionItem {
1105 identity: Some(DefinitionIdentity {
1106 id: "3".into(),
1107 role: DefinitionRole::Option,
1108 case: DefinitionCase::Sensitive,
1109 names: vec!["-3".to_owned()],
1110 }),
1111 terms: vec![vec![Inline::Code {
1112 value: "-3".to_owned(),
1113 }]],
1114 description: Vec::new(),
1115 inline_term: false,
1116 spacing_before_lines: None,
1117 }],
1118 compact: true,
1119 layout: LayoutHint::default(),
1120 source: None,
1121 });
1122
1123 let excerpt = select_excerpt(&query, &["3"]).expect("section path wins");
1124 assert!(matches!(
1125 excerpt.selections.as_slice(),
1126 [ExcerptSelection::DocumentSection { outline, .. }] if outline.path() == "3"
1127 ));
1128 assert!(matches!(
1129 super::select_explanation(&query, "3"),
1130 Err(ProjectionError::ExplanationRequiresEntry { .. })
1131 ));
1132 }
1133
1134 #[test]
1135 fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
1136 let mut combined = query();
1137 combined.tldr = Some(tldr());
1138 let excerpt = select_excerpt(
1139 &combined,
1140 &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
1141 )
1142 .expect("combined excerpt");
1143 assert!(matches!(
1144 excerpt.selections.as_slice(),
1145 [ExcerptSelection::Tldr { outline, .. }, ExcerptSelection::DocumentSection { .. }]
1146 if outline.path() == "0"
1147 ));
1148
1149 let mut tldr_only = combined;
1150 tldr_only.document = None;
1151 let outline = build_outline(&tldr_only).expect("tldr-only outline");
1152 assert_eq!(outline.nodes.len(), 1);
1153 assert_eq!(outline.nodes[0].path(), "0");
1154 assert!(outline.source.is_none());
1155 assert!(outline.meta.is_none());
1156 }
1157
1158 #[test]
1159 fn reports_missing_content_and_unknown_or_empty_selectors() {
1160 let mut empty = query();
1161 empty.document = None;
1162 assert!(matches!(
1163 build_outline(&empty),
1164 Err(ProjectionError::MissingContent { .. })
1165 ));
1166 assert_eq!(
1167 select_excerpt(&query(), &[] as &[String]),
1168 Err(ProjectionError::EmptySelection)
1169 );
1170 assert_eq!(
1171 select_excerpt(&query(), &[" ".to_owned()]),
1172 Err(ProjectionError::EmptySelector)
1173 );
1174 assert!(matches!(
1175 select_excerpt(&query(), &["9".to_owned()]),
1176 Err(ProjectionError::UnknownSelector { .. })
1177 ));
1178 }
1179}