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::V0Dot8,
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::V0Dot8,
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.iter().find(|candidate| {
405 !candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
406 }) {
407 return Ok(candidate);
408 }
409
410 let matches = matching_aliases(located, selector).1;
411 match matches.as_slice() {
412 [candidate] => return Ok(candidate),
413 [] => {}
414 _ => {
415 return Err(ProjectionError::AmbiguousSelector {
416 document: query.label.clone(),
417 selector: selector.to_owned(),
418 candidates: matches
419 .into_iter()
420 .map(|candidate| SelectorCandidate {
421 path: candidate.path().to_string(),
422 id: candidate.id().into(),
423 })
424 .collect(),
425 });
426 }
427 }
428
429 let selects_tldr =
430 (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
431 let selects_root = (selector == DOCUMENT_ROOT_ID
432 || selector.parse() == Ok(OutlinePath::DocumentRoot))
433 && query
434 .document
435 .as_ref()
436 .is_some_and(|document| !document.blocks.is_empty());
437 let selects_section = located.iter().any(|candidate| {
438 candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
439 });
440 if selects_tldr || selects_root || selects_section {
441 return Err(ProjectionError::ExplanationRequiresEntry {
442 document: query.label.clone(),
443 selector: selector.to_owned(),
444 });
445 }
446
447 Err(ProjectionError::UnknownSelector {
448 document: query.label.clone(),
449 selector: selector.to_owned(),
450 })
451}
452
453fn resolve_candidate<'a>(
454 query: &ResolvedContent,
455 located: &'a [LocatedNode<'a>],
456 selector: &str,
457) -> Result<&'a LocatedNode<'a>, ProjectionError> {
458 if let Some(candidate) = located
459 .iter()
460 .find(|candidate| candidate.matches_path(selector) || candidate.id() == selector)
461 {
462 return Ok(candidate);
463 }
464
465 let matches = matching_aliases(located, selector).1;
466 match matches.as_slice() {
467 [] => Err(ProjectionError::UnknownSelector {
468 document: query.label.clone(),
469 selector: selector.to_owned(),
470 }),
471 [candidate] => Ok(candidate),
472 _ => Err(ProjectionError::AmbiguousSelector {
473 document: query.label.clone(),
474 selector: selector.to_owned(),
475 candidates: matches
476 .into_iter()
477 .map(|candidate| SelectorCandidate {
478 path: candidate.path().to_string(),
479 id: candidate.id().into(),
480 })
481 .collect(),
482 }),
483 }
484}
485
486fn outline_nodes(
487 sections: &[Section],
488 parent: &[usize],
489 detail: OutlineDetail,
490) -> Vec<OutlineNode> {
491 sections
492 .iter()
493 .enumerate()
494 .map(|(index, section)| {
495 let mut coordinates = parent.to_vec();
496 coordinates.push(index + 1);
497 let path =
498 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
499 let mut children = Vec::new();
500 if detail == OutlineDetail::Entries {
501 children.extend(
502 definition_entries(§ion.blocks)
503 .into_iter()
504 .enumerate()
505 .filter_map(|(index, (entry, _))| {
506 let identity = entry.identity.as_ref()?;
507 Some(OutlineNode::DocumentEntry {
508 path: OutlinePath::entry(Some(&coordinates), index + 1)
509 .expect("enumerated entry paths are one-based")
510 .to_string()
511 .into(),
512 id: identity.id.clone(),
513 title: identity.names.join(", "),
514 role: identity.role,
515 case: identity.case,
516 names: identity.names.clone(),
517 })
518 }),
519 );
520 }
521 children.extend(outline_nodes(§ion.children, &coordinates, detail));
522 OutlineNode::DocumentSection {
523 path: path.to_string().into(),
524 id: section.id.clone(),
525 title: section.title.clone(),
526 children,
527 }
528 })
529 .collect()
530}
531
532enum LocatedNode<'a> {
533 Section {
534 order: usize,
535 coordinates: Vec<usize>,
536 path: OutlinePath,
537 breadcrumbs: Vec<OutlineReference>,
538 section: &'a Section,
539 },
540 Entry {
541 order: usize,
542 coordinates: Vec<usize>,
543 path: OutlinePath,
544 title: String,
545 breadcrumbs: Vec<OutlineReference>,
546 entry: &'a DefinitionItem,
547 source: Option<SourceSpan>,
548 },
549}
550
551impl LocatedNode<'_> {
552 fn order(&self) -> usize {
553 match self {
554 Self::Section { order, .. } | Self::Entry { order, .. } => *order,
555 }
556 }
557
558 fn coordinates(&self) -> &[usize] {
559 match self {
560 Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
561 }
562 }
563
564 fn path(&self) -> &OutlinePath {
565 match self {
566 Self::Section { path, .. } | Self::Entry { path, .. } => path,
567 }
568 }
569
570 fn matches_path(&self, selector: &str) -> bool {
571 selector
572 .parse::<OutlinePath>()
573 .is_ok_and(|path| path == *self.path())
574 }
575
576 fn id(&self) -> &str {
577 match self {
578 Self::Section { section, .. } => §ion.id,
579 Self::Entry { entry, .. } => {
580 &entry
581 .identity
582 .as_ref()
583 .expect("located entries have identities")
584 .id
585 }
586 }
587 }
588
589 fn matches_exact_alias(&self, selector: &str) -> bool {
590 match self {
591 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
592 identity
593 .names
594 .iter()
595 .any(|name| semantic_name_equivalent(identity.case, name, selector))
596 }),
597 Self::Section { .. } => false,
598 }
599 }
600
601 fn matches_shorthand_alias(&self, selector: &str) -> bool {
602 match self {
603 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
604 identity.names.iter().any(|name| {
605 semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
606 semantic_name_equivalent(identity.case, shorthand, selector)
607 })
608 })
609 }),
610 Self::Section { .. } => false,
611 }
612 }
613
614 fn identity(&self) -> Option<&DefinitionIdentity> {
615 match self {
616 Self::Entry { entry, .. } => entry.identity.as_ref(),
617 Self::Section { .. } => None,
618 }
619 }
620
621 fn source(&self) -> Option<SourceSpan> {
622 match self {
623 Self::Entry { source, .. } => *source,
624 Self::Section { section, .. } => section.source,
625 }
626 }
627
628 const fn is_section(&self) -> bool {
629 matches!(self, Self::Section { .. })
630 }
631
632 fn selection(&self) -> ExcerptSelection {
633 match self {
634 Self::Section {
635 path,
636 breadcrumbs,
637 section,
638 ..
639 } => ExcerptSelection::DocumentSection {
640 outline: OutlineTrail {
641 ancestors: breadcrumbs.clone(),
642 node: OutlineNodeReference::DocumentSection {
643 path: path.to_string().into(),
644 id: section.id.clone(),
645 title: section.title.clone(),
646 },
647 },
648 section: (*section).clone(),
649 },
650 Self::Entry {
651 path,
652 title,
653 breadcrumbs,
654 entry,
655 ..
656 } => ExcerptSelection::DocumentEntry {
657 outline: OutlineTrail {
658 ancestors: breadcrumbs.clone(),
659 node: {
660 let identity = entry
661 .identity
662 .as_ref()
663 .expect("located entries have identities");
664 OutlineNodeReference::DocumentEntry {
665 path: path.to_string().into(),
666 id: identity.id.clone(),
667 title: title.clone(),
668 role: identity.role,
669 case: identity.case,
670 names: identity.names.clone(),
671 }
672 },
673 },
674 entry: (*entry).clone(),
675 },
676 }
677 }
678}
679
680fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
681 match case {
682 DefinitionCase::Sensitive => left == right,
683 DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
684 }
685}
686
687fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
688 match role {
689 DefinitionRole::Option => {
690 let shorthand = name.trim_start_matches('-');
691 (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
692 }
693 DefinitionRole::EnvironmentVariable => name
694 .strip_prefix("$env:")
695 .or_else(|| name.strip_prefix("$ENV:")),
696 DefinitionRole::Command | DefinitionRole::Variable => None,
697 }
698}
699
700#[derive(Clone, Copy)]
701enum AliasMatchKind {
702 Exact,
703 Shorthand,
704}
705
706impl AliasMatchKind {
707 const fn label(self) -> &'static str {
708 match self {
709 Self::Exact => "exact alias",
710 Self::Shorthand => "normalized shorthand",
711 }
712 }
713}
714
715fn matching_aliases<'a>(
716 located: &'a [LocatedNode<'a>],
717 selector: &str,
718) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
719 let exact = located
720 .iter()
721 .filter(|candidate| candidate.matches_exact_alias(selector))
722 .collect::<Vec<_>>();
723 if !exact.is_empty() {
724 return (AliasMatchKind::Exact, exact);
725 }
726 (
727 AliasMatchKind::Shorthand,
728 located
729 .iter()
730 .filter(|candidate| candidate.matches_shorthand_alias(selector))
731 .collect(),
732 )
733}
734
735pub(crate) fn semantic_selector_diagnostics(
741 blocks: &[Block],
742 sections: &[Section],
743) -> Vec<Diagnostic> {
744 let mut located = Vec::new();
745 collect_root_entries(blocks, &mut located);
746 collect_sections(sections, &[], &[], &mut located);
747 let mut selectors = BTreeSet::new();
748 for candidate in &located {
749 let Some(identity) = candidate.identity() else {
750 continue;
751 };
752 for name in &identity.names {
753 selectors.insert(name.clone());
754 if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
755 selectors.insert(shorthand.to_owned());
756 }
757 }
758 }
759
760 let mut reported = HashSet::new();
761 let mut diagnostics = Vec::new();
762 for selector in selectors {
763 let (kind, matches) = matching_aliases(&located, &selector);
764 if matches.len() < 2 {
765 continue;
766 }
767 let key = matches
768 .iter()
769 .map(|candidate| candidate.id())
770 .collect::<Vec<_>>()
771 .join("\u{1f}");
772 if !reported.insert(key) {
773 continue;
774 }
775 let candidates = matches
776 .iter()
777 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
778 .collect::<Vec<_>>()
779 .join(", ");
780 diagnostics.push(Diagnostic {
781 level: DiagnosticLevel::Warning,
782 code: Some("markdown.semantic-entry.ambiguous-selector".to_owned()),
783 message: format!(
784 "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
785 kind.label()
786 ),
787 source: matches.first().and_then(|candidate| candidate.source()),
788 });
789 }
790 diagnostics
791}
792
793fn collect_sections<'a>(
794 sections: &'a [Section],
795 parent_coordinates: &[usize],
796 breadcrumbs: &[OutlineReference],
797 output: &mut Vec<LocatedNode<'a>>,
798) {
799 for (index, section) in sections.iter().enumerate() {
800 let mut coordinates = parent_coordinates.to_vec();
801 coordinates.push(index + 1);
802 let path =
803 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
804 let order = output.len();
805 output.push(LocatedNode::Section {
806 order,
807 coordinates: coordinates.clone(),
808 path: path.clone(),
809 breadcrumbs: breadcrumbs.to_vec(),
810 section,
811 });
812 let mut child_breadcrumbs = breadcrumbs.to_vec();
813 child_breadcrumbs.push(OutlineReference {
814 path: path.to_string().into(),
815 id: section.id.clone(),
816 title: section.title.clone(),
817 });
818 for (index, (entry, source)) in definition_entries(§ion.blocks).into_iter().enumerate()
819 {
820 let Some(identity) = &entry.identity else {
821 continue;
822 };
823 output.push(LocatedNode::Entry {
824 order: output.len(),
825 coordinates: coordinates.clone(),
826 path: OutlinePath::entry(Some(&coordinates), index + 1)
827 .expect("enumerated entry paths are one-based"),
828 title: identity.names.join(", "),
829 breadcrumbs: child_breadcrumbs.clone(),
830 entry,
831 source,
832 });
833 }
834 collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
835 }
836}
837
838fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
839 let breadcrumbs = vec![OutlineReference {
840 path: OutlinePath::DocumentRoot.to_string().into(),
841 id: DOCUMENT_ROOT_ID.into(),
842 title: DOCUMENT_ROOT_TITLE.to_owned(),
843 }];
844 for (index, (entry, source)) in definition_entries(blocks).into_iter().enumerate() {
845 let Some(identity) = &entry.identity else {
846 continue;
847 };
848 output.push(LocatedNode::Entry {
849 order: output.len(),
850 coordinates: Vec::new(),
851 path: OutlinePath::entry(None, index + 1)
852 .expect("enumerated entry paths are one-based"),
853 title: identity.names.join(", "),
854 breadcrumbs: breadcrumbs.clone(),
855 entry,
856 source,
857 });
858 }
859}
860
861fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
862 ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
863}
864
865#[cfg(test)]
866mod tests {
867 use crate::ResolvedContent;
868 use mant_ir::{
869 Block, Diagnostic, DiagnosticLevel, Document, DocumentMeta, DocumentSource, Inline,
870 LayoutHint, Section, SourceFormat, TldrDocument, TldrOrigin,
871 };
872 use mant_protocol::{ExcerptSelection, OutlineNode};
873
874 use super::{ProjectionError, build_outline, select_excerpt};
875
876 fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
877 Section {
878 id: id.to_owned().into(),
879 title: title.to_owned(),
880 spacing_before_lines: 0,
881 blocks: Vec::new(),
882 children,
883 source: None,
884 }
885 }
886
887 fn query() -> ResolvedContent {
888 ResolvedContent {
889 address: None,
890 label: "demo".to_owned(),
891 document: Some(Document {
892 parser: None,
893 source: DocumentSource {
894 format: SourceFormat::Man,
895 path: Some("/man/demo.1".to_owned()),
896 },
897 meta: DocumentMeta {
898 manual_section: Some("1".to_owned()),
899 ..DocumentMeta::default()
900 },
901 diagnostics: Vec::new(),
902 blocks: Vec::new(),
903 sections: vec![
904 section("name-1", "NAME", Vec::new()),
905 section(
906 "options-2",
907 "OPTIONS",
908 vec![
909 section("common-3", "Common options", Vec::new()),
910 section("other-4", "Other options", Vec::new()),
911 ],
912 ),
913 section("files-5", "FILES", Vec::new()),
914 ],
915 }),
916 tldr: None,
917 }
918 }
919
920 fn tldr() -> TldrDocument {
921 TldrDocument {
922 title: "demo".to_owned(),
923 description: vec!["A small demonstration.".to_owned()],
924 more_information: Some("https://example.com/demo".to_owned()),
925 examples: Vec::new(),
926 platform: "common".to_owned(),
927 language: "en".to_owned(),
928 source_path: "/tldr/pages/common/demo.md".to_owned(),
929 origin: TldrOrigin::TldrPages,
930 }
931 }
932
933 #[test]
934 fn builds_one_based_tree_paths_without_copying_blocks() {
935 let outline = build_outline(&query()).expect("outline");
936
937 assert_eq!(
938 outline
939 .meta
940 .as_ref()
941 .and_then(|meta| meta.manual_section.as_deref()),
942 Some("1")
943 );
944 assert_eq!(outline.nodes[1].path(), "2");
945 assert_eq!(outline.nodes[1].id(), "options-2");
946 assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
947 assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
948 }
949
950 #[test]
951 fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
952 let mut query = query();
953 query.tldr = Some(tldr());
954
955 let outline = build_outline(&query).expect("combined outline");
956
957 assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
958 assert_eq!(outline.nodes[0].path(), "0");
959 assert_eq!(outline.nodes[0].id(), "tldr");
960 assert_eq!(outline.nodes[1].path(), "1");
961 assert_eq!(outline.nodes[2].path(), "2");
962 }
963
964 #[test]
965 fn entry_completeness_distinguishes_rejections_from_author_warnings() {
966 let mut query = query();
967 {
968 let document = query.document.as_mut().expect("document");
969 for code in [
970 "markdown.semantic-entry.ambiguous-selector",
971 "markdown.semantic-entry-list",
972 ] {
973 document.diagnostics.push(Diagnostic {
974 level: DiagnosticLevel::Warning,
975 code: Some(code.to_owned()),
976 message: "author warning".to_owned(),
977 source: None,
978 });
979 }
980 }
981 assert!(
982 build_outline(&query)
983 .expect("complete outline")
984 .entries_complete
985 );
986
987 query
988 .document
989 .as_mut()
990 .expect("document")
991 .diagnostics
992 .push(Diagnostic {
993 level: DiagnosticLevel::Warning,
994 code: Some("markdown.semantic-entry.invalid-entry-name".to_owned()),
995 message: "rejected declaration".to_owned(),
996 source: None,
997 });
998 assert!(
999 !build_outline(&query)
1000 .expect("partial outline")
1001 .entries_complete
1002 );
1003 }
1004
1005 #[test]
1006 fn addresses_document_content_before_the_first_heading_as_root() {
1007 let mut query = query();
1008 let document = query.document.as_mut().expect("document");
1009 document.source.format = SourceFormat::Markdown;
1010 document.blocks.push(Block::Paragraph {
1011 children: vec![Inline::Text {
1012 value: "Document preface.".to_owned(),
1013 }],
1014 layout: LayoutHint::default(),
1015 source: None,
1016 });
1017
1018 let outline = build_outline(&query).expect("Markdown outline");
1019 assert!(matches!(
1020 &outline.nodes[0],
1021 OutlineNode::DocumentRoot { path, id, title }
1022 if path == "root" && id == "document-overview" && title == "OVERVIEW"
1023 ));
1024 assert_eq!(outline.nodes[1].path(), "1");
1026
1027 let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
1028 .expect("root excerpt");
1029 assert!(matches!(
1030 excerpt.selections.as_slice(),
1031 [ExcerptSelection::DocumentRoot { outline, blocks, .. }]
1032 if outline.path() == "root" && blocks.len() == 1
1033 ));
1034 assert_eq!(
1035 excerpt.source.as_ref().map(|source| source.format),
1036 Some(SourceFormat::Markdown)
1037 );
1038 }
1039
1040 #[test]
1041 fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
1042 let excerpt = select_excerpt(
1043 &query(),
1044 &[
1045 "files-5".to_owned(),
1046 "2.1".to_owned(),
1047 "2".to_owned(),
1048 "options-2".to_owned(),
1049 ],
1050 )
1051 .expect("excerpt");
1052
1053 let paths = excerpt
1054 .selections
1055 .iter()
1056 .map(|selection| selection.outline().path())
1057 .collect::<Vec<_>>();
1058 assert_eq!(paths, ["2", "3"]);
1059 let ExcerptSelection::DocumentSection {
1060 section, outline, ..
1061 } = &excerpt.selections[0]
1062 else {
1063 panic!("expected manual selection");
1064 };
1065 assert_eq!(section.children.len(), 2);
1066 assert!(outline.ancestors.is_empty());
1067 }
1068
1069 #[test]
1070 fn child_selection_retains_ancestor_breadcrumbs() {
1071 let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
1072
1073 let ExcerptSelection::DocumentSection { outline, .. } = &excerpt.selections[0] else {
1074 panic!("expected manual selection");
1075 };
1076 assert_eq!(outline.title(), "Other options");
1077 assert_eq!(outline.ancestors[0].path, "2");
1078 assert_eq!(outline.ancestors[0].title, "OPTIONS");
1079 }
1080
1081 #[test]
1082 fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
1083 let mut combined = query();
1084 combined.tldr = Some(tldr());
1085 let excerpt = select_excerpt(
1086 &combined,
1087 &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
1088 )
1089 .expect("combined excerpt");
1090 assert!(matches!(
1091 excerpt.selections.as_slice(),
1092 [ExcerptSelection::Tldr { outline, .. }, ExcerptSelection::DocumentSection { .. }]
1093 if outline.path() == "0"
1094 ));
1095
1096 let mut tldr_only = combined;
1097 tldr_only.document = None;
1098 let outline = build_outline(&tldr_only).expect("tldr-only outline");
1099 assert_eq!(outline.nodes.len(), 1);
1100 assert_eq!(outline.nodes[0].path(), "0");
1101 assert!(outline.source.is_none());
1102 assert!(outline.meta.is_none());
1103 }
1104
1105 #[test]
1106 fn reports_missing_content_and_unknown_or_empty_selectors() {
1107 let mut empty = query();
1108 empty.document = None;
1109 assert!(matches!(
1110 build_outline(&empty),
1111 Err(ProjectionError::MissingContent { .. })
1112 ));
1113 assert_eq!(
1114 select_excerpt(&query(), &[] as &[String]),
1115 Err(ProjectionError::EmptySelection)
1116 );
1117 assert_eq!(
1118 select_excerpt(&query(), &[" ".to_owned()]),
1119 Err(ProjectionError::EmptySelector)
1120 );
1121 assert!(matches!(
1122 select_excerpt(&query(), &["9".to_owned()]),
1123 Err(ProjectionError::UnknownSelector { .. })
1124 ));
1125 }
1126}