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, OutlineReference, OutlineSchema,
15 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 AmbiguousSelector {
57 document: String,
59 selector: String,
61 candidates: Vec<SelectorCandidate>,
63 },
64 ExplanationRequiresEntry {
66 document: String,
68 selector: String,
70 },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SelectorCandidate {
76 pub path: String,
78 pub id: String,
80}
81
82impl fmt::Display for ProjectionError {
83 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 Self::MissingContent { document } => {
86 write!(formatter, "document '{document}' has no available content")
87 }
88 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
89 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
90 Self::UnknownSelector { document, selector } => write!(
91 formatter,
92 "document '{document}' has no outline node '{selector}'; inspect its entries outline as JSON for available selectors and diagnostics"
93 ),
94 Self::AmbiguousSelector {
95 document,
96 selector,
97 candidates,
98 } => {
99 write!(
100 formatter,
101 "document '{document}' has multiple semantic entries named '{selector}': "
102 )?;
103 for (index, candidate) in candidates.iter().enumerate() {
104 if index > 0 {
105 formatter.write_str(", ")?;
106 }
107 write!(formatter, "{} ({})", candidate.path, candidate.id)?;
108 }
109 formatter.write_str("; select one by path or ID")
110 }
111 Self::ExplanationRequiresEntry { document, selector } => write!(
112 formatter,
113 "document '{document}' outline node '{selector}' is not a semantic entry; use --node for sections"
114 ),
115 }
116 }
117}
118
119impl Error for ProjectionError {}
120
121pub fn build_outline(query: &ResolvedContent) -> Result<QueryOutline, ProjectionError> {
128 build_outline_with_detail(query, OutlineDetail::Sections)
129}
130
131pub fn build_outline_with_detail(
138 query: &ResolvedContent,
139 detail: OutlineDetail,
140) -> Result<QueryOutline, ProjectionError> {
141 if query.tldr.is_none() && query.document.is_none() {
142 return Err(ProjectionError::MissingContent {
143 document: query.label.clone(),
144 });
145 }
146 let diagnostics = query
147 .document
148 .as_ref()
149 .map_or_else(Vec::new, |document| document.diagnostics.clone());
150 let entries_complete = diagnostics.iter().all(|diagnostic| {
151 !diagnostic
152 .code
153 .as_deref()
154 .is_some_and(|code| code.starts_with("markdown.semantic-entry"))
155 });
156 let mut nodes = Vec::new();
157 if query.tldr.is_some() {
158 nodes.push(OutlineNode::Tldr {
159 path: OutlinePath::Tldr.to_string().into(),
160 id: TLDR_ID.into(),
161 title: TLDR_TITLE.to_owned(),
162 });
163 }
164 if let Some(manual) = &query.document {
165 if !manual.blocks.is_empty() {
166 nodes.push(OutlineNode::DocumentRoot {
167 path: OutlinePath::DocumentRoot.to_string().into(),
168 id: DOCUMENT_ROOT_ID.into(),
169 title: DOCUMENT_ROOT_TITLE.to_owned(),
170 });
171 if detail == OutlineDetail::Entries {
172 nodes.extend(
173 definition_entries(&manual.blocks)
174 .into_iter()
175 .enumerate()
176 .filter_map(|(index, (entry, _))| {
177 let identity = entry.identity.as_ref()?;
178 Some(OutlineNode::DocumentEntry {
179 path: OutlinePath::entry(None, index + 1)?.to_string().into(),
180 id: identity.id.clone(),
181 title: identity.names.join(", "),
182 role: identity.role,
183 case: identity.case,
184 names: identity.names.clone(),
185 })
186 }),
187 );
188 }
189 }
190 nodes.extend(outline_nodes(&manual.sections, &[], detail));
191 }
192 Ok(QueryOutline {
193 schema: OutlineSchema::V7,
194 detail,
195 label: query.label.clone(),
196 source: query
197 .document
198 .as_ref()
199 .map(|document| document.source.clone()),
200 meta: query
201 .document
202 .as_ref()
203 .map(|document| document.meta.clone()),
204 diagnostics,
205 entries_complete,
206 nodes,
207 })
208}
209
210pub fn select_excerpt<S: AsRef<str>>(
219 query: &ResolvedContent,
220 selectors: &[S],
221) -> Result<QueryExcerpt, ProjectionError> {
222 if selectors.is_empty() {
223 return Err(ProjectionError::EmptySelection);
224 }
225 if query.tldr.is_none() && query.document.is_none() {
226 return Err(ProjectionError::MissingContent {
227 document: query.label.clone(),
228 });
229 }
230 let mut located = Vec::new();
231 if let Some(manual) = &query.document {
232 collect_root_entries(&manual.blocks, &mut located);
233 collect_sections(&manual.sections, &[], &[], &mut located);
234 }
235
236 let mut tldr_selected = false;
237 let mut document_root_selected = false;
238 let mut selected_ids = HashSet::new();
239 let mut selected = Vec::new();
240 for raw_selector in selectors {
241 let selector = raw_selector.as_ref().trim();
242 if selector.is_empty() {
243 return Err(ProjectionError::EmptySelector);
244 }
245 if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr))
246 && query.tldr.is_some()
247 {
248 tldr_selected = true;
249 continue;
250 }
251 if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
252 && query
253 .document
254 .as_ref()
255 .is_some_and(|document| !document.blocks.is_empty())
256 {
257 document_root_selected = true;
258 continue;
259 }
260 let candidate = resolve_candidate(query, &located, selector)?;
261 if selected_ids.insert(candidate.id()) {
262 selected.push(candidate);
263 }
264 }
265 let selected_sections = selected
266 .iter()
267 .filter(|candidate| candidate.is_section())
268 .map(|candidate| candidate.coordinates().to_vec())
269 .collect::<Vec<_>>();
270 selected.retain(|candidate| {
271 if document_root_selected && candidate.path().is_document_root_entry() {
272 return false;
273 }
274 !selected_sections.iter().any(|ancestor| {
275 if candidate.is_section() {
276 ancestor != candidate.coordinates()
277 && is_ancestor(ancestor, candidate.coordinates())
278 } else {
279 ancestor == candidate.coordinates()
280 || is_ancestor(ancestor, candidate.coordinates())
281 }
282 })
283 });
284 selected.sort_by_key(|candidate| candidate.order());
285
286 let document = if selected.is_empty() && !document_root_selected {
287 None
288 } else {
289 query.document.as_ref()
290 };
291 let mut selections = Vec::new();
292 if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
293 selections.push(ExcerptSelection::Tldr {
294 path: OutlinePath::Tldr.to_string().into(),
295 id: TLDR_ID.into(),
296 title: TLDR_TITLE.to_owned(),
297 document,
298 });
299 }
300 if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
301 selections.push(ExcerptSelection::DocumentRoot {
302 path: OutlinePath::DocumentRoot.to_string().into(),
303 id: DOCUMENT_ROOT_ID.into(),
304 title: DOCUMENT_ROOT_TITLE.to_owned(),
305 blocks: document.blocks.clone(),
306 });
307 }
308 selections.extend(selected.into_iter().map(LocatedNode::selection));
309
310 Ok(QueryExcerpt {
311 schema: ExcerptSchema::V7,
312 label: query.label.clone(),
313 producer: document.map(mant_protocol::Producer::for_document),
314 source: document.map(|document| document.source.clone()),
315 meta: document.map(|document| document.meta.clone()),
316 diagnostics: document
317 .map(|document| document.diagnostics.clone())
318 .unwrap_or_default(),
319 selections,
320 })
321}
322
323pub fn select_explanation(
334 query: &ResolvedContent,
335 selector: &str,
336) -> Result<QueryExcerpt, ProjectionError> {
337 if query.tldr.is_none() && query.document.is_none() {
338 return Err(ProjectionError::MissingContent {
339 document: query.label.clone(),
340 });
341 }
342 let selector = selector.trim();
343 if selector.is_empty() {
344 return Err(ProjectionError::EmptySelector);
345 }
346 let mut located = Vec::new();
347 if let Some(manual) = &query.document {
348 collect_root_entries(&manual.blocks, &mut located);
349 collect_sections(&manual.sections, &[], &[], &mut located);
350 }
351 let candidate = resolve_explanation_candidate(query, &located, selector)?;
352 select_excerpt(query, &[candidate.path().to_string()])
353}
354
355fn resolve_explanation_candidate<'a>(
356 query: &ResolvedContent,
357 located: &'a [LocatedNode<'a>],
358 selector: &str,
359) -> Result<&'a LocatedNode<'a>, ProjectionError> {
360 if let Some(candidate) = located.iter().find(|candidate| {
361 !candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
362 }) {
363 return Ok(candidate);
364 }
365
366 let matches = matching_aliases(located, selector).1;
367 match matches.as_slice() {
368 [candidate] => return Ok(candidate),
369 [] => {}
370 _ => {
371 return Err(ProjectionError::AmbiguousSelector {
372 document: query.label.clone(),
373 selector: selector.to_owned(),
374 candidates: matches
375 .into_iter()
376 .map(|candidate| SelectorCandidate {
377 path: candidate.path().to_string(),
378 id: candidate.id().into(),
379 })
380 .collect(),
381 });
382 }
383 }
384
385 let selects_tldr =
386 (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
387 let selects_root = (selector == DOCUMENT_ROOT_ID
388 || selector.parse() == Ok(OutlinePath::DocumentRoot))
389 && query
390 .document
391 .as_ref()
392 .is_some_and(|document| !document.blocks.is_empty());
393 let selects_section = located.iter().any(|candidate| {
394 candidate.is_section() && (candidate.matches_path(selector) || candidate.id() == selector)
395 });
396 if selects_tldr || selects_root || selects_section {
397 return Err(ProjectionError::ExplanationRequiresEntry {
398 document: query.label.clone(),
399 selector: selector.to_owned(),
400 });
401 }
402
403 Err(ProjectionError::UnknownSelector {
404 document: query.label.clone(),
405 selector: selector.to_owned(),
406 })
407}
408
409fn resolve_candidate<'a>(
410 query: &ResolvedContent,
411 located: &'a [LocatedNode<'a>],
412 selector: &str,
413) -> Result<&'a LocatedNode<'a>, ProjectionError> {
414 if let Some(candidate) = located
415 .iter()
416 .find(|candidate| candidate.matches_path(selector) || candidate.id() == selector)
417 {
418 return Ok(candidate);
419 }
420
421 let matches = matching_aliases(located, selector).1;
422 match matches.as_slice() {
423 [] => Err(ProjectionError::UnknownSelector {
424 document: query.label.clone(),
425 selector: selector.to_owned(),
426 }),
427 [candidate] => Ok(candidate),
428 _ => 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
442fn outline_nodes(
443 sections: &[Section],
444 parent: &[usize],
445 detail: OutlineDetail,
446) -> Vec<OutlineNode> {
447 sections
448 .iter()
449 .enumerate()
450 .map(|(index, section)| {
451 let mut coordinates = parent.to_vec();
452 coordinates.push(index + 1);
453 let path =
454 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
455 let mut children = Vec::new();
456 if detail == OutlineDetail::Entries {
457 children.extend(
458 definition_entries(§ion.blocks)
459 .into_iter()
460 .enumerate()
461 .filter_map(|(index, (entry, _))| {
462 let identity = entry.identity.as_ref()?;
463 Some(OutlineNode::DocumentEntry {
464 path: OutlinePath::entry(Some(&coordinates), index + 1)
465 .expect("enumerated entry paths are one-based")
466 .to_string()
467 .into(),
468 id: identity.id.clone(),
469 title: identity.names.join(", "),
470 role: identity.role,
471 case: identity.case,
472 names: identity.names.clone(),
473 })
474 }),
475 );
476 }
477 children.extend(outline_nodes(§ion.children, &coordinates, detail));
478 OutlineNode::DocumentSection {
479 path: path.to_string().into(),
480 id: section.id.clone(),
481 title: section.title.clone(),
482 children,
483 }
484 })
485 .collect()
486}
487
488enum LocatedNode<'a> {
489 Section {
490 order: usize,
491 coordinates: Vec<usize>,
492 path: OutlinePath,
493 breadcrumbs: Vec<OutlineReference>,
494 section: &'a Section,
495 },
496 Entry {
497 order: usize,
498 coordinates: Vec<usize>,
499 path: OutlinePath,
500 title: String,
501 breadcrumbs: Vec<OutlineReference>,
502 entry: &'a DefinitionItem,
503 source: Option<SourceSpan>,
504 },
505}
506
507impl LocatedNode<'_> {
508 fn order(&self) -> usize {
509 match self {
510 Self::Section { order, .. } | Self::Entry { order, .. } => *order,
511 }
512 }
513
514 fn coordinates(&self) -> &[usize] {
515 match self {
516 Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
517 }
518 }
519
520 fn path(&self) -> &OutlinePath {
521 match self {
522 Self::Section { path, .. } | Self::Entry { path, .. } => path,
523 }
524 }
525
526 fn matches_path(&self, selector: &str) -> bool {
527 selector
528 .parse::<OutlinePath>()
529 .is_ok_and(|path| path == *self.path())
530 }
531
532 fn id(&self) -> &str {
533 match self {
534 Self::Section { section, .. } => §ion.id,
535 Self::Entry { entry, .. } => {
536 &entry
537 .identity
538 .as_ref()
539 .expect("located entries have identities")
540 .id
541 }
542 }
543 }
544
545 fn matches_exact_alias(&self, selector: &str) -> bool {
546 match self {
547 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
548 identity
549 .names
550 .iter()
551 .any(|name| semantic_name_equivalent(identity.case, name, selector))
552 }),
553 Self::Section { .. } => false,
554 }
555 }
556
557 fn matches_shorthand_alias(&self, selector: &str) -> bool {
558 match self {
559 Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
560 identity.names.iter().any(|name| {
561 semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
562 semantic_name_equivalent(identity.case, shorthand, selector)
563 })
564 })
565 }),
566 Self::Section { .. } => false,
567 }
568 }
569
570 fn identity(&self) -> Option<&DefinitionIdentity> {
571 match self {
572 Self::Entry { entry, .. } => entry.identity.as_ref(),
573 Self::Section { .. } => None,
574 }
575 }
576
577 fn source(&self) -> Option<SourceSpan> {
578 match self {
579 Self::Entry { source, .. } => *source,
580 Self::Section { section, .. } => section.source,
581 }
582 }
583
584 const fn is_section(&self) -> bool {
585 matches!(self, Self::Section { .. })
586 }
587
588 fn selection(&self) -> ExcerptSelection {
589 match self {
590 Self::Section {
591 path,
592 breadcrumbs,
593 section,
594 ..
595 } => ExcerptSelection::DocumentSection {
596 path: path.to_string().into(),
597 id: section.id.clone(),
598 title: section.title.clone(),
599 breadcrumbs: breadcrumbs.clone(),
600 section: (*section).clone(),
601 },
602 Self::Entry {
603 path,
604 title,
605 breadcrumbs,
606 entry,
607 ..
608 } => ExcerptSelection::DocumentEntry {
609 path: path.to_string().into(),
610 id: entry
611 .identity
612 .as_ref()
613 .expect("located entries have identities")
614 .id
615 .clone(),
616 title: title.clone(),
617 breadcrumbs: breadcrumbs.clone(),
618 entry: (*entry).clone(),
619 },
620 }
621 }
622}
623
624fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
625 match case {
626 DefinitionCase::Sensitive => left == right,
627 DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
628 }
629}
630
631fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
632 match role {
633 DefinitionRole::Option => {
634 let shorthand = name.trim_start_matches('-');
635 (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
636 }
637 DefinitionRole::EnvironmentVariable => name
638 .strip_prefix("$env:")
639 .or_else(|| name.strip_prefix("$ENV:")),
640 DefinitionRole::Command | DefinitionRole::Variable => None,
641 }
642}
643
644#[derive(Clone, Copy)]
645enum AliasMatchKind {
646 Exact,
647 Shorthand,
648}
649
650impl AliasMatchKind {
651 const fn label(self) -> &'static str {
652 match self {
653 Self::Exact => "exact alias",
654 Self::Shorthand => "normalized shorthand",
655 }
656 }
657}
658
659fn matching_aliases<'a>(
660 located: &'a [LocatedNode<'a>],
661 selector: &str,
662) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
663 let exact = located
664 .iter()
665 .filter(|candidate| candidate.matches_exact_alias(selector))
666 .collect::<Vec<_>>();
667 if !exact.is_empty() {
668 return (AliasMatchKind::Exact, exact);
669 }
670 (
671 AliasMatchKind::Shorthand,
672 located
673 .iter()
674 .filter(|candidate| candidate.matches_shorthand_alias(selector))
675 .collect(),
676 )
677}
678
679pub(crate) fn semantic_selector_diagnostics(
685 blocks: &[Block],
686 sections: &[Section],
687) -> Vec<Diagnostic> {
688 let mut located = Vec::new();
689 collect_root_entries(blocks, &mut located);
690 collect_sections(sections, &[], &[], &mut located);
691 let mut selectors = BTreeSet::new();
692 for candidate in &located {
693 let Some(identity) = candidate.identity() else {
694 continue;
695 };
696 for name in &identity.names {
697 selectors.insert(name.clone());
698 if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
699 selectors.insert(shorthand.to_owned());
700 }
701 }
702 }
703
704 let mut reported = HashSet::new();
705 let mut diagnostics = Vec::new();
706 for selector in selectors {
707 let (kind, matches) = matching_aliases(&located, &selector);
708 if matches.len() < 2 {
709 continue;
710 }
711 let key = matches
712 .iter()
713 .map(|candidate| candidate.id())
714 .collect::<Vec<_>>()
715 .join("\u{1f}");
716 if !reported.insert(key) {
717 continue;
718 }
719 let candidates = matches
720 .iter()
721 .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
722 .collect::<Vec<_>>()
723 .join(", ");
724 diagnostics.push(Diagnostic {
725 level: DiagnosticLevel::Warning,
726 code: Some("markdown.semantic-entry.ambiguous-selector".to_owned()),
727 message: format!(
728 "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
729 kind.label()
730 ),
731 source: matches.first().and_then(|candidate| candidate.source()),
732 });
733 }
734 diagnostics
735}
736
737fn collect_sections<'a>(
738 sections: &'a [Section],
739 parent_coordinates: &[usize],
740 breadcrumbs: &[OutlineReference],
741 output: &mut Vec<LocatedNode<'a>>,
742) {
743 for (index, section) in sections.iter().enumerate() {
744 let mut coordinates = parent_coordinates.to_vec();
745 coordinates.push(index + 1);
746 let path =
747 OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
748 let order = output.len();
749 output.push(LocatedNode::Section {
750 order,
751 coordinates: coordinates.clone(),
752 path: path.clone(),
753 breadcrumbs: breadcrumbs.to_vec(),
754 section,
755 });
756 let mut child_breadcrumbs = breadcrumbs.to_vec();
757 child_breadcrumbs.push(OutlineReference {
758 path: path.to_string().into(),
759 id: section.id.clone(),
760 title: section.title.clone(),
761 });
762 for (index, (entry, source)) in definition_entries(§ion.blocks).into_iter().enumerate()
763 {
764 let Some(identity) = &entry.identity else {
765 continue;
766 };
767 output.push(LocatedNode::Entry {
768 order: output.len(),
769 coordinates: coordinates.clone(),
770 path: OutlinePath::entry(Some(&coordinates), index + 1)
771 .expect("enumerated entry paths are one-based"),
772 title: identity.names.join(", "),
773 breadcrumbs: child_breadcrumbs.clone(),
774 entry,
775 source,
776 });
777 }
778 collect_sections(§ion.children, &coordinates, &child_breadcrumbs, output);
779 }
780}
781
782fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
783 let breadcrumbs = vec![OutlineReference {
784 path: OutlinePath::DocumentRoot.to_string().into(),
785 id: DOCUMENT_ROOT_ID.into(),
786 title: DOCUMENT_ROOT_TITLE.to_owned(),
787 }];
788 for (index, (entry, source)) in definition_entries(blocks).into_iter().enumerate() {
789 let Some(identity) = &entry.identity else {
790 continue;
791 };
792 output.push(LocatedNode::Entry {
793 order: output.len(),
794 coordinates: Vec::new(),
795 path: OutlinePath::entry(None, index + 1)
796 .expect("enumerated entry paths are one-based"),
797 title: identity.names.join(", "),
798 breadcrumbs: breadcrumbs.clone(),
799 entry,
800 source,
801 });
802 }
803}
804
805fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
806 ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
807}
808
809#[cfg(test)]
810mod tests {
811 use crate::ResolvedContent;
812 use mant_ir::{
813 Block, Document, DocumentMeta, DocumentSource, Inline, LayoutHint, Section, SourceFormat,
814 TldrDocument, TldrOrigin,
815 };
816 use mant_protocol::{ExcerptSelection, OutlineNode};
817
818 use super::{ProjectionError, build_outline, select_excerpt};
819
820 fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
821 Section {
822 id: id.to_owned().into(),
823 title: title.to_owned(),
824 spacing_before_lines: 0,
825 blocks: Vec::new(),
826 children,
827 source: None,
828 }
829 }
830
831 fn query() -> ResolvedContent {
832 ResolvedContent {
833 address: None,
834 label: "demo".to_owned(),
835 document: Some(Document {
836 parser: None,
837 source: DocumentSource {
838 format: SourceFormat::Man,
839 path: Some("/man/demo.1".to_owned()),
840 },
841 meta: DocumentMeta {
842 manual_section: Some("1".to_owned()),
843 ..DocumentMeta::default()
844 },
845 diagnostics: Vec::new(),
846 blocks: Vec::new(),
847 sections: vec![
848 section("name-1", "NAME", Vec::new()),
849 section(
850 "options-2",
851 "OPTIONS",
852 vec![
853 section("common-3", "Common options", Vec::new()),
854 section("other-4", "Other options", Vec::new()),
855 ],
856 ),
857 section("files-5", "FILES", Vec::new()),
858 ],
859 }),
860 tldr: None,
861 }
862 }
863
864 fn tldr() -> TldrDocument {
865 TldrDocument {
866 title: "demo".to_owned(),
867 description: vec!["A small demonstration.".to_owned()],
868 more_information: Some("https://example.com/demo".to_owned()),
869 examples: Vec::new(),
870 platform: "common".to_owned(),
871 language: "en".to_owned(),
872 source_path: "/tldr/pages/common/demo.md".to_owned(),
873 origin: TldrOrigin::TldrPages,
874 }
875 }
876
877 #[test]
878 fn builds_one_based_tree_paths_without_copying_blocks() {
879 let outline = build_outline(&query()).expect("outline");
880
881 assert_eq!(
882 outline
883 .meta
884 .as_ref()
885 .and_then(|meta| meta.manual_section.as_deref()),
886 Some("1")
887 );
888 assert_eq!(outline.nodes[1].path(), "2");
889 assert_eq!(outline.nodes[1].id(), "options-2");
890 assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
891 assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
892 }
893
894 #[test]
895 fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
896 let mut query = query();
897 query.tldr = Some(tldr());
898
899 let outline = build_outline(&query).expect("combined outline");
900
901 assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
902 assert_eq!(outline.nodes[0].path(), "0");
903 assert_eq!(outline.nodes[0].id(), "tldr");
904 assert_eq!(outline.nodes[1].path(), "1");
905 assert_eq!(outline.nodes[2].path(), "2");
906 }
907
908 #[test]
909 fn addresses_document_content_before_the_first_heading_as_root() {
910 let mut query = query();
911 let document = query.document.as_mut().expect("document");
912 document.source.format = SourceFormat::Markdown;
913 document.blocks.push(Block::Paragraph {
914 children: vec![Inline::Text {
915 value: "Document preface.".to_owned(),
916 }],
917 layout: LayoutHint::default(),
918 source: None,
919 });
920
921 let outline = build_outline(&query).expect("Markdown outline");
922 assert!(matches!(
923 &outline.nodes[0],
924 OutlineNode::DocumentRoot { path, id, title }
925 if path == "root" && id == "document-overview" && title == "OVERVIEW"
926 ));
927 assert_eq!(outline.nodes[1].path(), "1");
929
930 let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
931 .expect("root excerpt");
932 assert!(matches!(
933 excerpt.selections.as_slice(),
934 [ExcerptSelection::DocumentRoot { path, blocks, .. }]
935 if path == "root" && blocks.len() == 1
936 ));
937 assert_eq!(
938 excerpt.source.as_ref().map(|source| source.format),
939 Some(SourceFormat::Markdown)
940 );
941 }
942
943 #[test]
944 fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
945 let excerpt = select_excerpt(
946 &query(),
947 &[
948 "files-5".to_owned(),
949 "2.1".to_owned(),
950 "2".to_owned(),
951 "options-2".to_owned(),
952 ],
953 )
954 .expect("excerpt");
955
956 let paths = excerpt
957 .selections
958 .iter()
959 .map(|selection| match selection {
960 ExcerptSelection::Tldr { path, .. }
961 | ExcerptSelection::DocumentRoot { path, .. }
962 | ExcerptSelection::DocumentSection { path, .. }
963 | ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
964 })
965 .collect::<Vec<_>>();
966 assert_eq!(paths, ["2", "3"]);
967 let ExcerptSelection::DocumentSection {
968 section,
969 breadcrumbs,
970 ..
971 } = &excerpt.selections[0]
972 else {
973 panic!("expected manual selection");
974 };
975 assert_eq!(section.children.len(), 2);
976 assert!(breadcrumbs.is_empty());
977 }
978
979 #[test]
980 fn child_selection_retains_ancestor_breadcrumbs() {
981 let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
982
983 let ExcerptSelection::DocumentSection {
984 title, breadcrumbs, ..
985 } = &excerpt.selections[0]
986 else {
987 panic!("expected manual selection");
988 };
989 assert_eq!(title, "Other options");
990 assert_eq!(breadcrumbs[0].path, "2");
991 assert_eq!(breadcrumbs[0].title, "OPTIONS");
992 }
993
994 #[test]
995 fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
996 let mut combined = query();
997 combined.tldr = Some(tldr());
998 let excerpt = select_excerpt(
999 &combined,
1000 &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
1001 )
1002 .expect("combined excerpt");
1003 assert!(matches!(
1004 excerpt.selections.as_slice(),
1005 [ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
1006 if path == "0"
1007 ));
1008
1009 let mut tldr_only = combined;
1010 tldr_only.document = None;
1011 let outline = build_outline(&tldr_only).expect("tldr-only outline");
1012 assert_eq!(outline.nodes.len(), 1);
1013 assert_eq!(outline.nodes[0].path(), "0");
1014 assert!(outline.source.is_none());
1015 assert!(outline.meta.is_none());
1016 }
1017
1018 #[test]
1019 fn reports_missing_content_and_unknown_or_empty_selectors() {
1020 let mut empty = query();
1021 empty.document = None;
1022 assert!(matches!(
1023 build_outline(&empty),
1024 Err(ProjectionError::MissingContent { .. })
1025 ));
1026 assert_eq!(
1027 select_excerpt(&query(), &[] as &[String]),
1028 Err(ProjectionError::EmptySelection)
1029 );
1030 assert_eq!(
1031 select_excerpt(&query(), &[" ".to_owned()]),
1032 Err(ProjectionError::EmptySelector)
1033 );
1034 assert!(matches!(
1035 select_excerpt(&query(), &["9".to_owned()]),
1036 Err(ProjectionError::UnknownSelector { .. })
1037 ));
1038 }
1039}