1use std::{
4 error::Error,
5 ffi::OsStr,
6 fmt, fs,
7 io::{self, Read},
8 path::{Path, PathBuf},
9 sync::OnceLock,
10};
11
12use mant_ir::{Document, DocumentAddress, MarkdownOrigin, ResolvedContent, TldrDocument};
13use mant_protocol::{
14 CatalogQuery, DocumentCatalog, InputFormat, QueryExcerpt, QueryInput, QueryOutline,
15 QueryRequest, QuerySearch, QueryView, SearchCase, SearchQuery, SearchScope, SearchSyntax,
16};
17use mant_sources::{RegisteredDocumentIndex, RegisteredDocumentOrigin, SourceConfigError};
18
19use crate::{
20 ManualIndex, ManualPage, ManualRequest, ProjectionError, SearchError,
21 build_outline_with_detail, discover_manual_roots, executable::query_name_candidates,
22 locate_manual_source_in, parse_manual_bytes, parse_manual_page, parse_manual_source,
23 parse_markdown, read_cached_tldr_page, search_query, select_excerpt, select_explanation,
24 validate_search_query,
25};
26
27mod input;
28mod named;
29
30use input::query_with;
31pub use input::{query_markdown_text, query_roff_bytes};
32use named::query_named_document;
33
34pub const MAX_MARKDOWN_BYTES: u64 = 16 * 1024 * 1024;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum QueryError {
45 EmptyName,
47 InvalidManualSection,
49 TldrManualSection {
51 section: String,
53 },
54 InvalidSource,
56 ConflictingSourceSelectors,
58 EmptyMarkdownPath,
60 UnsupportedInputFormat {
62 path: String,
64 },
65 EmptySelection,
67 EmptySelector,
69 EmptyEntry,
71 InvalidSearch(SearchError),
73 Markdown {
75 path: String,
77 detail: String,
79 },
80 EmptyMarkdown {
82 label: String,
84 },
85 Registry {
87 detail: String,
89 },
90 Manual(ManualLoadError),
92 ManualWithTldr {
94 error: ManualLoadError,
96 topic: String,
98 },
99 TldrNotFound {
101 topic: String,
103 },
104 Tldr {
106 topic: String,
108 detail: String,
110 },
111 NoReadableContent {
113 name: String,
115 },
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ManualLoadError {
121 NotFound {
123 name: String,
125 detail: String,
127 },
128 Parse {
130 name: String,
132 detail: String,
134 },
135 Empty {
137 name: String,
139 path: PathBuf,
141 diagnostics: Vec<String>,
143 },
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum QueryViewResult {
149 Full(Box<ResolvedContent>),
151 Outline(QueryOutline),
153 Excerpt(QueryExcerpt),
155 Search(QuerySearch),
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum QueryExecutionError {
162 Query(QueryError),
164 Projection(ProjectionError),
166 Search(SearchError),
168}
169
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
172pub enum QueryPolicy {
173 #[default]
175 Combined,
176 ManualOnly,
178 TldrOnly,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183enum FullDocumentMode {
184 Priority,
185 NativeManual,
186 None,
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190enum QuickReferenceMode {
191 AttachToCommandManual,
192 Exclude,
193 Only,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197struct NamedResolutionPlan {
198 document: FullDocumentMode,
199 quick_reference: QuickReferenceMode,
200}
201
202impl QueryPolicy {
203 fn named_resolution_plan(self, has_manual_section: bool) -> NamedResolutionPlan {
204 match self {
205 Self::Combined => NamedResolutionPlan {
206 document: if has_manual_section {
207 FullDocumentMode::NativeManual
208 } else {
209 FullDocumentMode::Priority
210 },
211 quick_reference: QuickReferenceMode::AttachToCommandManual,
212 },
213 Self::ManualOnly => NamedResolutionPlan {
214 document: FullDocumentMode::NativeManual,
215 quick_reference: QuickReferenceMode::Exclude,
216 },
217 Self::TldrOnly => NamedResolutionPlan {
218 document: FullDocumentMode::None,
219 quick_reference: QuickReferenceMode::Only,
220 },
221 }
222 }
223}
224
225impl fmt::Display for QueryError {
226 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 Self::EmptyName => formatter.write_str("name must not be empty"),
229 Self::InvalidManualSection => formatter.write_str(
230 "manual section must be a conventional number or the single letter 'l' or 'n'",
231 ),
232 Self::TldrManualSection { section } => write!(
233 formatter,
234 "manual section '{section}' does not identify a command quick reference; tldr supports section families 1 and 8"
235 ),
236 Self::InvalidSource => formatter.write_str("document source must not be empty"),
237 Self::ConflictingSourceSelectors => formatter.write_str(
238 "document source cannot be combined with a manual section or manual-only policy",
239 ),
240 Self::EmptyMarkdownPath => formatter.write_str("Markdown path must not be empty"),
241 Self::UnsupportedInputFormat { path } => write!(
242 formatter,
243 "could not infer the input format for '{path}'; use --input-format markdown or roff"
244 ),
245 Self::EmptySelection => formatter.write_str("at least one outline node is required"),
246 Self::EmptySelector => formatter.write_str("outline node must not be empty"),
247 Self::EmptyEntry => formatter.write_str("semantic entry must not be empty"),
248 Self::InvalidSearch(error) => error.fmt(formatter),
249 Self::Markdown { path, detail } => {
250 write!(
251 formatter,
252 "could not load Markdown document '{path}': {detail}"
253 )
254 }
255 Self::EmptyMarkdown { label } => {
256 write!(
257 formatter,
258 "Markdown document '{label}' has no readable content"
259 )
260 }
261 Self::Registry { detail } => formatter.write_str(detail),
262 Self::Manual(error) => error.fmt(formatter),
263 Self::ManualWithTldr { error, topic } => {
264 error.fmt(formatter)?;
265 write!(
266 formatter,
267 "\nhint: a tldr entry is available; run `mant {topic} --tldr`"
268 )
269 }
270 Self::TldrNotFound { topic } => {
271 write!(formatter, "no tldr quick reference was found for '{topic}'")
272 }
273 Self::Tldr { topic, detail } => {
274 write!(formatter, "could not load tldr entry '{topic}': {detail}")
275 }
276 Self::NoReadableContent { name } => {
277 write!(
278 formatter,
279 "no readable document content was found for '{name}'"
280 )
281 }
282 }
283 }
284}
285
286impl Error for QueryError {
287 fn source(&self) -> Option<&(dyn Error + 'static)> {
288 match self {
289 Self::InvalidSearch(error) => Some(error),
290 Self::Manual(error) | Self::ManualWithTldr { error, .. } => Some(error),
291 Self::EmptyName
292 | Self::InvalidManualSection
293 | Self::TldrManualSection { .. }
294 | Self::InvalidSource
295 | Self::ConflictingSourceSelectors
296 | Self::EmptyMarkdownPath
297 | Self::UnsupportedInputFormat { .. }
298 | Self::EmptySelection
299 | Self::EmptySelector
300 | Self::EmptyEntry
301 | Self::Markdown { .. }
302 | Self::EmptyMarkdown { .. }
303 | Self::Registry { .. }
304 | Self::TldrNotFound { .. }
305 | Self::Tldr { .. }
306 | Self::NoReadableContent { .. } => None,
307 }
308 }
309}
310
311impl fmt::Display for ManualLoadError {
312 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
313 match self {
314 Self::NotFound { name, detail } => {
315 write!(formatter, "could not load manual '{name}': {detail}")
316 }
317 Self::Parse { name, detail } => write!(
318 formatter,
319 "could not load manual '{name}': manual source: {detail}"
320 ),
321 Self::Empty {
322 name,
323 path,
324 diagnostics,
325 } => {
326 write!(
327 formatter,
328 "could not load manual '{name}': libmandoc parsed {} but produced no readable sections",
329 path.display()
330 )?;
331 if !diagnostics.is_empty() {
332 write!(formatter, "; diagnostics: {}", diagnostics.join("; "))?;
333 }
334 Ok(())
335 }
336 }
337 }
338}
339
340impl Error for ManualLoadError {}
341
342impl fmt::Display for QueryExecutionError {
343 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
344 match self {
345 Self::Query(error) => error.fmt(formatter),
346 Self::Projection(error) => error.fmt(formatter),
347 Self::Search(error) => error.fmt(formatter),
348 }
349 }
350}
351
352impl Error for QueryExecutionError {
353 fn source(&self) -> Option<&(dyn Error + 'static)> {
354 match self {
355 Self::Query(error) => Some(error),
356 Self::Projection(error) => Some(error),
357 Self::Search(error) => Some(error),
358 }
359 }
360}
361
362pub fn resolve_query(request: &QueryRequest) -> Result<ResolvedContent, QueryError> {
369 resolve_query_with_policy(request, QueryPolicy::default())
370}
371
372pub fn resolve_query_with_policy(
378 request: &QueryRequest,
379 policy: QueryPolicy,
380) -> Result<ResolvedContent, QueryError> {
381 let resolver = DocumentResolver::from_system();
382 resolver.resolve(request, policy)
383}
384
385pub fn execute_query(
391 request: &QueryRequest,
392 policy: QueryPolicy,
393) -> Result<QueryViewResult, QueryExecutionError> {
394 let resolver = DocumentResolver::from_system();
395 resolver.execute(request, policy)
396}
397
398pub fn project_query_view(
404 query: ResolvedContent,
405 view: &QueryView,
406) -> Result<QueryViewResult, QueryExecutionError> {
407 match view {
408 QueryView::Full {} => Ok(QueryViewResult::Full(Box::new(query))),
409 QueryView::Outline { detail } => build_outline_with_detail(&query, *detail)
410 .map(QueryViewResult::Outline)
411 .map_err(QueryExecutionError::Projection),
412 QueryView::Excerpt { selectors } => select_excerpt(&query, selectors)
413 .map(QueryViewResult::Excerpt)
414 .map_err(QueryExecutionError::Projection),
415 QueryView::Explain { entry } => select_explanation_with_text_hint(&query, entry)
416 .map(QueryViewResult::Excerpt)
417 .map_err(QueryExecutionError::Projection),
418 QueryView::Search {
419 pattern,
420 syntax,
421 case,
422 scope,
423 word,
424 context_lines,
425 limit,
426 offset,
427 } => search_query(
428 &query,
429 &SearchQuery {
430 pattern: pattern.clone(),
431 syntax: *syntax,
432 case: *case,
433 scope: *scope,
434 word: *word,
435 context_lines: *context_lines,
436 limit: *limit,
437 offset: *offset,
438 },
439 )
440 .map(QueryViewResult::Search)
441 .map_err(QueryExecutionError::Search),
442 }
443}
444
445fn select_explanation_with_text_hint(
446 query: &ResolvedContent,
447 entry: &str,
448) -> Result<QueryExcerpt, ProjectionError> {
449 match select_explanation(query, entry) {
450 Err(ProjectionError::UnknownSelector { document, selector }) => {
451 let probe = SearchQuery {
452 pattern: selector.clone(),
453 syntax: SearchSyntax::Literal,
454 case: SearchCase::Insensitive,
455 scope: SearchScope::Visible,
456 word: false,
457 context_lines: 0,
458 limit: 1,
459 offset: 0,
460 };
461 if let Some(found) = search_query(query, &probe)
462 .ok()
463 .and_then(|result| result.matches.into_iter().next())
464 {
465 let line = found
466 .occurrences
467 .first()
468 .map_or(1, |occurrence| occurrence.markdown.start_line);
469 return Err(ProjectionError::SelectorFoundOnlyInText {
470 document,
471 selector,
472 path: found.outline.path().to_owned(),
473 title: found.outline.title().to_owned(),
474 line,
475 });
476 }
477 Err(ProjectionError::UnknownSelector { document, selector })
478 }
479 result => result,
480 }
481}
482
483pub fn validate_query_request(
489 request: &QueryRequest,
490 policy: QueryPolicy,
491) -> Result<(), QueryError> {
492 match &request.input {
493 QueryInput::Document {
494 selector,
495 source,
496 manual_section,
497 } => {
498 if selector.trim().is_empty() {
499 return Err(QueryError::EmptyName);
500 }
501 if source
502 .as_deref()
503 .is_some_and(|value| value.trim().is_empty())
504 {
505 return Err(QueryError::InvalidSource);
506 }
507 if manual_section
508 .as_deref()
509 .is_some_and(|value| !crate::is_manual_section(value.trim()))
510 {
511 return Err(QueryError::InvalidManualSection);
512 }
513 if policy == QueryPolicy::TldrOnly
514 && let Some(section) = manual_section.as_deref()
515 && !crate::is_command_manual_section(section.trim())
516 {
517 return Err(QueryError::TldrManualSection {
518 section: section.trim().to_owned(),
519 });
520 }
521 if source.is_some() && (manual_section.is_some() || policy == QueryPolicy::ManualOnly) {
522 return Err(QueryError::ConflictingSourceSelectors);
523 }
524 }
525 QueryInput::File { path, .. } => {
526 if path.trim().is_empty() {
527 return Err(QueryError::EmptyMarkdownPath);
528 }
529 if policy != QueryPolicy::Combined {
530 return Err(QueryError::Markdown {
531 path: path.trim().to_owned(),
532 detail: "content-only policies do not apply to direct input".to_owned(),
533 });
534 }
535 }
536 }
537 match &request.view {
538 QueryView::Excerpt { selectors } => {
539 if selectors.is_empty() {
540 return Err(QueryError::EmptySelection);
541 }
542 if selectors.iter().any(|selector| selector.trim().is_empty()) {
543 return Err(QueryError::EmptySelector);
544 }
545 }
546 QueryView::Explain { entry } if entry.trim().is_empty() => {
547 return Err(QueryError::EmptyEntry);
548 }
549 QueryView::Search {
550 pattern,
551 syntax,
552 case,
553 scope,
554 word,
555 context_lines,
556 limit,
557 offset,
558 } => validate_search_query(&SearchQuery {
559 pattern: pattern.clone(),
560 syntax: *syntax,
561 case: *case,
562 scope: *scope,
563 word: *word,
564 context_lines: *context_lines,
565 limit: *limit,
566 offset: *offset,
567 })
568 .map_err(QueryError::InvalidSearch)?,
569 QueryView::Full {} | QueryView::Outline { .. } | QueryView::Explain { .. } => {}
570 }
571 Ok(())
572}
573
574trait QueryHost {
575 fn name_candidates(&self, name: &str) -> Vec<String>;
576 fn locate_registered_document(
577 &self,
578 candidates: &[String],
579 source: Option<&str>,
580 phase: RegisteredLookupPhase,
581 ) -> Result<Option<RegisteredSelection>, String>;
582 fn locate_registered_document_groups(
583 &self,
584 candidates: &[String],
585 source: Option<&str>,
586 phase: RegisteredLookupPhase,
587 ) -> Result<Vec<RegisteredSelectionGroup>, String>;
588 fn locate_registered_address(
589 &self,
590 address: &DocumentAddress,
591 ) -> Result<Option<RegisteredSelection>, String>;
592 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String>;
593 fn parse_manual(&self, page: &ManualPage) -> Result<Document, String>;
594 fn parse_manual_input(&self, path: &Path) -> Result<Document, String>;
595 fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String>;
596 fn read_markdown(&self, path: &Path) -> Result<String, String>;
597}
598
599#[derive(Clone, Copy)]
600enum RegisteredLookupPhase {
601 BeforeBuiltin,
602 AfterBuiltin,
603}
604
605#[derive(Clone)]
606struct RegisteredSelection {
607 path: PathBuf,
608 address: DocumentAddress,
609}
610
611struct RegisteredSelectionGroup {
612 documents: Vec<RegisteredSelection>,
613}
614
615fn registered_selection(document: &mant_sources::RegisteredDocument) -> RegisteredSelection {
616 RegisteredSelection {
617 path: document.path.clone(),
618 address: DocumentAddress::Markdown {
619 path: document.logical_path.clone(),
620 origin: match &document.origin {
621 RegisteredDocumentOrigin::Documents => MarkdownOrigin::Documents,
622 RegisteredDocumentOrigin::Source(name) => {
623 MarkdownOrigin::Source { name: name.clone() }
624 }
625 },
626 },
627 }
628}
629
630struct LoadedManual {
631 document: Document,
632 address: DocumentAddress,
633}
634
635pub struct DocumentResolver {
637 registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
638 manual_roots: Vec<PathBuf>,
639 manuals: OnceLock<ManualIndex>,
640 available: OnceLock<Vec<crate::catalog::AvailableDocument>>,
641}
642
643impl DocumentResolver {
644 #[must_use]
647 pub fn from_system() -> Self {
648 Self {
649 registered: OnceLock::new(),
650 manual_roots: discover_manual_roots(),
651 manuals: OnceLock::new(),
652 available: OnceLock::new(),
653 }
654 }
655
656 pub fn resolve(
666 &self,
667 request: &QueryRequest,
668 policy: QueryPolicy,
669 ) -> Result<ResolvedContent, QueryError> {
670 validate_query_request(request, policy)?;
671 query_with(request, policy, self)
672 }
673
674 pub fn execute(
680 &self,
681 request: &QueryRequest,
682 policy: QueryPolicy,
683 ) -> Result<QueryViewResult, QueryExecutionError> {
684 let query = self
685 .resolve(request, policy)
686 .map_err(QueryExecutionError::Query)?;
687 project_query_view(query, &request.view)
688 }
689
690 pub fn discover(&self, query: &CatalogQuery) -> Result<DocumentCatalog, String> {
698 let registered = self
699 .registered
700 .get_or_init(RegisteredDocumentIndex::load)
701 .as_ref()
702 .map_err(ToString::to_string)?;
703 let manuals = self
704 .manuals
705 .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
706 let documents = self.available.get_or_init(|| {
707 crate::catalog::list_available_documents_from(
708 registered.documents().to_vec(),
709 manuals.pages(),
710 )
711 });
712 crate::catalog::query_available_documents(documents, query)
713 .map_err(|error| error.to_string())
714 }
715}
716
717impl QueryHost for DocumentResolver {
718 fn name_candidates(&self, name: &str) -> Vec<String> {
719 query_name_candidates(name)
720 }
721
722 fn locate_registered_document(
723 &self,
724 candidates: &[String],
725 source: Option<&str>,
726 phase: RegisteredLookupPhase,
727 ) -> Result<Option<RegisteredSelection>, String> {
728 let index = self
729 .registered
730 .get_or_init(RegisteredDocumentIndex::load)
731 .as_ref()
732 .map_err(ToString::to_string)?;
733 let selected = if source.is_some() {
734 index.find(candidates, source)
735 } else {
736 match phase {
737 RegisteredLookupPhase::BeforeBuiltin => index.find_before_builtin(candidates),
738 RegisteredLookupPhase::AfterBuiltin => index.find_after_builtin(candidates),
739 }
740 };
741 selected
742 .map(|registered| registered.map(registered_selection))
743 .map_err(|error| error.to_string())
744 }
745
746 fn locate_registered_document_groups(
747 &self,
748 candidates: &[String],
749 source: Option<&str>,
750 phase: RegisteredLookupPhase,
751 ) -> Result<Vec<RegisteredSelectionGroup>, String> {
752 let index = self
753 .registered
754 .get_or_init(RegisteredDocumentIndex::load)
755 .as_ref()
756 .map_err(ToString::to_string)?;
757 let groups = if let Some(source) = source {
758 index.matches_in_source(candidates, source)
759 } else {
760 Ok(match phase {
761 RegisteredLookupPhase::BeforeBuiltin => index.matches_before_builtin(candidates),
762 RegisteredLookupPhase::AfterBuiltin => index.matches_after_builtin(candidates),
763 })
764 }
765 .map_err(|error| error.to_string())?;
766 Ok(groups
767 .into_iter()
768 .map(|group| RegisteredSelectionGroup {
769 documents: group.documents.iter().map(registered_selection).collect(),
770 })
771 .collect())
772 }
773
774 fn locate_registered_address(
775 &self,
776 address: &DocumentAddress,
777 ) -> Result<Option<RegisteredSelection>, String> {
778 let DocumentAddress::Markdown { path, origin } = address else {
779 return Ok(None);
780 };
781 let origin = match origin {
782 MarkdownOrigin::Documents => RegisteredDocumentOrigin::Documents,
783 MarkdownOrigin::Source { name } => RegisteredDocumentOrigin::Source(name.clone()),
784 };
785 let index = self
786 .registered
787 .get_or_init(RegisteredDocumentIndex::load)
788 .as_ref()
789 .map_err(ToString::to_string)?;
790 index
791 .find_address(path, &origin)
792 .map(|document| {
793 document.map(|document| RegisteredSelection {
794 path: document.path.clone(),
795 address: address.clone(),
796 })
797 })
798 .map_err(|error| error.to_string())
799 }
800
801 fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
802 let manuals = self
803 .manuals
804 .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
805 locate_manual_source_in(request, manuals).map_err(|error| error.load_detail())
806 }
807
808 fn parse_manual(&self, page: &ManualPage) -> Result<Document, String> {
809 parse_manual_page(page).map_err(|error| error.to_string())
810 }
811
812 fn parse_manual_input(&self, path: &Path) -> Result<Document, String> {
813 parse_manual_source(path).map_err(|error| error.to_string())
814 }
815
816 fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
817 read_cached_tldr_page(name).map_err(|error| error.to_string())
818 }
819
820 fn read_markdown(&self, path: &Path) -> Result<String, String> {
821 let file = fs::File::open(path).map_err(|error| error.to_string())?;
822 read_capped_utf8(file, MAX_MARKDOWN_BYTES)
823 }
824}
825
826fn read_capped_utf8(reader: impl Read, limit: u64) -> Result<String, String> {
832 read_capped_utf8_io(reader, limit).map_err(|error| error.to_string())
833}
834
835pub(crate) fn read_capped_utf8_io(reader: impl Read, limit: u64) -> io::Result<String> {
837 crate::bounded::read_utf8(reader, limit, "Markdown document")
838}
839
840#[cfg(test)]
841mod tests;