Skip to main content

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