1use 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
17pub(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#[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
71pub fn build_outline(query: &QueryBundle) -> Result<QueryOutline, ProjectionError> {
78 build_outline_with_detail(query, OutlineDetail::Sections)
79}
80
81pub 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
130pub 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(§ion.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(§ion.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, .. } => §ion.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(§ion.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(§ion.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 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}