Skip to main content

mant_core/
projection.rs

1//! Projects complete structured documents into outlines and selectable excerpts.
2
3use std::{collections::HashSet, error::Error, fmt};
4
5use mant_ast::{
6    Block, DefinitionItem, ExcerptSchema, ExcerptSelection, OutlineDetail, OutlineNode,
7    OutlineReference, OutlineSchema, QueryBundle, QueryExcerpt, QueryOutline, Section,
8};
9
10const TLDR_PATH: &str = "0";
11pub(crate) const TLDR_ID: &str = "tldr";
12const TLDR_TITLE: &str = "TLDR QUICK REFERENCE";
13pub(crate) const DOCUMENT_ROOT_PATH: &str = "root";
14pub(crate) const DOCUMENT_ROOT_ID: &str = "document-overview";
15pub(crate) const DOCUMENT_ROOT_TITLE: &str = "OVERVIEW";
16
17/// Whether an identifier belongs to the selector namespace rather than a
18/// document-defined node.
19///
20/// Section paths use dotted positive indices (`2.1`), while semantic entries
21/// append an option index (`2.1/o3`). The parser reserves the complete grammar,
22/// not only selectors present in one particular document, so source-defined
23/// IDs can never make excerpt lookup ambiguous.
24pub(crate) fn is_reserved_selector(value: &str) -> bool {
25    matches!(
26        value,
27        TLDR_PATH | TLDR_ID | DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID
28    ) || is_outline_path(value)
29}
30
31fn is_outline_path(value: &str) -> bool {
32    let (sections, entry) = value
33        .split_once("/o")
34        .map_or((value, None), |(sections, entry)| (sections, Some(entry)));
35    let section_path = !sections.is_empty()
36        && sections
37            .split('.')
38            .all(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
39    let entry_path = entry
40        .is_none_or(|index| !index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()));
41    section_path && entry_path
42}
43
44/// Failure to derive an addressable view from a complete query.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum ProjectionError {
47    MissingContent { document: String },
48    EmptySelection,
49    EmptySelector,
50    UnknownSelector { document: String, selector: String },
51}
52
53impl fmt::Display for ProjectionError {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Self::MissingContent { document } => {
57                write!(formatter, "document '{document}' has no available content")
58            }
59            Self::EmptySelection => formatter.write_str("at least one outline node is required"),
60            Self::EmptySelector => formatter.write_str("outline node must not be empty"),
61            Self::UnknownSelector { document, selector } => write!(
62                formatter,
63                "document '{document}' has no outline node '{selector}'; run 'mant {document} --outline'"
64            ),
65        }
66    }
67}
68
69impl Error for ProjectionError {}
70
71/// Build a block-free, addressable outline for one complete query.
72///
73/// # Errors
74///
75/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
76/// is available.
77pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
78    build_outline_with_detail(query, OutlineDetail::Sections)
79}
80
81/// Build an outline with optional semantic definition entries.
82///
83/// # Errors
84///
85/// Returns [`ProjectionError::MissingContent`] when neither tldr nor a manual
86/// is available.
87pub fn build_outline_with_detail(
88    query: &QueryBundle,
89    detail: OutlineDetail,
90) -> Result<QueryOutline, ProjectionError> {
91    if query.tldr.is_none() && query.document.is_none() {
92        return Err(ProjectionError::MissingContent {
93            document: query.label.clone(),
94        });
95    }
96    let mut nodes = Vec::new();
97    if query.tldr.is_some() {
98        nodes.push(OutlineNode::Tldr {
99            path: TLDR_PATH.to_owned(),
100            id: TLDR_ID.to_owned(),
101            title: TLDR_TITLE.to_owned(),
102        });
103    }
104    if let Some(manual) = &query.document {
105        if !manual.blocks.is_empty() {
106            nodes.push(OutlineNode::DocumentRoot {
107                path: DOCUMENT_ROOT_PATH.to_owned(),
108                id: DOCUMENT_ROOT_ID.to_owned(),
109                title: DOCUMENT_ROOT_TITLE.to_owned(),
110            });
111        }
112        nodes.extend(outline_nodes(&manual.sections, &[], detail));
113    }
114    Ok(QueryOutline {
115        schema: OutlineSchema::V4,
116        detail,
117        label: query.label.clone(),
118        source: query
119            .document
120            .as_ref()
121            .map(|document| document.source.clone()),
122        meta: query
123            .document
124            .as_ref()
125            .map(|document| document.meta.clone()),
126        nodes,
127    })
128}
129
130/// Select tldr, document-root content, or complete section subtrees by path or ID.
131///
132/// Duplicate selections and descendants of another selected node are omitted.
133/// The result always follows source order, independent of argument order.
134///
135/// # Errors
136///
137/// Returns an error when no content exists or any selector is empty or unknown.
138pub fn select_excerpt(
139    query: &QueryBundle,
140    selectors: &[String],
141) -> Result<QueryExcerpt, ProjectionError> {
142    if selectors.is_empty() {
143        return Err(ProjectionError::EmptySelection);
144    }
145    if query.tldr.is_none() && query.document.is_none() {
146        return Err(ProjectionError::MissingContent {
147            document: query.label.clone(),
148        });
149    }
150    let mut located = Vec::new();
151    if let Some(manual) = &query.document {
152        collect_sections(&manual.sections, &[], &[], &mut located);
153    }
154
155    let mut tldr_selected = false;
156    let mut document_root_selected = false;
157    let mut selected_ids = HashSet::new();
158    let mut selected = Vec::new();
159    for raw_selector in selectors {
160        let selector = raw_selector.trim();
161        if selector.is_empty() {
162            return Err(ProjectionError::EmptySelector);
163        }
164        if matches!(selector, TLDR_PATH | TLDR_ID) && query.tldr.is_some() {
165            tldr_selected = true;
166            continue;
167        }
168        if matches!(selector, DOCUMENT_ROOT_PATH | DOCUMENT_ROOT_ID)
169            && query
170                .document
171                .as_ref()
172                .is_some_and(|document| !document.blocks.is_empty())
173        {
174            document_root_selected = true;
175            continue;
176        }
177        let candidate = located
178            .iter()
179            .find(|candidate| candidate.matches(selector))
180            .ok_or_else(|| ProjectionError::UnknownSelector {
181                document: query.label.clone(),
182                selector: selector.to_owned(),
183            })?;
184        if selected_ids.insert(candidate.id()) {
185            selected.push(candidate);
186        }
187    }
188    let selected_sections = selected
189        .iter()
190        .filter(|candidate| candidate.is_section())
191        .map(|candidate| candidate.coordinates().to_vec())
192        .collect::<Vec<_>>();
193    selected.retain(|candidate| {
194        !selected_sections.iter().any(|ancestor| {
195            if candidate.is_section() {
196                ancestor != candidate.coordinates()
197                    && is_ancestor(ancestor, candidate.coordinates())
198            } else {
199                ancestor == candidate.coordinates()
200                    || is_ancestor(ancestor, candidate.coordinates())
201            }
202        })
203    });
204    selected.sort_by_key(|candidate| candidate.order());
205
206    let document = if selected.is_empty() && !document_root_selected {
207        None
208    } else {
209        query.document.as_ref()
210    };
211    let mut selections = Vec::new();
212    if let (true, Some(document)) = (tldr_selected, query.tldr.clone()) {
213        selections.push(ExcerptSelection::Tldr {
214            path: TLDR_PATH.to_owned(),
215            id: TLDR_ID.to_owned(),
216            title: TLDR_TITLE.to_owned(),
217            document,
218        });
219    }
220    if let (true, Some(document)) = (document_root_selected, query.document.as_ref()) {
221        selections.push(ExcerptSelection::DocumentRoot {
222            path: DOCUMENT_ROOT_PATH.to_owned(),
223            id: DOCUMENT_ROOT_ID.to_owned(),
224            title: DOCUMENT_ROOT_TITLE.to_owned(),
225            blocks: document.blocks.clone(),
226        });
227    }
228    selections.extend(selected.into_iter().map(LocatedNode::selection));
229
230    Ok(QueryExcerpt {
231        schema: ExcerptSchema::V4,
232        label: query.label.clone(),
233        producer: document.map(|document| document.producer.clone()),
234        source: document.map(|document| document.source.clone()),
235        meta: document.map(|document| document.meta.clone()),
236        diagnostics: document
237            .map(|document| document.diagnostics.clone())
238            .unwrap_or_default(),
239        selections,
240    })
241}
242
243fn outline_nodes(
244    sections: &[Section],
245    parent: &[usize],
246    detail: OutlineDetail,
247) -> Vec<OutlineNode> {
248    sections
249        .iter()
250        .enumerate()
251        .map(|(index, section)| {
252            let mut coordinates = parent.to_vec();
253            coordinates.push(index + 1);
254            let path = format_path(&coordinates);
255            let mut children = Vec::new();
256            if detail == OutlineDetail::Options {
257                let mut entries = Vec::new();
258                collect_definition_entries(&section.blocks, &mut entries);
259                children.extend(
260                    entries
261                        .into_iter()
262                        .enumerate()
263                        .filter_map(|(index, entry)| {
264                            let identity = entry.identity.as_ref()?;
265                            Some(OutlineNode::DocumentEntry {
266                                path: format!("{path}/o{}", index + 1),
267                                id: identity.id.clone(),
268                                title: identity.names.join(", "),
269                                role: identity.role,
270                                names: identity.names.clone(),
271                            })
272                        }),
273                );
274            }
275            children.extend(outline_nodes(&section.children, &coordinates, detail));
276            OutlineNode::DocumentSection {
277                path,
278                id: section.id.clone(),
279                title: section.title.clone(),
280                children,
281            }
282        })
283        .collect()
284}
285
286enum LocatedNode<'a> {
287    Section {
288        order: usize,
289        coordinates: Vec<usize>,
290        path: String,
291        breadcrumbs: Vec<OutlineReference>,
292        section: &'a Section,
293    },
294    Entry {
295        order: usize,
296        coordinates: Vec<usize>,
297        path: String,
298        title: String,
299        breadcrumbs: Vec<OutlineReference>,
300        entry: &'a DefinitionItem,
301    },
302}
303
304impl LocatedNode<'_> {
305    fn order(&self) -> usize {
306        match self {
307            Self::Section { order, .. } | Self::Entry { order, .. } => *order,
308        }
309    }
310
311    fn coordinates(&self) -> &[usize] {
312        match self {
313            Self::Section { coordinates, .. } | Self::Entry { coordinates, .. } => coordinates,
314        }
315    }
316
317    fn path(&self) -> &str {
318        match self {
319            Self::Section { path, .. } | Self::Entry { path, .. } => path,
320        }
321    }
322
323    fn id(&self) -> &str {
324        match self {
325            Self::Section { section, .. } => &section.id,
326            Self::Entry { entry, .. } => {
327                &entry
328                    .identity
329                    .as_ref()
330                    .expect("located entries have identities")
331                    .id
332            }
333        }
334    }
335
336    fn matches(&self, selector: &str) -> bool {
337        if self.path() == selector || self.id() == selector {
338            return true;
339        }
340        match self {
341            Self::Entry { entry, .. } => entry.identity.as_ref().is_some_and(|identity| {
342                identity
343                    .names
344                    .iter()
345                    .any(|name| name == selector || name.trim_start_matches('-') == selector)
346            }),
347            Self::Section { .. } => false,
348        }
349    }
350
351    const fn is_section(&self) -> bool {
352        matches!(self, Self::Section { .. })
353    }
354
355    fn selection(&self) -> ExcerptSelection {
356        match self {
357            Self::Section {
358                path,
359                breadcrumbs,
360                section,
361                ..
362            } => ExcerptSelection::DocumentSection {
363                path: path.clone(),
364                id: section.id.clone(),
365                title: section.title.clone(),
366                breadcrumbs: breadcrumbs.clone(),
367                section: (*section).clone(),
368            },
369            Self::Entry {
370                path,
371                title,
372                breadcrumbs,
373                entry,
374                ..
375            } => ExcerptSelection::DocumentEntry {
376                path: path.clone(),
377                id: entry
378                    .identity
379                    .as_ref()
380                    .expect("located entries have identities")
381                    .id
382                    .clone(),
383                title: title.clone(),
384                breadcrumbs: breadcrumbs.clone(),
385                entry: (*entry).clone(),
386            },
387        }
388    }
389}
390
391fn collect_sections<'a>(
392    sections: &'a [Section],
393    parent_coordinates: &[usize],
394    breadcrumbs: &[OutlineReference],
395    output: &mut Vec<LocatedNode<'a>>,
396) {
397    for (index, section) in sections.iter().enumerate() {
398        let mut coordinates = parent_coordinates.to_vec();
399        coordinates.push(index + 1);
400        let path = format_path(&coordinates);
401        let order = output.len();
402        output.push(LocatedNode::Section {
403            order,
404            coordinates: coordinates.clone(),
405            path: path.clone(),
406            breadcrumbs: breadcrumbs.to_vec(),
407            section,
408        });
409        let mut child_breadcrumbs = breadcrumbs.to_vec();
410        child_breadcrumbs.push(OutlineReference {
411            path: path.clone(),
412            id: section.id.clone(),
413            title: section.title.clone(),
414        });
415        let mut entries = Vec::new();
416        collect_definition_entries(&section.blocks, &mut entries);
417        for (index, entry) in entries.into_iter().enumerate() {
418            let Some(identity) = &entry.identity else {
419                continue;
420            };
421            output.push(LocatedNode::Entry {
422                order: output.len(),
423                coordinates: coordinates.clone(),
424                path: format!("{path}/o{}", index + 1),
425                title: identity.names.join(", "),
426                breadcrumbs: child_breadcrumbs.clone(),
427                entry,
428            });
429        }
430        collect_sections(&section.children, &coordinates, &child_breadcrumbs, output);
431    }
432}
433
434fn collect_definition_entries<'a>(blocks: &'a [Block], output: &mut Vec<&'a DefinitionItem>) {
435    for block in blocks {
436        match block {
437            Block::List { items, .. } => {
438                for item in items {
439                    collect_definition_entries(&item.blocks, output);
440                }
441            }
442            Block::DefinitionList { items, .. } => {
443                for item in items {
444                    if item.identity.is_some() {
445                        output.push(item);
446                    }
447                    collect_definition_entries(&item.description, output);
448                }
449            }
450            Block::Table { rows, .. } => {
451                for row in rows {
452                    for cell in &row.cells {
453                        collect_definition_entries(&cell.blocks, output);
454                    }
455                }
456            }
457            Block::Paragraph { .. }
458            | Block::Preformatted { .. }
459            | Block::Equation { .. }
460            | Block::VerticalSpace { .. }
461            | Block::ThematicBreak { .. }
462            | Block::Unsupported { .. } => {}
463        }
464    }
465}
466
467fn format_path(coordinates: &[usize]) -> String {
468    coordinates
469        .iter()
470        .map(usize::to_string)
471        .collect::<Vec<_>>()
472        .join(".")
473}
474
475fn is_ancestor(ancestor: &[usize], descendant: &[usize]) -> bool {
476    ancestor.len() < descendant.len() && descendant.starts_with(ancestor)
477}
478
479#[cfg(test)]
480mod tests {
481    use mant_ast::{
482        Block, DocumentMeta, DocumentSchema, DocumentSource, ExcerptSelection, Inline, LayoutHint,
483        MantDocument, OutlineNode, Producer, QueryBundle, QuerySchema, Section, SourceFormat,
484        TldrDocument, TldrOrigin,
485    };
486
487    use super::{ProjectionError, build_outline, select_excerpt};
488
489    fn section(id: &str, title: &str, children: Vec<Section>) -> Section {
490        Section {
491            id: id.to_owned(),
492            title: title.to_owned(),
493            spacing_before_lines: 0,
494            blocks: Vec::new(),
495            children,
496            source: None,
497        }
498    }
499
500    fn query() -> QueryBundle {
501        QueryBundle {
502            schema: QuerySchema::V4,
503            label: "demo".to_owned(),
504            document: Some(MantDocument {
505                schema: DocumentSchema::V4,
506                producer: Producer {
507                    name: "test".to_owned(),
508                    version: "1".to_owned(),
509                    engine: None,
510                },
511                source: DocumentSource {
512                    format: SourceFormat::Man,
513                    path: Some("/man/demo.1".to_owned()),
514                },
515                meta: DocumentMeta {
516                    section: Some("1".to_owned()),
517                    ..DocumentMeta::default()
518                },
519                diagnostics: Vec::new(),
520                blocks: Vec::new(),
521                sections: vec![
522                    section("name-1", "NAME", Vec::new()),
523                    section(
524                        "options-2",
525                        "OPTIONS",
526                        vec![
527                            section("common-3", "Common options", Vec::new()),
528                            section("other-4", "Other options", Vec::new()),
529                        ],
530                    ),
531                    section("files-5", "FILES", Vec::new()),
532                ],
533            }),
534            tldr: None,
535        }
536    }
537
538    fn tldr() -> TldrDocument {
539        TldrDocument {
540            title: "demo".to_owned(),
541            description: vec!["A small demonstration.".to_owned()],
542            more_information: Some("https://example.com/demo".to_owned()),
543            examples: Vec::new(),
544            platform: "common".to_owned(),
545            language: "en".to_owned(),
546            source_path: "/tldr/pages/common/demo.md".to_owned(),
547            origin: TldrOrigin::TldrPages,
548        }
549    }
550
551    #[test]
552    fn builds_one_based_tree_paths_without_copying_blocks() {
553        let outline = build_outline(&query()).expect("outline");
554
555        assert_eq!(
556            outline
557                .meta
558                .as_ref()
559                .and_then(|meta| meta.section.as_deref()),
560            Some("1")
561        );
562        assert_eq!(outline.nodes[1].path(), "2");
563        assert_eq!(outline.nodes[1].id(), "options-2");
564        assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
565        assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
566    }
567
568    #[test]
569    fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
570        let mut query = query();
571        query.tldr = Some(tldr());
572
573        let outline = build_outline(&query).expect("combined outline");
574
575        assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
576        assert_eq!(outline.nodes[0].path(), "0");
577        assert_eq!(outline.nodes[0].id(), "tldr");
578        assert_eq!(outline.nodes[1].path(), "1");
579        assert_eq!(outline.nodes[2].path(), "2");
580    }
581
582    #[test]
583    fn addresses_document_content_before_the_first_heading_as_root() {
584        let mut query = query();
585        let document = query.document.as_mut().expect("document");
586        document.source.format = SourceFormat::Markdown;
587        document.blocks.push(Block::Paragraph {
588            children: vec![Inline::Text {
589                value: "Document preface.".to_owned(),
590            }],
591            layout: LayoutHint::default(),
592            source: None,
593        });
594
595        let outline = build_outline(&query).expect("Markdown outline");
596        assert!(matches!(
597            &outline.nodes[0],
598            OutlineNode::DocumentRoot { path, id, title }
599                if path == "root" && id == "document-overview" && title == "OVERVIEW"
600        ));
601        // Heading paths remain stable and independent from the synthetic root.
602        assert_eq!(outline.nodes[1].path(), "1");
603
604        let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
605            .expect("root excerpt");
606        assert!(matches!(
607            excerpt.selections.as_slice(),
608            [ExcerptSelection::DocumentRoot { path, blocks, .. }]
609                if path == "root" && blocks.len() == 1
610        ));
611        assert_eq!(
612            excerpt.source.as_ref().map(|source| source.format),
613            Some(SourceFormat::Markdown)
614        );
615    }
616
617    #[test]
618    fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
619        let excerpt = select_excerpt(
620            &query(),
621            &[
622                "files-5".to_owned(),
623                "2.1".to_owned(),
624                "2".to_owned(),
625                "options-2".to_owned(),
626            ],
627        )
628        .expect("excerpt");
629
630        let paths = excerpt
631            .selections
632            .iter()
633            .map(|selection| match selection {
634                ExcerptSelection::Tldr { path, .. }
635                | ExcerptSelection::DocumentRoot { path, .. }
636                | ExcerptSelection::DocumentSection { path, .. }
637                | ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
638            })
639            .collect::<Vec<_>>();
640        assert_eq!(paths, ["2", "3"]);
641        let ExcerptSelection::DocumentSection {
642            section,
643            breadcrumbs,
644            ..
645        } = &excerpt.selections[0]
646        else {
647            panic!("expected manual selection");
648        };
649        assert_eq!(section.children.len(), 2);
650        assert!(breadcrumbs.is_empty());
651    }
652
653    #[test]
654    fn child_selection_retains_ancestor_breadcrumbs() {
655        let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
656
657        let ExcerptSelection::DocumentSection {
658            title, breadcrumbs, ..
659        } = &excerpt.selections[0]
660        else {
661            panic!("expected manual selection");
662        };
663        assert_eq!(title, "Other options");
664        assert_eq!(breadcrumbs[0].path, "2");
665        assert_eq!(breadcrumbs[0].title, "OPTIONS");
666    }
667
668    #[test]
669    fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
670        let mut combined = query();
671        combined.tldr = Some(tldr());
672        let excerpt = select_excerpt(
673            &combined,
674            &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
675        )
676        .expect("combined excerpt");
677        assert!(matches!(
678            excerpt.selections.as_slice(),
679            [ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
680                if path == "0"
681        ));
682
683        let mut tldr_only = combined;
684        tldr_only.document = None;
685        let outline = build_outline(&tldr_only).expect("tldr-only outline");
686        assert_eq!(outline.nodes.len(), 1);
687        assert_eq!(outline.nodes[0].path(), "0");
688        assert!(outline.source.is_none());
689        assert!(outline.meta.is_none());
690    }
691
692    #[test]
693    fn reports_missing_content_and_unknown_or_empty_selectors() {
694        let mut empty = query();
695        empty.document = None;
696        assert!(matches!(
697            build_outline(&empty),
698            Err(ProjectionError::MissingContent { .. })
699        ));
700        assert_eq!(
701            select_excerpt(&query(), &[]),
702            Err(ProjectionError::EmptySelection)
703        );
704        assert_eq!(
705            select_excerpt(&query(), &[" ".to_owned()]),
706            Err(ProjectionError::EmptySelector)
707        );
708        assert!(matches!(
709            select_excerpt(&query(), &["9".to_owned()]),
710            Err(ProjectionError::UnknownSelector { .. })
711        ));
712    }
713}