Skip to main content

mant_engine/
projection.rs

1//! Projects complete structured documents into outlines and selectable excerpts.
2
3use std::{
4    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
5    error::Error,
6    fmt,
7};
8
9use mant_ir::{
10    Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
11    DiagnosticLevel, EntryKindCount, EntrySummary, OutlinePath, Section, SemanticEntry,
12    SemanticIndex, SourceSpan,
13};
14use mant_protocol::{
15    EntryProjection, ExcerptSchema, ExcerptSelection, NodeSelector, OutlineDetail, OutlineNode,
16    OutlineNodeReference, OutlineReference, OutlineSchema, OutlineTrail, QueryExcerpt,
17    QueryOutline,
18};
19
20use crate::{
21    ResolvedContent,
22    definitions::{definition_entries, environment_variable_body},
23    inline::plain_text,
24};
25
26pub(crate) const TLDR_ID: &str = "tldr";
27const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
28pub(crate) use mant_ir::DOCUMENT_ROOT_ID;
29pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
30
31/// Whether an identifier belongs to the selector namespace rather than a
32/// document-defined node.
33///
34/// Section paths use dotted positive indices (`2.1`), while semantic entries
35/// append a semantic-entry index (`2.1/e3`). The parser reserves the complete grammar,
36/// not only selectors present in one particular document, so source-defined
37/// IDs can never make excerpt lookup ambiguous.
38pub(crate) fn is_reserved_selector(value: &str) -> bool {
39    matches!(value, TLDR_ID | DOCUMENT_ROOT_ID)
40        || value.parse::<OutlinePath>().is_ok()
41        || [
42            "option-",
43            "marker-",
44            "operand-",
45            "command-",
46            "configuration-",
47            "environment-",
48            "variable-",
49            "value-",
50            "term-",
51        ]
52        .iter()
53        .any(|prefix| value.starts_with(prefix))
54}
55
56/// Failure to derive an addressable view from a complete query.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ProjectionError {
59    /// Neither an authoritative document nor a quick reference is available.
60    MissingContent {
61        /// Requested document label.
62        document: String,
63    },
64    /// Excerpt projection received no selectors.
65    EmptySelection,
66    /// One selector was empty after trimming.
67    EmptySelector,
68    /// No addressable node matched a selector.
69    UnknownSelector {
70        /// Requested document label.
71        document: String,
72        /// Unresolved selector.
73        selector: String,
74    },
75    /// Explanation lookup found no semantic entry, but the same text occurs
76    /// elsewhere in the rendered document.
77    SelectorFoundOnlyInText {
78        /// Requested document label.
79        document: String,
80        /// Unresolved semantic-entry selector.
81        selector: String,
82        /// Canonical path of the nearest addressable node.
83        path: String,
84        /// Display title of the nearest addressable node.
85        title: String,
86        /// One-based rendered line containing the first occurrence.
87        line: u32,
88    },
89    /// An alias matched more than one semantic entry.
90    AmbiguousSelector {
91        /// Requested document label.
92        document: String,
93        /// Ambiguous selector.
94        selector: String,
95        /// Stable paths and IDs that disambiguate the match.
96        candidates: Vec<SelectorCandidate>,
97    },
98    /// Explanation lookup selected a non-entry node.
99    ExplanationRequiresEntry {
100        /// Requested document label.
101        document: String,
102        /// Selector naming the non-entry node.
103        selector: String,
104    },
105}
106
107/// One stable qualification offered when a semantic alias is ambiguous.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct SelectorCandidate {
110    /// Canonical structural outline path.
111    pub path: String,
112    /// Stable document-local identity.
113    pub id: String,
114}
115
116impl fmt::Display for ProjectionError {
117    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match self {
119            Self::MissingContent { document } => {
120                write!(formatter, "document '{document}' has no available content")
121            }
122            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
123            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
124            Self::UnknownSelector { document, selector } => write!(
125                formatter,
126                "document '{document}' has no outline node '{selector}'; inspect its entries outline for available selectors and diagnostics"
127            ),
128            Self::SelectorFoundOnlyInText {
129                document,
130                selector,
131                path,
132                title,
133                line,
134            } => write!(
135                formatter,
136                "document '{document}' has no semantic entry '{selector}', but that text appears in outline node {path} ({title}) at line {line}"
137            ),
138            Self::AmbiguousSelector {
139                document,
140                selector,
141                candidates,
142            } => {
143                write!(
144                    formatter,
145                    "document '{document}' has multiple semantic entries named '{selector}': "
146                )?;
147                for (index, candidate) in candidates.iter().enumerate() {
148                    if index > 0 {
149                        formatter.write_str(", ")?;
150                    }
151                    write!(formatter, "{} ({})", candidate.path, candidate.id)?;
152                }
153                formatter.write_str("; select one by path or ID")
154            }
155            Self::ExplanationRequiresEntry { document, selector } => write!(
156                formatter,
157                "document '{document}' outline node '{selector}' is not a semantic entry; select a semantic entry instead"
158            ),
159        }
160    }
161}
162
163impl Error for ProjectionError {}
164
165/// Build a block-free, addressable outline for one complete query.
166///
167/// # Errors
168///
169/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
170/// is available.
171pub fn build_outline(query: &ResolvedContent) -> Result<QueryOutline, ProjectionError> {
172    build_outline_projection(query, EntryProjection::Summary, None)
173}
174
175/// Build an outline with optional semantic definition entries.
176///
177/// # Errors
178///
179/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
180/// is available.
181pub fn build_outline_with_detail(
182    query: &ResolvedContent,
183    detail: OutlineDetail,
184) -> Result<QueryOutline, ProjectionError> {
185    build_outline_projection(query, detail.into(), None)
186}
187
188/// Build a structural outline with an explicit semantic-entry projection.
189///
190/// # Errors
191///
192/// Returns [`ProjectionError::MissingContent`] when no content is available,
193/// or [`ProjectionError::UnknownSelector`] when `root` matches no outline node.
194pub fn build_outline_projection(
195    query: &ResolvedContent,
196    entries: EntryProjection,
197    root: Option<NodeSelector>,
198) -> Result<QueryOutline, ProjectionError> {
199    if query.tldr.is_none() && query.document.is_none() {
200        return Err(ProjectionError::MissingContent {
201            document: query.label.clone(),
202        });
203    }
204    let diagnostics = query
205        .document
206        .as_ref()
207        .map_or_else(Vec::new, |document| document.diagnostics.clone());
208    let entries_complete = diagnostics.iter().all(|diagnostic| {
209        !diagnostic.code.as_deref().is_some_and(|code| {
210            crate::markdown::is_semantic_entry_rejection_code(code)
211                || code == "manual.semantic-entry.unclassified-definition"
212        })
213    });
214    let materialized_entries = if root.is_some() {
215        EntryProjection::All
216    } else {
217        entries.clone()
218    };
219    let mut nodes = Vec::new();
220    if query.tldr.is_some() && !matches!(&materialized_entries, EntryProjection::Kinds { .. }) {
221        nodes.push(OutlineNode::Tldr {
222            path: OutlinePath::Tldr.to_string().into(),
223            id: TLDR_ID.into(),
224            title: TLDR_TITLE.to_owned(),
225        });
226    }
227    if let Some(manual) = &query.document {
228        let index = SemanticIndex::build(manual);
229        if !manual.blocks.is_empty() {
230            let root_entries = index.root();
231            let children = project_entries(root_entries, None, &[], &materialized_entries);
232            let root = OutlineNode::DocumentRoot {
233                path: OutlinePath::DocumentRoot.to_string().into(),
234                id: DOCUMENT_ROOT_ID.into(),
235                title: DOCUMENT_ROOT_TITLE.to_owned(),
236                entry_summary: projected_summary(root_entries, &materialized_entries),
237                children,
238            };
239            if !matches!(&materialized_entries, EntryProjection::Kinds { .. })
240                || !root.children().is_empty()
241            {
242                nodes.push(root);
243            }
244        }
245        nodes.extend(outline_nodes(
246            &manual.sections,
247            &[],
248            &index,
249            &materialized_entries,
250        ));
251    }
252    if let Some(selector) = root.as_ref() {
253        let mut selected = resolve_outline_root(query, &nodes, selector.as_str())?.clone();
254        reproject_selected_node(&mut selected, &entries, true);
255        nodes = vec![selected];
256    }
257    Ok(QueryOutline {
258        schema: OutlineSchema::V0Dot10,
259        entries,
260        root,
261        label: query.label.clone(),
262        source: query
263            .document
264            .as_ref()
265            .map(|document| document.source.clone()),
266        meta: query
267            .document
268            .as_ref()
269            .map(|document| document.meta.clone()),
270        diagnostics,
271        entries_complete,
272        nodes,
273    })
274}
275
276/// Select tldr, document-root content, or complete section subtrees by path or ID.
277///
278/// Duplicate selections and descendants of another selected node are omitted.
279/// The result always follows source order, independent of argument order.
280///
281/// # Errors
282///
283/// Returns an error when no content exists or any selector is empty or unknown.
284pub fn select_excerpt<S: AsRef<str>>(
285    query: &ResolvedContent,
286    selectors: &[S],
287) -> Result<QueryExcerpt, ProjectionError> {
288    if selectors.is_empty() {
289        return Err(ProjectionError::EmptySelection);
290    }
291    if query.tldr.is_none() && query.document.is_none() {
292        return Err(ProjectionError::MissingContent {
293            document: query.label.clone(),
294        });
295    }
296    let mut located = Vec::new();
297    if let Some(manual) = &query.document {
298        collect_root_entries(&manual.blocks, &mut located);
299        collect_sections(&manual.sections, &[], &[], &mut located);
300    }
301
302    let (tldr_selected, document_root_selected, mut selected) =
303        resolve_excerpt_candidates(query, selectors, &located)?;
304    let selected_sections = selected
305        .iter()
306        .filter(|candidate| candidate.is_section())
307        .map(|candidate| candidate.coordinates().to_vec())
308        .collect::<Vec<_>>();
309    selected.retain(|candidate| {
310        if document_root_selected && candidate.path().is_document_root_entry() {
311            return false;
312        }
313        !selected_sections.iter().any(|ancestor| {
314            if candidate.is_section() {
315                ancestor != candidate.coordinates()
316                    && is_ancestor(ancestor, candidate.coordinates())
317            } else {
318                ancestor == candidate.coordinates()
319                    || is_ancestor(ancestor, candidate.coordinates())
320            }
321        })
322    });
323    selected.sort_by_key(|candidate| candidate.order());
324
325    let document = if selected.is_empty() && !document_root_selected {
326        None
327    } else {
328        query.document.as_ref()
329    };
330    let mut selections = Vec::new();
331    if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
332        selections.push(ExcerptSelection::Tldr {
333            outline: OutlineTrail {
334                ancestors: Vec::new(),
335                node: OutlineNodeReference::Tldr {
336                    path: OutlinePath::Tldr.to_string().into(),
337                    id: TLDR_ID.into(),
338                    title: TLDR_TITLE.to_owned(),
339                },
340            },
341            document,
342        });
343    }
344    if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
345        selections.push(ExcerptSelection::DocumentRoot {
346            outline: OutlineTrail {
347                ancestors: Vec::new(),
348                node: OutlineNodeReference::DocumentRoot {
349                    path: OutlinePath::DocumentRoot.to_string().into(),
350                    id: DOCUMENT_ROOT_ID.into(),
351                    title: DOCUMENT_ROOT_TITLE.to_owned(),
352                },
353            },
354            blocks: document.blocks.clone(),
355        });
356    }
357    selections.extend(selected.into_iter().map(LocatedNode::selection));
358
359    Ok(QueryExcerpt {
360        schema: ExcerptSchema::V0Dot10,
361        label: query.label.clone(),
362        producer: document.map(mant_protocol::Producer::for_document),
363        source: document.map(|document| document.source.clone()),
364        meta: document.map(|document| document.meta.clone()),
365        diagnostics: document
366            .map(|document| document.diagnostics.clone())
367            .unwrap_or_default(),
368        selections,
369    })
370}
371
372fn resolve_excerpt_candidates<'a, S: AsRef<str>>(
373    query: &ResolvedContent,
374    selectors: &[S],
375    located: &'a [LocatedNode<'a>],
376) -> Result<(bool, bool, Vec<&'a LocatedNode<'a>>), ProjectionError> {
377    let mut tldr_selected = false;
378    let mut document_root_selected = false;
379    let mut selected_ids = HashSet::new();
380    let mut selected = Vec::new();
381    for raw_selector in selectors {
382        let selector = raw_selector.as_ref().trim();
383        if selector.is_empty() {
384            return Err(ProjectionError::EmptySelector);
385        }
386        if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr))
387            && query.tldr.is_some()
388        {
389            tldr_selected = true;
390            continue;
391        }
392        if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
393            && query
394                .document
395                .as_ref()
396                .is_some_and(|document| !document.blocks.is_empty())
397        {
398            document_root_selected = true;
399            continue;
400        }
401        let candidate = resolve_candidate(query, located, selector)?;
402        if selected_ids.insert(candidate.id()) {
403            selected.push(candidate);
404        }
405    }
406    Ok((tldr_selected, document_root_selected, selected))
407}
408
409/// Select exactly one semantic entry by stable path, ID, or alias.
410///
411/// Exact paths and IDs take precedence over aliases. Repeated aliases are
412/// rejected with deterministic candidates instead of silently choosing the
413/// first entry in source order.
414///
415/// # Errors
416///
417/// Returns an error when the selector is empty, unknown, names a section, or
418/// matches more than one semantic entry.
419pub fn select_explanation(
420    query: &ResolvedContent,
421    selector: &str,
422) -> Result<QueryExcerpt, ProjectionError> {
423    if query.tldr.is_none() && query.document.is_none() {
424        return Err(ProjectionError::MissingContent {
425            document: query.label.clone(),
426        });
427    }
428    let selector = selector.trim();
429    if selector.is_empty() {
430        return Err(ProjectionError::EmptySelector);
431    }
432    let mut located = Vec::new();
433    if let Some(manual) = &query.document {
434        collect_root_entries(&manual.blocks, &mut located);
435        collect_sections(&manual.sections, &[], &[], &mut located);
436    }
437    let candidate = resolve_explanation_candidate(query, &located, selector)?;
438    select_excerpt(query, &[candidate.path().to_string()])
439}
440
441fn resolve_explanation_candidate<'a>(
442    query: &ResolvedContent,
443    located: &'a [LocatedNode<'a>],
444    selector: &str,
445) -> Result<&'a LocatedNode<'a>, ProjectionError> {
446    let selects_tldr =
447        (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some();
448    let selects_root = (selector == DOCUMENT_ROOT_ID
449        || selector.parse() == Ok(OutlinePath::DocumentRoot))
450        && query
451            .document
452            .as_ref()
453            .is_some_and(|document| !document.blocks.is_empty());
454    if selects_tldr || selects_root {
455        return Err(ProjectionError::ExplanationRequiresEntry {
456            document: query.label.clone(),
457            selector: selector.to_owned(),
458        });
459    }
460    let candidate = resolve_candidate(query, located, selector)?;
461    if candidate.is_section() {
462        return Err(ProjectionError::ExplanationRequiresEntry {
463            document: query.label.clone(),
464            selector: selector.to_owned(),
465        });
466    }
467    Ok(candidate)
468}
469
470fn resolve_candidate<'a>(
471    query: &ResolvedContent,
472    located: &'a [LocatedNode<'a>],
473    selector: &str,
474) -> Result<&'a LocatedNode<'a>, ProjectionError> {
475    if let Some(candidate) = located
476        .iter()
477        .find(|candidate| candidate.matches_path(selector))
478    {
479        return Ok(candidate);
480    }
481    let ids = located
482        .iter()
483        .filter(|candidate| candidate.id() == selector)
484        .collect::<Vec<_>>();
485    match ids.as_slice() {
486        [candidate] => return Ok(candidate),
487        [] => {}
488        _ => return Err(ambiguous_selector(query, selector, ids)),
489    }
490
491    let matches = matching_aliases(located, selector).1;
492    match matches.as_slice() {
493        [] => Err(ProjectionError::UnknownSelector {
494            document: query.label.clone(),
495            selector: selector.to_owned(),
496        }),
497        [candidate] => Ok(candidate),
498        _ => Err(ambiguous_selector(query, selector, matches)),
499    }
500}
501
502fn ambiguous_selector(
503    query: &ResolvedContent,
504    selector: &str,
505    matches: Vec<&LocatedNode<'_>>,
506) -> ProjectionError {
507    ProjectionError::AmbiguousSelector {
508        document: query.label.clone(),
509        selector: selector.to_owned(),
510        candidates: matches
511            .into_iter()
512            .map(|candidate| SelectorCandidate {
513                path: candidate.path().to_string(),
514                id: candidate.id().into(),
515            })
516            .collect(),
517    }
518}
519
520fn outline_nodes(
521    sections: &[Section],
522    parent: &[usize],
523    index: &SemanticIndex,
524    entries: &EntryProjection,
525) -> Vec<OutlineNode> {
526    sections
527        .iter()
528        .enumerate()
529        .filter_map(|(section_index, section)| {
530            let mut coordinates = parent.to_vec();
531            coordinates.push(section_index + 1);
532            let path =
533                OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
534            let semantic_entries = index.section(&section.id);
535            let mut children = project_entries(semantic_entries, Some(&coordinates), &[], entries);
536            children.extend(outline_nodes(
537                &section.children,
538                &coordinates,
539                index,
540                entries,
541            ));
542            let node = OutlineNode::DocumentSection {
543                path: path.to_string().into(),
544                id: section.id.clone(),
545                title: section.title.clone(),
546                entry_summary: projected_summary(semantic_entries, entries),
547                children,
548            };
549            (!matches!(entries, EntryProjection::Kinds { .. }) || !node.children().is_empty())
550                .then_some(node)
551        })
552        .collect()
553}
554
555fn projected_summary(
556    entries: &[SemanticEntry],
557    projection: &EntryProjection,
558) -> Option<EntrySummary> {
559    let summary = match projection {
560        EntryProjection::None => return None,
561        EntryProjection::Summary | EntryProjection::All => EntrySummary::for_entries(entries),
562        EntryProjection::Kinds { kinds } => filtered_entry_summary(entries, kinds),
563    };
564    (!summary.is_empty()).then_some(summary)
565}
566
567fn filtered_entry_summary(entries: &[SemanticEntry], kinds: &[mant_ir::EntryKind]) -> EntrySummary {
568    let mut summary = EntrySummary::default();
569    for entry in entries {
570        summarize_filtered_entry(entry, kinds, &mut summary, true);
571    }
572    summary
573}
574
575fn summarize_filtered_entry(
576    entry: &SemanticEntry,
577    kinds: &[mant_ir::EntryKind],
578    summary: &mut EntrySummary,
579    direct: bool,
580) {
581    if kinds.contains(&entry.kind) {
582        record_projected_summary(summary, entry.kind, entry.forms.len(), direct);
583    }
584    for child in &entry.children {
585        summarize_filtered_entry(child, kinds, summary, false);
586    }
587}
588
589fn record_projected_summary(
590    summary: &mut EntrySummary,
591    kind: mant_ir::EntryKind,
592    forms: usize,
593    direct: bool,
594) {
595    if direct {
596        summary.direct = summary.direct.saturating_add(1);
597    } else {
598        summary.descendants = summary.descendants.saturating_add(1);
599    }
600    summary.forms = summary
601        .forms
602        .saturating_add(u32::try_from(forms).unwrap_or(u32::MAX));
603    if let Some(count) = summary.by_kind.iter_mut().find(|count| count.kind == kind) {
604        count.count = count.count.saturating_add(1);
605    } else {
606        summary.by_kind.push(EntryKindCount { kind, count: 1 });
607        summary.by_kind.sort_by_key(|count| count.kind);
608    }
609}
610
611fn project_entries(
612    entries: &[SemanticEntry],
613    section: Option<&[usize]>,
614    parent: &[usize],
615    projection: &EntryProjection,
616) -> Vec<OutlineNode> {
617    if matches!(projection, EntryProjection::None | EntryProjection::Summary) {
618        return Vec::new();
619    }
620    entries
621        .iter()
622        .enumerate()
623        .filter_map(|(index, entry)| {
624            let mut coordinates = parent.to_vec();
625            coordinates.push(index + 1);
626            let children = project_entries(&entry.children, section, &coordinates, projection);
627            let selected = match projection {
628                EntryProjection::All => true,
629                EntryProjection::Kinds { kinds } => kinds.contains(&entry.kind),
630                EntryProjection::None | EntryProjection::Summary => false,
631            };
632            if !selected && children.is_empty() {
633                return None;
634            }
635            let title = (!entry.forms.is_empty())
636                .then(|| entry.forms.join(" | "))
637                .or_else(|| entry.aliases.first().cloned())
638                .unwrap_or_else(|| entry.id.to_string());
639            Some(OutlineNode::DocumentEntry {
640                path: OutlinePath::nested_entry(section, &coordinates)?
641                    .to_string()
642                    .into(),
643                id: entry.id.clone(),
644                title,
645                entry_kind: entry.kind,
646                case: entry.case,
647                aliases: entry.aliases.clone(),
648                forms: entry.forms.clone(),
649                targets: entry.targets.clone(),
650                value_domain: entry.value_domain.clone(),
651                entry_summary: projected_summary(&entry.children, projection),
652                children,
653            })
654        })
655        .collect()
656}
657
658fn find_outline_node<'a>(
659    nodes: &'a [OutlineNode],
660    predicate: &impl Fn(&OutlineNode) -> bool,
661) -> Option<&'a OutlineNode> {
662    for node in nodes {
663        if predicate(node) {
664            return Some(node);
665        }
666        if let Some(found) = find_outline_node(node.children(), predicate) {
667            return Some(found);
668        }
669    }
670    None
671}
672
673fn resolve_outline_root<'a>(
674    query: &ResolvedContent,
675    nodes: &'a [OutlineNode],
676    selector: &str,
677) -> Result<&'a OutlineNode, ProjectionError> {
678    if (selector == TLDR_ID || selector.parse() == Ok(OutlinePath::Tldr)) && query.tldr.is_some() {
679        return find_outline_node(nodes, &|node| node.path() == OutlinePath::Tldr.to_string())
680            .ok_or_else(|| ProjectionError::UnknownSelector {
681                document: query.label.clone(),
682                selector: selector.to_owned(),
683            });
684    }
685    if (selector == DOCUMENT_ROOT_ID || selector.parse() == Ok(OutlinePath::DocumentRoot))
686        && query
687            .document
688            .as_ref()
689            .is_some_and(|document| !document.blocks.is_empty())
690    {
691        return find_outline_node(nodes, &|node| {
692            node.path() == OutlinePath::DocumentRoot.to_string()
693        })
694        .ok_or_else(|| ProjectionError::UnknownSelector {
695            document: query.label.clone(),
696            selector: selector.to_owned(),
697        });
698    }
699
700    let mut located = Vec::new();
701    if let Some(manual) = &query.document {
702        collect_root_entries(&manual.blocks, &mut located);
703        collect_sections(&manual.sections, &[], &[], &mut located);
704    }
705    let path = resolve_candidate(query, &located, selector)?
706        .path()
707        .to_string();
708    find_outline_node(nodes, &|node| node.path() == path).ok_or_else(|| {
709        ProjectionError::UnknownSelector {
710            document: query.label.clone(),
711            selector: selector.to_owned(),
712        }
713    })
714}
715
716fn reproject_selected_node(
717    node: &mut OutlineNode,
718    projection: &EntryProjection,
719    keep_self: bool,
720) -> bool {
721    match node {
722        OutlineNode::Tldr { .. } => true,
723        OutlineNode::DocumentRoot {
724            entry_summary,
725            children,
726            ..
727        }
728        | OutlineNode::DocumentSection {
729            entry_summary,
730            children,
731            ..
732        } => {
733            if matches!(projection, EntryProjection::None) {
734                *entry_summary = None;
735            }
736            children.retain_mut(|child| reproject_selected_node(child, projection, false));
737            if let EntryProjection::Kinds { kinds } = projection {
738                *entry_summary = projected_outline_summary(children, kinds);
739            }
740            true
741        }
742        OutlineNode::DocumentEntry {
743            entry_kind,
744            entry_summary,
745            children,
746            ..
747        } => {
748            if matches!(projection, EntryProjection::None) {
749                *entry_summary = None;
750            }
751            if matches!(projection, EntryProjection::None | EntryProjection::Summary) {
752                children.clear();
753            } else {
754                children.retain_mut(|child| reproject_selected_node(child, projection, false));
755            }
756            if let EntryProjection::Kinds { kinds } = projection {
757                *entry_summary = projected_outline_summary(children, kinds);
758            }
759            keep_self
760                || match projection {
761                    EntryProjection::All => true,
762                    EntryProjection::Kinds { kinds } => {
763                        kinds.contains(entry_kind) || !children.is_empty()
764                    }
765                    EntryProjection::None | EntryProjection::Summary => false,
766                }
767        }
768    }
769}
770
771fn projected_outline_summary(
772    nodes: &[OutlineNode],
773    kinds: &[mant_ir::EntryKind],
774) -> Option<EntrySummary> {
775    fn visit(
776        node: &OutlineNode,
777        kinds: &[mant_ir::EntryKind],
778        summary: &mut EntrySummary,
779        direct: bool,
780    ) {
781        let OutlineNode::DocumentEntry {
782            entry_kind,
783            forms,
784            children,
785            ..
786        } = node
787        else {
788            return;
789        };
790        if kinds.contains(entry_kind) {
791            record_projected_summary(summary, *entry_kind, forms.len(), direct);
792        }
793        for child in children {
794            visit(child, kinds, summary, false);
795        }
796    }
797
798    let mut summary = EntrySummary::default();
799    for node in nodes {
800        visit(node, kinds, &mut summary, true);
801    }
802    (!summary.is_empty()).then_some(summary)
803}
804
805enum LocatedNode<'a> {
806    Section {
807        order: usize,
808        coordinates: Vec<usize>,
809        path: OutlinePath,
810        breadcrumbs: Vec<OutlineReference>,
811        section: &'a Section,
812    },
813    Entry {
814        order: usize,
815        coordinates: Vec<usize>,
816        path: OutlinePath,
817        title: String,
818        breadcrumbs: Vec<OutlineReference>,
819        entry: &'a DefinitionItem,
820        source: Option<SourceSpan>,
821    },
822}
823
824impl LocatedNode<'_> {
825    fn order(&self) -> usize {
826        match self {
827            Self::Section { order, .. } | Self::Entry { order, .. } => *order,
828        }
829    }
830
831    fn coordinates(&self) -> &[usize] {
832        match self {
833            Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
834        }
835    }
836
837    fn path(&self) -> &OutlinePath {
838        match self {
839            Self::Section { path, .. } | Self::Entry { path, .. } => path,
840        }
841    }
842
843    fn matches_path(&self, selector: &str) -> bool {
844        selector
845            .parse::<OutlinePath>()
846            .is_ok_and(|path| path == *self.path())
847    }
848
849    fn id(&self) -> &str {
850        match self {
851            Self::Section { section, .. } => &section.id,
852            Self::Entry { entry, .. } => {
853                &entry
854                    .identity
855                    .as_ref()
856                    .expect("located entries have identities")
857                    .id
858            }
859        }
860    }
861
862    fn matches_exact_alias(&self, selector: &str) -> bool {
863        match self {
864            Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
865                identity
866                    .names
867                    .iter()
868                    .any(|name| semantic_name_equivalent(identity.case, name, selector))
869            }),
870            Self::Section { .. } => false,
871        }
872    }
873
874    fn matches_shorthand_alias(&self, selector: &str) -> bool {
875        match self {
876            Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
877                identity.names.iter().any(|name| {
878                    semantic_name_shorthand(identity.role, name).is_some_and(|shorthand| {
879                        semantic_name_equivalent(identity.case, shorthand, selector)
880                    })
881                })
882            }),
883            Self::Section { .. } => false,
884        }
885    }
886
887    fn identity(&self) -> Option<&DefinitionIdentity> {
888        match self {
889            Self::Entry { entry, .. } => entry.identity.as_ref(),
890            Self::Section { .. } => None,
891        }
892    }
893
894    fn source(&self) -> Option<SourceSpan> {
895        match self {
896            Self::Entry { source, .. } => *source,
897            Self::Section { section, .. } => section.source,
898        }
899    }
900
901    const fn is_section(&self) -> bool {
902        matches!(self, Self::Section { .. })
903    }
904
905    fn selection(&self) -> ExcerptSelection {
906        match self {
907            Self::Section {
908                path,
909                breadcrumbs,
910                section,
911                ..
912            } => ExcerptSelection::DocumentSection {
913                outline: OutlineTrail {
914                    ancestors: breadcrumbs.clone(),
915                    node: OutlineNodeReference::DocumentSection {
916                        path: path.to_string().into(),
917                        id: section.id.clone(),
918                        title: section.title.clone(),
919                    },
920                },
921                section: (*section).clone(),
922            },
923            Self::Entry {
924                path,
925                title,
926                breadcrumbs,
927                entry,
928                ..
929            } => ExcerptSelection::DocumentEntry {
930                outline: OutlineTrail {
931                    ancestors: breadcrumbs.clone(),
932                    node: {
933                        let identity = entry
934                            .identity
935                            .as_ref()
936                            .expect("located entries have identities");
937                        OutlineNodeReference::DocumentEntry {
938                            path: path.to_string().into(),
939                            id: identity.id.clone(),
940                            title: title.clone(),
941                            role: identity.role,
942                            case: identity.case,
943                            names: identity.names.clone(),
944                        }
945                    },
946                },
947                entry: (*entry).clone(),
948            },
949        }
950    }
951}
952
953fn semantic_name_equivalent(case: DefinitionCase, left: &str, right: &str) -> bool {
954    match case {
955        DefinitionCase::Sensitive => left == right,
956        DefinitionCase::Insensitive => left.eq_ignore_ascii_case(right),
957    }
958}
959
960fn semantic_name_shorthand(role: DefinitionRole, name: &str) -> Option<&str> {
961    match role {
962        DefinitionRole::Option => {
963            let shorthand = name.trim_start_matches('-');
964            (shorthand != name && !shorthand.is_empty()).then_some(shorthand)
965        }
966        DefinitionRole::EnvironmentVariable => {
967            environment_variable_body(name).filter(|body| *body != name)
968        }
969        DefinitionRole::Command
970        | DefinitionRole::ConfigurationKey
971        | DefinitionRole::Marker
972        | DefinitionRole::Operand
973        | DefinitionRole::Variable
974        | DefinitionRole::Value
975        | DefinitionRole::Term => None,
976    }
977}
978
979#[derive(Clone, Copy)]
980enum AliasMatchKind {
981    Exact,
982    Shorthand,
983}
984
985impl AliasMatchKind {
986    const fn label(self) -> &'static str {
987        match self {
988            Self::Exact => "exact alias",
989            Self::Shorthand => "normalized shorthand",
990        }
991    }
992}
993
994fn matching_aliases<'a>(
995    located: &'a [LocatedNode<'a>],
996    selector: &str,
997) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
998    let exact = located
999        .iter()
1000        .filter(|candidate| candidate.matches_exact_alias(selector))
1001        .collect::<Vec<_>>();
1002    if !exact.is_empty() {
1003        return (AliasMatchKind::Exact, exact);
1004    }
1005    (
1006        AliasMatchKind::Shorthand,
1007        located
1008            .iter()
1009            .filter(|candidate| candidate.matches_shorthand_alias(selector))
1010            .collect(),
1011    )
1012}
1013
1014/// Report selectors that cannot address exactly one semantic entry.
1015///
1016/// The lookup policy itself remains usable through stable paths and IDs, but
1017/// Markdown authors receive a source diagnostic before an agent discovers the
1018/// ambiguity at query time.
1019pub(crate) fn semantic_selector_diagnostics(
1020    blocks: &[Block],
1021    sections: &[Section],
1022    source_family: &str,
1023) -> Vec<Diagnostic> {
1024    let mut located = Vec::new();
1025    collect_root_entries(blocks, &mut located);
1026    collect_sections(sections, &[], &[], &mut located);
1027    let index = SelectorDiagnosticsIndex::new(&located);
1028    let mut selectors = BTreeSet::new();
1029    for candidate in &located {
1030        let Some(identity) = candidate.identity() else {
1031            continue;
1032        };
1033        for name in &identity.names {
1034            selectors.insert(name.clone());
1035            if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
1036                selectors.insert(shorthand.to_owned());
1037            }
1038        }
1039    }
1040
1041    let mut diagnostics = selector_alias_diagnostics(&index, selectors, source_family);
1042    diagnostics.extend(duplicate_id_diagnostics(&index.ids, source_family));
1043    diagnostics
1044}
1045
1046#[derive(Default)]
1047struct AliasIndex<'a> {
1048    sensitive: HashMap<&'a str, Vec<&'a LocatedNode<'a>>>,
1049    insensitive: HashMap<String, Vec<&'a LocatedNode<'a>>>,
1050}
1051
1052impl<'a> AliasIndex<'a> {
1053    fn insert(&mut self, case: DefinitionCase, alias: &'a str, candidate: &'a LocatedNode<'a>) {
1054        let bucket = match case {
1055            DefinitionCase::Sensitive => self.sensitive.entry(alias).or_default(),
1056            DefinitionCase::Insensitive => self
1057                .insensitive
1058                .entry(alias.to_ascii_lowercase())
1059                .or_default(),
1060        };
1061        if bucket
1062            .last()
1063            .is_none_or(|existing| existing.order() != candidate.order())
1064        {
1065            bucket.push(candidate);
1066        }
1067    }
1068
1069    fn matches(&self, selector: &str) -> Vec<&'a LocatedNode<'a>> {
1070        let mut matches = self.sensitive.get(selector).cloned().unwrap_or_default();
1071        if let Some(insensitive) = self.insensitive.get(&selector.to_ascii_lowercase()) {
1072            matches.extend(insensitive.iter().copied());
1073        }
1074        matches.sort_unstable_by_key(|candidate| candidate.order());
1075        matches.dedup_by_key(|candidate| candidate.order());
1076        matches
1077    }
1078}
1079
1080struct SelectorDiagnosticsIndex<'a> {
1081    exact_aliases: AliasIndex<'a>,
1082    shorthand_aliases: AliasIndex<'a>,
1083    ids: BTreeMap<&'a str, Vec<&'a LocatedNode<'a>>>,
1084}
1085
1086impl<'a> SelectorDiagnosticsIndex<'a> {
1087    fn new(located: &'a [LocatedNode<'a>]) -> Self {
1088        let mut index = Self {
1089            exact_aliases: AliasIndex::default(),
1090            shorthand_aliases: AliasIndex::default(),
1091            ids: BTreeMap::new(),
1092        };
1093        for candidate in located {
1094            index.ids.entry(candidate.id()).or_default().push(candidate);
1095            let Some(identity) = candidate.identity() else {
1096                continue;
1097            };
1098            for name in &identity.names {
1099                index.exact_aliases.insert(identity.case, name, candidate);
1100                if let Some(shorthand) = semantic_name_shorthand(identity.role, name) {
1101                    index
1102                        .shorthand_aliases
1103                        .insert(identity.case, shorthand, candidate);
1104                }
1105            }
1106        }
1107        index
1108    }
1109
1110    fn matching_aliases(&self, selector: &str) -> (AliasMatchKind, Vec<&'a LocatedNode<'a>>) {
1111        let exact = self.exact_aliases.matches(selector);
1112        if !exact.is_empty() {
1113            return (AliasMatchKind::Exact, exact);
1114        }
1115        (
1116            AliasMatchKind::Shorthand,
1117            self.shorthand_aliases.matches(selector),
1118        )
1119    }
1120}
1121
1122fn selector_alias_diagnostics(
1123    index: &SelectorDiagnosticsIndex<'_>,
1124    selectors: BTreeSet<String>,
1125    source_family: &str,
1126) -> Vec<Diagnostic> {
1127    let mut reported = HashSet::new();
1128    let mut diagnostics = Vec::new();
1129    for selector in selectors {
1130        let (kind, matches) = index.matching_aliases(&selector);
1131        let exact_ids = index
1132            .ids
1133            .get(selector.as_str())
1134            .map_or(&[][..], Vec::as_slice);
1135        let shadowed_matches = matches
1136            .iter()
1137            .copied()
1138            .filter(|candidate| {
1139                !exact_ids
1140                    .iter()
1141                    .any(|owner| owner.path() == candidate.path())
1142            })
1143            .collect::<Vec<_>>();
1144        if !shadowed_matches.is_empty() && !exact_ids.is_empty() {
1145            let key = format!("shadowed\u{1f}{selector}");
1146            if reported.insert(key) {
1147                let owners = exact_ids
1148                    .iter()
1149                    .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1150                    .collect::<Vec<_>>()
1151                    .join(", ");
1152                let entries = shadowed_matches
1153                    .iter()
1154                    .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1155                    .collect::<Vec<_>>()
1156                    .join(", ");
1157                diagnostics.push(Diagnostic {
1158                    level: DiagnosticLevel::Warning,
1159                    code: Some(format!(
1160                        "{source_family}.semantic-entry.shadowed-selector"
1161                    )),
1162                    message: format!(
1163                        "semantic selector '{selector}' is owned by exact outline ID {owners}; matching {} entries {entries} require their path or ID",
1164                        kind.label()
1165                    ),
1166                    source: shadowed_matches
1167                        .first()
1168                        .and_then(|candidate| candidate.source()),
1169                });
1170            }
1171        }
1172        if matches.len() < 2 {
1173            continue;
1174        }
1175        let key = matches
1176            .iter()
1177            .map(|candidate| candidate.id())
1178            .collect::<Vec<_>>()
1179            .join("\u{1f}");
1180        if !reported.insert(key) {
1181            continue;
1182        }
1183        let candidates = matches
1184            .iter()
1185            .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1186            .collect::<Vec<_>>()
1187            .join(", ");
1188        diagnostics.push(Diagnostic {
1189            level: DiagnosticLevel::Warning,
1190            code: Some(format!(
1191                "{source_family}.semantic-entry.ambiguous-selector"
1192            )),
1193            message: format!(
1194                "semantic selector '{selector}' has multiple {} matches: {candidates}; select by path or ID",
1195                kind.label()
1196            ),
1197            source: matches.first().and_then(|candidate| candidate.source()),
1198        });
1199    }
1200    diagnostics
1201}
1202
1203fn duplicate_id_diagnostics(
1204    ids: &BTreeMap<&str, Vec<&LocatedNode<'_>>>,
1205    source_family: &str,
1206) -> Vec<Diagnostic> {
1207    let mut diagnostics = Vec::new();
1208    for (id, matches) in ids {
1209        if matches.len() < 2 {
1210            continue;
1211        }
1212        let candidates = matches
1213            .iter()
1214            .map(|candidate| format!("{} ({})", candidate.path(), candidate.id()))
1215            .collect::<Vec<_>>()
1216            .join(", ");
1217        diagnostics.push(Diagnostic {
1218            level: DiagnosticLevel::Warning,
1219            code: Some(format!("{source_family}.outline.duplicate-id")),
1220            message: format!(
1221                "outline ID '{id}' belongs to multiple nodes: {candidates}; select by path"
1222            ),
1223            source: matches.first().and_then(|candidate| candidate.source()),
1224        });
1225    }
1226    diagnostics
1227}
1228
1229fn collect_sections<'a>(
1230    sections: &'a [Section],
1231    parent_coordinates: &[usize],
1232    breadcrumbs: &[OutlineReference],
1233    output: &mut Vec<LocatedNode<'a>>,
1234) {
1235    for (index, section) in sections.iter().enumerate() {
1236        let mut coordinates = parent_coordinates.to_vec();
1237        coordinates.push(index + 1);
1238        let path =
1239            OutlinePath::section(&coordinates).expect("enumerated section paths are one-based");
1240        let order = output.len();
1241        output.push(LocatedNode::Section {
1242            order,
1243            coordinates: coordinates.clone(),
1244            path: path.clone(),
1245            breadcrumbs: breadcrumbs.to_vec(),
1246            section,
1247        });
1248        let mut child_breadcrumbs = breadcrumbs.to_vec();
1249        child_breadcrumbs.push(OutlineReference {
1250            path: path.to_string().into(),
1251            id: section.id.clone(),
1252            title: section.title.clone(),
1253        });
1254        for located in definition_entries(&section.blocks) {
1255            let entry = located.item;
1256            let mut entry_breadcrumbs = child_breadcrumbs.clone();
1257            append_entry_breadcrumbs(
1258                &mut entry_breadcrumbs,
1259                Some(&coordinates),
1260                &located.indices,
1261                &located.ancestors,
1262            );
1263            output.push(LocatedNode::Entry {
1264                order: output.len(),
1265                coordinates: coordinates.clone(),
1266                path: OutlinePath::nested_entry(Some(&coordinates), &located.indices)
1267                    .expect("enumerated entry paths are one-based"),
1268                title: definition_title(entry),
1269                breadcrumbs: entry_breadcrumbs,
1270                entry,
1271                source: located.source,
1272            });
1273        }
1274        collect_sections(&section.children, &coordinates, &child_breadcrumbs, output);
1275    }
1276}
1277
1278fn collect_root_entries<'a>(blocks: &'a [Block], output: &mut Vec<LocatedNode<'a>>) {
1279    let breadcrumbs = vec![OutlineReference {
1280        path: OutlinePath::DocumentRoot.to_string().into(),
1281        id: DOCUMENT_ROOT_ID.into(),
1282        title: DOCUMENT_ROOT_TITLE.to_owned(),
1283    }];
1284    for located in definition_entries(blocks) {
1285        let entry = located.item;
1286        let mut entry_breadcrumbs = breadcrumbs.clone();
1287        append_entry_breadcrumbs(
1288            &mut entry_breadcrumbs,
1289            None,
1290            &located.indices,
1291            &located.ancestors,
1292        );
1293        output.push(LocatedNode::Entry {
1294            order: output.len(),
1295            coordinates: Vec::new(),
1296            path: OutlinePath::nested_entry(None, &located.indices)
1297                .expect("enumerated entry paths are one-based"),
1298            title: definition_title(entry),
1299            breadcrumbs: entry_breadcrumbs,
1300            entry,
1301            source: located.source,
1302        });
1303    }
1304}
1305
1306fn append_entry_breadcrumbs(
1307    breadcrumbs: &mut Vec<OutlineReference>,
1308    section: Option<&[usize]>,
1309    indices: &[usize],
1310    ancestors: &[&DefinitionItem],
1311) {
1312    for (depth, ancestor) in ancestors.iter().enumerate() {
1313        let path = OutlinePath::nested_entry(section, &indices[..=depth])
1314            .expect("ancestor entry paths are one-based");
1315        let identity = ancestor
1316            .identity
1317            .as_ref()
1318            .expect("semantic entry ancestors have identities");
1319        breadcrumbs.push(OutlineReference {
1320            path: path.to_string().into(),
1321            id: identity.id.clone(),
1322            title: definition_title(ancestor),
1323        });
1324    }
1325}
1326
1327fn definition_title(entry: &DefinitionItem) -> String {
1328    let identity = entry
1329        .identity
1330        .as_ref()
1331        .expect("semantic entries have identities");
1332    if !identity.names.is_empty() {
1333        return identity.names.join(", ");
1334    }
1335    let forms = entry
1336        .terms
1337        .iter()
1338        .map(|term| plain_text(term))
1339        .filter(|form| !form.is_empty())
1340        .collect::<Vec<_>>();
1341    if !forms.is_empty() {
1342        return forms.join(" | ");
1343    }
1344    identity.id.to_string()
1345}
1346
1347fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
1348    ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353    use crate::ResolvedContent;
1354    use mant_ir::{
1355        Block, DefinitionCase, DefinitionIdentity, DefinitionItem, DefinitionRole, Diagnostic,
1356        DiagnosticLevel, Document, DocumentMeta, DocumentSource, EntryKind, Inline, LayoutHint,
1357        ParameterKind, Section, SourceFormat, TldrDocument, TldrOrigin,
1358    };
1359    use mant_protocol::{EntryProjection, ExcerptSelection, NodeSelector, OutlineNode};
1360
1361    use super::{
1362        ProjectionError, build_outline, build_outline_projection, select_excerpt,
1363        semantic_selector_diagnostics,
1364    };
1365
1366    fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
1367        Section {
1368            id: id.to_owned().into(),
1369            title: title.to_owned(),
1370            spacing_before_lines: 0,
1371            blocks: Vec::new(),
1372            children,
1373            source: None,
1374        }
1375    }
1376
1377    fn query() -> ResolvedContent {
1378        ResolvedContent {
1379            address: None,
1380            label: "demo".to_owned(),
1381            document: Some(Document {
1382                parser: None,
1383                source: DocumentSource {
1384                    format: SourceFormat::Man,
1385                    path: Some("/man/demo.1".to_owned()),
1386                },
1387                meta: DocumentMeta {
1388                    manual_section: Some("1".to_owned()),
1389                    ..DocumentMeta::default()
1390                },
1391                diagnostics: Vec::new(),
1392                blocks: Vec::new(),
1393                sections: vec![
1394                    section("name-1", "NAME", Vec::new()),
1395                    section(
1396                        "options-2",
1397                        "OPTIONS",
1398                        vec![
1399                            section("common-3", "Common options", Vec::new()),
1400                            section("other-4", "Other options", Vec::new()),
1401                        ],
1402                    ),
1403                    section("files-5", "FILES", Vec::new()),
1404                ],
1405            }),
1406            tldr: None,
1407        }
1408    }
1409
1410    fn tldr() -> TldrDocument {
1411        TldrDocument {
1412            title: "demo".to_owned(),
1413            description: vec!["A small demonstration.".to_owned()],
1414            more_information: Some("https://example.com/demo".to_owned()),
1415            examples: Vec::new(),
1416            platform: "common".to_owned(),
1417            language: "en".to_owned(),
1418            source_path: "/tldr/pages/common/demo.md".to_owned(),
1419            origin: TldrOrigin::TldrPages,
1420        }
1421    }
1422
1423    fn definition(
1424        id: &str,
1425        role: DefinitionRole,
1426        aliases: &[&str],
1427        forms: &[&str],
1428        description: Vec<Block>,
1429    ) -> DefinitionItem {
1430        DefinitionItem {
1431            identity: Some(DefinitionIdentity {
1432                id: id.into(),
1433                role,
1434                case: DefinitionCase::Sensitive,
1435                names: aliases.iter().map(|alias| (*alias).to_owned()).collect(),
1436            }),
1437            terms: forms
1438                .iter()
1439                .map(|form| {
1440                    vec![Inline::Code {
1441                        value: (*form).to_owned(),
1442                    }]
1443                })
1444                .collect(),
1445            description,
1446            inline_term: false,
1447            spacing_before_lines: None,
1448        }
1449    }
1450
1451    fn query_with_semantic_entries() -> ResolvedContent {
1452        let value = definition(
1453            "value-yes",
1454            DefinitionRole::Value,
1455            &["yes"],
1456            &["yes"],
1457            Vec::new(),
1458        );
1459        let local_forward = definition(
1460            "option-local-forward",
1461            DefinitionRole::Option,
1462            &["-L"],
1463            &["-L port:host:hostport", "-L socket:remote_socket"],
1464            vec![Block::DefinitionList {
1465                items: vec![value],
1466                compact: true,
1467                layout: LayoutHint::default(),
1468                source: None,
1469            }],
1470        );
1471        let marker = definition(
1472            "marker-end-options",
1473            DefinitionRole::Marker,
1474            &["--"],
1475            &["--"],
1476            Vec::new(),
1477        );
1478        let mut query = query();
1479        query.document.as_mut().expect("document").sections[1]
1480            .blocks
1481            .push(Block::DefinitionList {
1482                items: vec![local_forward, marker],
1483                compact: true,
1484                layout: LayoutHint::default(),
1485                source: None,
1486            });
1487        query
1488    }
1489
1490    #[test]
1491    fn indexed_selector_diagnostics_preserve_case_policy_and_deduplicate_aliases() {
1492        let sensitive = definition(
1493            "command-sensitive-mode",
1494            DefinitionRole::Command,
1495            &["Mode"],
1496            &["Mode"],
1497            Vec::new(),
1498        );
1499        let mut insensitive = definition(
1500            "command-insensitive-mode",
1501            DefinitionRole::Command,
1502            &["MODE", "mode"],
1503            &["MODE"],
1504            Vec::new(),
1505        );
1506        insensitive.identity.as_mut().expect("identity").case = DefinitionCase::Insensitive;
1507        let blocks = vec![Block::DefinitionList {
1508            items: vec![sensitive, insensitive],
1509            compact: true,
1510            layout: LayoutHint::default(),
1511            source: None,
1512        }];
1513        let sections = vec![section("mode", "Mode", Vec::new())];
1514
1515        let diagnostics = semantic_selector_diagnostics(&blocks, &sections, "manual");
1516        assert_eq!(
1517            diagnostics
1518                .iter()
1519                .filter(|diagnostic| {
1520                    diagnostic.code.as_deref() == Some("manual.semantic-entry.ambiguous-selector")
1521                })
1522                .count(),
1523            1
1524        );
1525        assert!(diagnostics.iter().any(|diagnostic| {
1526            diagnostic.code.as_deref() == Some("manual.semantic-entry.shadowed-selector")
1527                && diagnostic.message.contains("semantic selector 'mode'")
1528                && diagnostic
1529                    .message
1530                    .matches("command-insensitive-mode")
1531                    .count()
1532                    == 1
1533        }));
1534    }
1535
1536    #[test]
1537    fn builds_one_based_tree_paths_without_copying_blocks() {
1538        let outline = build_outline(&query()).expect("outline");
1539
1540        assert_eq!(
1541            outline
1542                .meta
1543                .as_ref()
1544                .and_then(|meta| meta.manual_section.as_deref()),
1545            Some("1")
1546        );
1547        assert_eq!(outline.nodes[1].path(), "2");
1548        assert_eq!(outline.nodes[1].id(), "options-2");
1549        assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
1550        assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
1551    }
1552
1553    #[test]
1554    fn default_outline_summarizes_entries_without_materializing_them() {
1555        let outline = build_outline(&query_with_semantic_entries()).expect("summary outline");
1556        let OutlineNode::DocumentSection {
1557            entry_summary,
1558            children,
1559            ..
1560        } = &outline.nodes[1]
1561        else {
1562            panic!("expected options section");
1563        };
1564        let summary = entry_summary.as_ref().expect("non-empty entry summary");
1565        assert_eq!(
1566            (summary.direct, summary.descendants, summary.forms),
1567            (2, 1, 4)
1568        );
1569        assert!(
1570            children
1571                .iter()
1572                .all(|child| !matches!(child, OutlineNode::DocumentEntry { .. }))
1573        );
1574        assert!(matches!(
1575            &outline.nodes[0],
1576            OutlineNode::DocumentSection {
1577                entry_summary: None,
1578                ..
1579            }
1580        ));
1581    }
1582
1583    #[test]
1584    fn full_and_filtered_outlines_preserve_forms_nesting_and_paths() {
1585        let query = query_with_semantic_entries();
1586        let full = build_outline_projection(&query, EntryProjection::All, None)
1587            .expect("full semantic outline");
1588        let OutlineNode::DocumentEntry {
1589            path,
1590            title,
1591            forms,
1592            children,
1593            ..
1594        } = &full.nodes[1].children()[0]
1595        else {
1596            panic!("expected option entry");
1597        };
1598        assert_eq!(path.as_str(), "2/e1");
1599        assert_eq!(title, "-L port:host:hostport | -L socket:remote_socket");
1600        assert_eq!(forms.len(), 2);
1601        assert_eq!(children[0].path(), "2/e1/e1");
1602
1603        let filtered = build_outline_projection(
1604            &query,
1605            EntryProjection::Kinds {
1606                kinds: vec![EntryKind::Value],
1607            },
1608            None,
1609        )
1610        .expect("value outline");
1611        assert_eq!(filtered.nodes.len(), 1);
1612        let option_section = filtered
1613            .nodes
1614            .iter()
1615            .find(|node| node.id() == "options-2")
1616            .expect("filtered ancestor section");
1617        let OutlineNode::DocumentSection {
1618            entry_summary: Some(summary),
1619            ..
1620        } = option_section
1621        else {
1622            panic!("filtered value summary");
1623        };
1624        assert_eq!((summary.direct, summary.descendants), (0, 1));
1625        assert_eq!(summary.by_kind.len(), 1);
1626        assert_eq!(summary.by_kind[0].kind, EntryKind::Value);
1627        let option = &option_section.children()[0];
1628        assert!(matches!(
1629            option,
1630            OutlineNode::DocumentEntry {
1631                entry_kind: EntryKind::Parameter {
1632                    parameter_kind: ParameterKind::Option
1633                },
1634                ..
1635            }
1636        ));
1637        assert!(matches!(
1638            option.children(),
1639            [OutlineNode::DocumentEntry {
1640                entry_kind: EntryKind::Value,
1641                ..
1642            }]
1643        ));
1644    }
1645
1646    #[test]
1647    fn kind_filter_with_no_matches_returns_an_explicitly_empty_projection() {
1648        let outline = build_outline_projection(
1649            &query_with_semantic_entries(),
1650            EntryProjection::Kinds {
1651                kinds: vec![EntryKind::EnvironmentVariable],
1652            },
1653            None,
1654        )
1655        .expect("empty environment projection");
1656
1657        assert!(outline.nodes.is_empty());
1658    }
1659
1660    #[test]
1661    fn every_projected_entry_path_round_trips_through_read_and_explain() {
1662        fn collect_entry_paths(nodes: &[OutlineNode], output: &mut Vec<String>) {
1663            for node in nodes {
1664                if matches!(node, OutlineNode::DocumentEntry { .. }) {
1665                    output.push(node.path().to_owned());
1666                }
1667                collect_entry_paths(node.children(), output);
1668            }
1669        }
1670
1671        let mut query = query_with_semantic_entries();
1672        query.document.as_mut().expect("document").sections[1].children[0]
1673            .blocks
1674            .push(Block::DefinitionList {
1675                items: vec![definition(
1676                    "generic-readline-term",
1677                    DefinitionRole::Term,
1678                    &[],
1679                    &["operate-and-get-next (C-o)"],
1680                    vec![Block::Paragraph {
1681                        children: vec![Inline::Text {
1682                            value: "Accept the current line and fetch the next history entry."
1683                                .to_owned(),
1684                        }],
1685                        layout: LayoutHint::default(),
1686                        source: None,
1687                    }],
1688                )],
1689                compact: true,
1690                layout: LayoutHint::default(),
1691                source: None,
1692            });
1693
1694        let outline = build_outline_projection(&query, EntryProjection::All, None)
1695            .expect("complete semantic outline");
1696        let mut paths = Vec::new();
1697        collect_entry_paths(&outline.nodes, &mut paths);
1698        assert_eq!(paths, ["2/e1", "2/e1/e1", "2/e2", "2.1/e1"]);
1699
1700        for path in paths {
1701            let excerpt = select_excerpt(&query, std::slice::from_ref(&path))
1702                .unwrap_or_else(|error| panic!("read must accept projected path {path}: {error}"));
1703            assert!(matches!(
1704                excerpt.selections.as_slice(),
1705                [ExcerptSelection::DocumentEntry { outline, .. }] if outline.path() == path
1706            ));
1707            let explanation = super::select_explanation(&query, &path).unwrap_or_else(|error| {
1708                panic!("explain must accept projected path {path}: {error}")
1709            });
1710            assert!(matches!(
1711                explanation.selections.as_slice(),
1712                [ExcerptSelection::DocumentEntry { outline, .. }] if outline.path() == path
1713            ));
1714        }
1715    }
1716
1717    #[test]
1718    fn outline_root_preserves_identity_excludes_siblings_and_rejects_ambiguous_aliases() {
1719        let mut query = query_with_semantic_entries();
1720        let section_rooted = build_outline_projection(
1721            &query,
1722            EntryProjection::All,
1723            Some(NodeSelector::new("options-2")),
1724        )
1725        .expect("section-rooted outline");
1726        let [
1727            OutlineNode::DocumentSection {
1728                path, id, children, ..
1729            },
1730        ] = section_rooted.nodes.as_slice()
1731        else {
1732            panic!("expected one rooted section");
1733        };
1734        assert_eq!(path.as_str(), "2", "rooting must not rebase paths");
1735        assert_eq!(id.as_str(), "options-2");
1736        assert!(
1737            children
1738                .iter()
1739                .any(|node| node.id() == "option-local-forward")
1740        );
1741        assert!(children.iter().any(|node| node.id() == "common-3"));
1742        assert!(children.iter().any(|node| node.id() == "other-4"));
1743        assert!(
1744            !section_rooted
1745                .nodes
1746                .iter()
1747                .any(|node| node.id() == "name-1" || node.id() == "files-5"),
1748            "unrelated siblings must not leak into a rooted projection"
1749        );
1750
1751        let rooted = build_outline_projection(
1752            &query,
1753            EntryProjection::Summary,
1754            Some(NodeSelector::new("option-local-forward")),
1755        )
1756        .expect("entry-rooted outline");
1757        assert!(matches!(
1758            rooted.nodes.as_slice(),
1759            [OutlineNode::DocumentEntry { id, children, .. }]
1760                if id == "option-local-forward" && children.is_empty()
1761        ));
1762
1763        query.document.as_mut().expect("document").sections[2]
1764            .blocks
1765            .push(Block::DefinitionList {
1766                items: vec![definition(
1767                    "option-other-local-forward",
1768                    DefinitionRole::Option,
1769                    &["-L"],
1770                    &["-L path"],
1771                    Vec::new(),
1772                )],
1773                compact: true,
1774                layout: LayoutHint::default(),
1775                source: None,
1776            });
1777        let error =
1778            build_outline_projection(&query, EntryProjection::All, Some(NodeSelector::new("-L")))
1779                .expect_err("ambiguous aliases must require qualification");
1780        let ProjectionError::AmbiguousSelector { candidates, .. } = error else {
1781            panic!("expected ambiguous selector");
1782        };
1783        assert_eq!(candidates.len(), 2);
1784        assert_eq!(candidates[0].path, "2/e1");
1785        assert_eq!(candidates[1].path, "3/e1");
1786    }
1787
1788    #[test]
1789    fn section_ids_win_consistently_before_entry_aliases() {
1790        let mut query = query();
1791        query.document.as_mut().expect("document").sections[0] = Section {
1792            id: "force".into(),
1793            title: "Force".to_owned(),
1794            spacing_before_lines: 0,
1795            blocks: Vec::new(),
1796            children: Vec::new(),
1797            source: None,
1798        };
1799        query.document.as_mut().expect("document").sections[1]
1800            .blocks
1801            .push(Block::DefinitionList {
1802                items: vec![definition(
1803                    "command-force",
1804                    DefinitionRole::Command,
1805                    &["force"],
1806                    &["force"],
1807                    Vec::new(),
1808                )],
1809                compact: true,
1810                layout: LayoutHint::default(),
1811                source: None,
1812            });
1813
1814        let excerpt = select_excerpt(&query, &["force"]).expect("exact section ID");
1815        assert!(matches!(
1816            excerpt.selections.as_slice(),
1817            [ExcerptSelection::DocumentSection { outline, .. }] if outline.path() == "1"
1818        ));
1819        assert!(matches!(
1820            super::select_explanation(&query, "force"),
1821            Err(ProjectionError::ExplanationRequiresEntry { .. })
1822        ));
1823        let outline = build_outline_projection(
1824            &query,
1825            EntryProjection::All,
1826            Some(NodeSelector::new("force")),
1827        )
1828        .expect("outline root uses the same exact-ID precedence");
1829        assert!(matches!(
1830            outline.nodes.as_slice(),
1831            [OutlineNode::DocumentSection { path, .. }] if path == "1"
1832        ));
1833
1834        assert!(super::select_explanation(&query, "command-force").is_ok());
1835    }
1836
1837    #[test]
1838    fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
1839        let mut query = query();
1840        query.tldr = Some(tldr());
1841
1842        let outline = build_outline(&query).expect("combined outline");
1843
1844        assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
1845        assert_eq!(outline.nodes[0].path(), "0");
1846        assert_eq!(outline.nodes[0].id(), "tldr");
1847        assert_eq!(outline.nodes[1].path(), "1");
1848        assert_eq!(outline.nodes[2].path(), "2");
1849    }
1850
1851    #[test]
1852    fn entry_completeness_distinguishes_rejections_from_author_warnings() {
1853        let mut query = query();
1854        {
1855            let document = query.document.as_mut().expect("document");
1856            for code in [
1857                "markdown.semantic-entry.ambiguous-selector",
1858                "markdown.semantic-entry-list",
1859            ] {
1860                document.diagnostics.push(Diagnostic {
1861                    level: DiagnosticLevel::Warning,
1862                    code: Some(code.to_owned()),
1863                    message: "author warning".to_owned(),
1864                    source: None,
1865                });
1866            }
1867        }
1868        assert!(
1869            build_outline(&query)
1870                .expect("complete outline")
1871                .entries_complete
1872        );
1873
1874        query
1875            .document
1876            .as_mut()
1877            .expect("document")
1878            .diagnostics
1879            .push(Diagnostic {
1880                level: DiagnosticLevel::Warning,
1881                code: Some("markdown.semantic-entry.invalid-entry-name".to_owned()),
1882                message: "rejected declaration".to_owned(),
1883                source: None,
1884            });
1885        assert!(
1886            !build_outline(&query)
1887                .expect("partial outline")
1888                .entries_complete
1889        );
1890    }
1891
1892    #[test]
1893    fn addresses_document_content_before_the_first_heading_as_root() {
1894        let mut query = query();
1895        let document = query.document.as_mut().expect("document");
1896        document.source.format = SourceFormat::Markdown;
1897        document.blocks.push(Block::Paragraph {
1898            children: vec![Inline::Text {
1899                value: "Document preface.".to_owned(),
1900            }],
1901            layout: LayoutHint::default(),
1902            source: None,
1903        });
1904
1905        let outline = build_outline(&query).expect("Markdown outline");
1906        assert!(matches!(
1907            &outline.nodes[0],
1908            OutlineNode::DocumentRoot { path, id, title, .. }
1909                if path == "root" && id == "document-overview" && title == "OVERVIEW"
1910        ));
1911        // Heading paths remain stable and independent from the synthetic root.
1912        assert_eq!(outline.nodes[1].path(), "1");
1913
1914        let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
1915            .expect("root excerpt");
1916        assert!(matches!(
1917            excerpt.selections.as_slice(),
1918            [ExcerptSelection::DocumentRoot { outline, blocks, .. }]
1919                if outline.path() == "root" && blocks.len() == 1
1920        ));
1921        assert_eq!(
1922            excerpt.source.as_ref().map(|source| source.format),
1923            Some(SourceFormat::Markdown)
1924        );
1925    }
1926
1927    #[test]
1928    fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
1929        let excerpt = select_excerpt(
1930            &query(),
1931            &[
1932                "files-5".to_owned(),
1933                "2.1".to_owned(),
1934                "2".to_owned(),
1935                "options-2".to_owned(),
1936            ],
1937        )
1938        .expect("excerpt");
1939
1940        let paths = excerpt
1941            .selections
1942            .iter()
1943            .map(|selection| selection.outline().path())
1944            .collect::<Vec<_>>();
1945        assert_eq!(paths, ["2", "3"]);
1946        let ExcerptSelection::DocumentSection {
1947            section, outline, ..
1948        } = &excerpt.selections[0]
1949        else {
1950            panic!("expected manual selection");
1951        };
1952        assert_eq!(section.children.len(), 2);
1953        assert!(outline.ancestors.is_empty());
1954    }
1955
1956    #[test]
1957    fn child_selection_retains_ancestor_breadcrumbs() {
1958        let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
1959
1960        let ExcerptSelection::DocumentSection { outline, .. } = &excerpt.selections[0] else {
1961            panic!("expected manual selection");
1962        };
1963        assert_eq!(outline.title(), "Other options");
1964        assert_eq!(outline.ancestors[0].path, "2");
1965        assert_eq!(outline.ancestors[0].title, "OPTIONS");
1966    }
1967
1968    #[test]
1969    fn structural_paths_take_precedence_over_colliding_entry_ids() {
1970        let mut query = query();
1971        query.document.as_mut().expect("document").sections[1]
1972            .blocks
1973            .push(Block::DefinitionList {
1974                items: vec![DefinitionItem {
1975                    identity: Some(DefinitionIdentity {
1976                        id: "3".into(),
1977                        role: DefinitionRole::Option,
1978                        case: DefinitionCase::Sensitive,
1979                        names: vec!["-3".to_owned()],
1980                    }),
1981                    terms: vec![vec![Inline::Code {
1982                        value: "-3".to_owned(),
1983                    }]],
1984                    description: Vec::new(),
1985                    inline_term: false,
1986                    spacing_before_lines: None,
1987                }],
1988                compact: true,
1989                layout: LayoutHint::default(),
1990                source: None,
1991            });
1992
1993        let excerpt = select_excerpt(&query, &["3"]).expect("section path wins");
1994        assert!(matches!(
1995            excerpt.selections.as_slice(),
1996            [ExcerptSelection::DocumentSection { outline, .. }] if outline.path() == "3"
1997        ));
1998        assert!(matches!(
1999            super::select_explanation(&query, "3"),
2000            Err(ProjectionError::ExplanationRequiresEntry { .. })
2001        ));
2002    }
2003
2004    #[test]
2005    fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
2006        let mut combined = query();
2007        combined.tldr = Some(tldr());
2008        let excerpt = select_excerpt(
2009            &combined,
2010            &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
2011        )
2012        .expect("combined excerpt");
2013        assert!(matches!(
2014            excerpt.selections.as_slice(),
2015            [ExcerptSelection::Tldr { outline, .. }, ExcerptSelection::DocumentSection { .. }]
2016                if outline.path() == "0"
2017        ));
2018
2019        let mut tldr_only = combined;
2020        tldr_only.document = None;
2021        let outline = build_outline(&tldr_only).expect("tldr-only outline");
2022        assert_eq!(outline.nodes.len(), 1);
2023        assert_eq!(outline.nodes[0].path(), "0");
2024        assert!(outline.source.is_none());
2025        assert!(outline.meta.is_none());
2026    }
2027
2028    #[test]
2029    fn reports_missing_content_and_unknown_or_empty_selectors() {
2030        let mut empty = query();
2031        empty.document = None;
2032        assert!(matches!(
2033            build_outline(&empty),
2034            Err(ProjectionError::MissingContent { .. })
2035        ));
2036        assert_eq!(
2037            select_excerpt(&query(), &[] as &[String]),
2038            Err(ProjectionError::EmptySelection)
2039        );
2040        assert_eq!(
2041            select_excerpt(&query(), &[" ".to_owned()]),
2042            Err(ProjectionError::EmptySelector)
2043        );
2044        assert!(matches!(
2045            select_excerpt(&query(), &["9".to_owned()]),
2046            Err(ProjectionError::UnknownSelector { .. })
2047        ));
2048    }
2049}