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