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