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 { topic: String },
48    EmptySelection,
49    EmptySelector,
50    UnknownSelector { topic: 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 { topic } => {
57                write!(formatter, "document '{topic}' 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 { topic, selector } => write!(
62                formatter,
63                "document '{topic}' has no outline node '{selector}'; run 'mant {topic} --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            topic: 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::V3,
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            topic: 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                topic: 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::V3,
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::V3,
503            label: "demo".to_owned(),
504            document: Some(MantDocument {
505                schema: DocumentSchema::V3,
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                    renderer: None,
515                },
516                meta: DocumentMeta {
517                    section: Some("1".to_owned()),
518                    ..DocumentMeta::default()
519                },
520                diagnostics: Vec::new(),
521                blocks: Vec::new(),
522                sections: vec![
523                    section("name-1", "NAME", Vec::new()),
524                    section(
525                        "options-2",
526                        "OPTIONS",
527                        vec![
528                            section("common-3", "Common options", Vec::new()),
529                            section("other-4", "Other options", Vec::new()),
530                        ],
531                    ),
532                    section("files-5", "FILES", Vec::new()),
533                ],
534            }),
535            tldr: None,
536        }
537    }
538
539    fn tldr() -> TldrDocument {
540        TldrDocument {
541            title: "demo".to_owned(),
542            description: vec!["A small demonstration.".to_owned()],
543            more_information: Some("https://example.com/demo".to_owned()),
544            examples: Vec::new(),
545            platform: "common".to_owned(),
546            language: "en".to_owned(),
547            source_path: "/tldr/pages/common/demo.md".to_owned(),
548            origin: TldrOrigin::TldrPages,
549        }
550    }
551
552    #[test]
553    fn builds_one_based_tree_paths_without_copying_blocks() {
554        let outline = build_outline(&query()).expect("outline");
555
556        assert_eq!(
557            outline
558                .meta
559                .as_ref()
560                .and_then(|meta| meta.section.as_deref()),
561            Some("1")
562        );
563        assert_eq!(outline.nodes[1].path(), "2");
564        assert_eq!(outline.nodes[1].id(), "options-2");
565        assert_eq!(outline.nodes[1].children()[0].path(), "2.1");
566        assert_eq!(outline.nodes[1].children()[1].path(), "2.2");
567    }
568
569    #[test]
570    fn prepends_tldr_as_zero_without_renumbering_manual_sections() {
571        let mut query = query();
572        query.tldr = Some(tldr());
573
574        let outline = build_outline(&query).expect("combined outline");
575
576        assert!(matches!(outline.nodes[0], OutlineNode::Tldr { .. }));
577        assert_eq!(outline.nodes[0].path(), "0");
578        assert_eq!(outline.nodes[0].id(), "tldr");
579        assert_eq!(outline.nodes[1].path(), "1");
580        assert_eq!(outline.nodes[2].path(), "2");
581    }
582
583    #[test]
584    fn addresses_document_content_before_the_first_heading_as_root() {
585        let mut query = query();
586        let document = query.document.as_mut().expect("document");
587        document.source.format = SourceFormat::Markdown;
588        document.blocks.push(Block::Paragraph {
589            children: vec![Inline::Text {
590                value: "Document preface.".to_owned(),
591            }],
592            layout: LayoutHint::default(),
593            source: None,
594        });
595
596        let outline = build_outline(&query).expect("Markdown outline");
597        assert!(matches!(
598            &outline.nodes[0],
599            OutlineNode::DocumentRoot { path, id, title }
600                if path == "root" && id == "document-overview" && title == "OVERVIEW"
601        ));
602        // Heading paths remain stable and independent from the synthetic root.
603        assert_eq!(outline.nodes[1].path(), "1");
604
605        let excerpt = select_excerpt(&query, &["document-overview".to_owned(), "root".to_owned()])
606            .expect("root excerpt");
607        assert!(matches!(
608            excerpt.selections.as_slice(),
609            [ExcerptSelection::DocumentRoot { path, blocks, .. }]
610                if path == "root" && blocks.len() == 1
611        ));
612        assert_eq!(
613            excerpt.source.as_ref().map(|source| source.format),
614            Some(SourceFormat::Markdown)
615        );
616    }
617
618    #[test]
619    fn selects_paths_or_ids_in_source_order_and_suppresses_descendant_duplicates() {
620        let excerpt = select_excerpt(
621            &query(),
622            &[
623                "files-5".to_owned(),
624                "2.1".to_owned(),
625                "2".to_owned(),
626                "options-2".to_owned(),
627            ],
628        )
629        .expect("excerpt");
630
631        let paths = excerpt
632            .selections
633            .iter()
634            .map(|selection| match selection {
635                ExcerptSelection::Tldr { path, .. }
636                | ExcerptSelection::DocumentRoot { path, .. }
637                | ExcerptSelection::DocumentSection { path, .. }
638                | ExcerptSelection::DocumentEntry { path, .. } => path.as_str(),
639            })
640            .collect::<Vec<_>>();
641        assert_eq!(paths, ["2", "3"]);
642        let ExcerptSelection::DocumentSection {
643            section,
644            breadcrumbs,
645            ..
646        } = &excerpt.selections[0]
647        else {
648            panic!("expected manual selection");
649        };
650        assert_eq!(section.children.len(), 2);
651        assert!(breadcrumbs.is_empty());
652    }
653
654    #[test]
655    fn child_selection_retains_ancestor_breadcrumbs() {
656        let excerpt = select_excerpt(&query(), &["2.2".to_owned()]).expect("excerpt");
657
658        let ExcerptSelection::DocumentSection {
659            title, breadcrumbs, ..
660        } = &excerpt.selections[0]
661        else {
662            panic!("expected manual selection");
663        };
664        assert_eq!(title, "Other options");
665        assert_eq!(breadcrumbs[0].path, "2");
666        assert_eq!(breadcrumbs[0].title, "OPTIONS");
667    }
668
669    #[test]
670    fn selects_tldr_by_zero_or_id_and_supports_tldr_only_outlines() {
671        let mut combined = query();
672        combined.tldr = Some(tldr());
673        let excerpt = select_excerpt(
674            &combined,
675            &["2".to_owned(), "tldr".to_owned(), "0".to_owned()],
676        )
677        .expect("combined excerpt");
678        assert!(matches!(
679            excerpt.selections.as_slice(),
680            [ExcerptSelection::Tldr { path, .. }, ExcerptSelection::DocumentSection { .. }]
681                if path == "0"
682        ));
683
684        let mut tldr_only = combined;
685        tldr_only.document = None;
686        let outline = build_outline(&tldr_only).expect("tldr-only outline");
687        assert_eq!(outline.nodes.len(), 1);
688        assert_eq!(outline.nodes[0].path(), "0");
689        assert!(outline.source.is_none());
690        assert!(outline.meta.is_none());
691    }
692
693    #[test]
694    fn reports_missing_content_and_unknown_or_empty_selectors() {
695        let mut empty = query();
696        empty.document = None;
697        assert!(matches!(
698            build_outline(&empty),
699            Err(ProjectionError::MissingContent { .. })
700        ));
701        assert_eq!(
702            select_excerpt(&query(), &[]),
703            Err(ProjectionError::EmptySelection)
704        );
705        assert_eq!(
706            select_excerpt(&query(), &[" ".to_owned()]),
707            Err(ProjectionError::EmptySelector)
708        );
709        assert!(matches!(
710            select_excerpt(&query(), &["9".to_owned()]),
711            Err(ProjectionError::UnknownSelector { .. })
712        ));
713    }
714}