Skip to main content

mant_core/
projection.rs

1//! Projects complete structured documents into outlines and selectable excerpts.
2
3use 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
24/// Whether an identifier belongs to the selector namespace rather than a
25/// document-defined node.
26///
27/// Section paths use dotted positive indices (`2.1`), while semantic entries
28/// append an option index (`2.1/o3`). The parser reserves the complete grammar,
29/// not only selectors present in one particular document, so source-defined
30/// IDs can never make excerpt lookup ambiguous.
31pub(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/// Failure to derive an addressable view from a complete query.
55#[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/// One stable qualification offered when a semantic alias is ambiguous.
78#[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
123/// Build a block-free, addressable outline for one complete query.
124///
125/// # Errors
126///
127/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
128/// is available.
129pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
130    build_outline_with_detail(query, OutlineDetail::Sections)
131}
132
133/// Build an outline with optional semantic definition entries.
134///
135/// # Errors
136///
137/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
138/// is available.
139pub 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
212/// Select tldr, document-root content, or complete section subtrees by path or ID.
213///
214/// Duplicate selections and descendants of another selected node are omitted.
215/// The result always follows source order, independent of argument order.
216///
217/// # Errors
218///
219/// Returns an error when no content exists or any selector is empty or unknown.
220pub 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
323/// Select exactly one semantic entry by stable path, ID, or alias.
324///
325/// Exact paths and IDs take precedence over aliases. Repeated aliases are
326/// rejected with deterministic candidates instead of silently choosing the
327/// first entry in source order.
328///
329/// # Errors
330///
331/// Returns an error when the selector is empty, unknown, names a section, or
332/// matches more than one semantic entry.
333pub 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(&section.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(&section.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, .. } => &section.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
667/// Report selectors that cannot address exactly one semantic entry.
668///
669/// The lookup policy itself remains usable through stable paths and IDs, but
670/// Markdown authors receive a source diagnostic before an agent discovers the
671/// ambiguity at query time.
672pub(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(&section.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(&section.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        // Heading paths remain stable and independent from the synthetic root.
925        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}