Skip to main content

mant_loader/loading/
resolver.rs

1//! Explicit local-environment snapshot for view-independent acquisition.
2use super::{
3    CatalogQuery, Document, DocumentAddress, DocumentCatalog, LoadError, LoadHost, LoadPolicy,
4    LoadSpec, MAX_MARKDOWN_BYTES, ManualIndex, ManualPage, ManualRequest, MarkdownOrigin, OnceLock,
5    Path, PathBuf, RegisteredDocumentIndex, RegisteredDocumentOrigin, RegisteredLookupPhase,
6    RegisteredSelection, RegisteredSelectionGroup, SourceConfigError, TldrDocument,
7    discover_manual_roots, fs, load_with, locate_manual_source_in, query_name_candidates,
8    read_cached_tldr_page, read_capped_utf8,
9};
10#[cfg(feature = "roff")]
11use super::{parse_manual_page, parse_manual_source};
12use mant_ir::ResolvedContent;
13
14fn registered_selection(document: &mant_sources::RegisteredDocument) -> RegisteredSelection {
15    RegisteredSelection {
16        path: document.path.clone(),
17        address: DocumentAddress::Markdown {
18            path: document.logical_path.clone(),
19            origin: match &document.origin {
20                RegisteredDocumentOrigin::Documents => MarkdownOrigin::Documents,
21                RegisteredDocumentOrigin::Source(name) => {
22                    MarkdownOrigin::Source { name: name.clone() }
23                }
24            },
25        },
26    }
27}
28
29/// One explicit local document-environment snapshot.
30pub struct DocumentLoader {
31    registered: OnceLock<Result<RegisteredDocumentIndex, SourceConfigError>>,
32    manual_roots: Vec<PathBuf>,
33    manuals: OnceLock<ManualIndex>,
34    available: OnceLock<Vec<crate::catalog::AvailableDocument>>,
35}
36
37impl DocumentLoader {
38    /// Capture native manual roots and lazily snapshot the manual index and
39    /// Markdown registration.
40    #[must_use]
41    pub fn from_system() -> Self {
42        Self {
43            registered: OnceLock::new(),
44            manual_roots: discover_manual_roots(),
45            manuals: OnceLock::new(),
46            available: OnceLock::new(),
47        }
48    }
49
50    /// Load one borrowed source specification against this environment snapshot.
51    /// Reusing a loader preserves manual and registered-document precedence.
52    /// Construct a new loader to refresh discovery.
53    ///
54    /// # Errors
55    /// Returns invalid source-selection input or unreadable local content.
56    pub fn load(
57        &self,
58        spec: LoadSpec<'_>,
59        policy: LoadPolicy,
60    ) -> Result<ResolvedContent, LoadError> {
61        load_with(spec, policy, self)
62    }
63
64    /// Filter the same registered-document and manual snapshots used by
65    /// [`Self::load`].
66    ///
67    /// # Errors
68    ///
69    /// Returns source-configuration or catalog-query failures as one host
70    /// boundary diagnostic.
71    pub fn discover(&self, query: &CatalogQuery) -> Result<DocumentCatalog, String> {
72        let plan =
73            crate::catalog::PreparedCatalogQuery::new(query).map_err(|error| error.to_string())?;
74        self.discover_prepared(&plan)
75    }
76
77    /// Discover using an already validated and compiled catalog query.
78    ///
79    /// This reuses both the supplied matcher and this loader's environment
80    /// snapshot. No source lookup occurs while the query is being prepared.
81    ///
82    /// # Errors
83    /// Returns source configuration or catalog acquisition failures.
84    pub fn discover_prepared(
85        &self,
86        plan: &crate::PreparedCatalogQuery<'_>,
87    ) -> Result<DocumentCatalog, String> {
88        let registered = self
89            .registered
90            .get_or_init(RegisteredDocumentIndex::load)
91            .as_ref()
92            .map_err(ToString::to_string)?;
93        let manuals = self
94            .manuals
95            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
96        let documents = self.available.get_or_init(|| {
97            crate::catalog::list_available_documents_from(
98                registered.documents().to_vec(),
99                manuals.pages(),
100            )
101        });
102        Ok(plan.apply(documents))
103    }
104}
105
106impl LoadHost for DocumentLoader {
107    fn native_available(&self) -> bool {
108        cfg!(feature = "roff")
109    }
110    fn name_candidates(&self, name: &str) -> Vec<String> {
111        query_name_candidates(name)
112    }
113
114    fn locate_registered_document(
115        &self,
116        candidates: &[String],
117        source: Option<&str>,
118        phase: RegisteredLookupPhase,
119    ) -> Result<Option<RegisteredSelection>, String> {
120        let index = self
121            .registered
122            .get_or_init(RegisteredDocumentIndex::load)
123            .as_ref()
124            .map_err(ToString::to_string)?;
125        let selected = if source.is_some() {
126            index.find(candidates, source)
127        } else {
128            match phase {
129                RegisteredLookupPhase::BeforeBuiltin => index.find_before_builtin(candidates),
130                RegisteredLookupPhase::AfterBuiltin => index.find_after_builtin(candidates),
131            }
132        };
133        selected
134            .map(|registered| registered.map(registered_selection))
135            .map_err(|error| error.to_string())
136    }
137
138    fn locate_registered_document_groups(
139        &self,
140        candidates: &[String],
141        source: Option<&str>,
142        phase: RegisteredLookupPhase,
143    ) -> Result<Vec<RegisteredSelectionGroup>, String> {
144        let index = self
145            .registered
146            .get_or_init(RegisteredDocumentIndex::load)
147            .as_ref()
148            .map_err(ToString::to_string)?;
149        let groups = if let Some(source) = source {
150            index.matches_in_source(candidates, source)
151        } else {
152            Ok(match phase {
153                RegisteredLookupPhase::BeforeBuiltin => index.matches_before_builtin(candidates),
154                RegisteredLookupPhase::AfterBuiltin => index.matches_after_builtin(candidates),
155            })
156        }
157        .map_err(|error| error.to_string())?;
158        Ok(groups
159            .into_iter()
160            .map(|group| RegisteredSelectionGroup {
161                documents: group.documents.iter().map(registered_selection).collect(),
162            })
163            .collect())
164    }
165
166    fn locate_registered_address(
167        &self,
168        address: &DocumentAddress,
169    ) -> Result<Option<RegisteredSelection>, String> {
170        let DocumentAddress::Markdown { path, origin } = address else {
171            return Ok(None);
172        };
173        let origin = match origin {
174            MarkdownOrigin::Documents => RegisteredDocumentOrigin::Documents,
175            MarkdownOrigin::Source { name } => RegisteredDocumentOrigin::Source(name.clone()),
176        };
177        let index = self
178            .registered
179            .get_or_init(RegisteredDocumentIndex::load)
180            .as_ref()
181            .map_err(ToString::to_string)?;
182        index
183            .find_address(path, &origin)
184            .map(|document| {
185                document.map(|document| RegisteredSelection {
186                    path: document.path.clone(),
187                    address: address.clone(),
188                })
189            })
190            .map_err(|error| error.to_string())
191    }
192
193    fn locate_manual(&self, request: &ManualRequest) -> Result<ManualPage, String> {
194        let manuals = self
195            .manuals
196            .get_or_init(|| ManualIndex::from_roots(self.manual_roots.clone()));
197        locate_manual_source_in(request, manuals).map_err(|error| error.load_detail())
198    }
199
200    fn parse_manual(&self, page: &ManualPage) -> Result<Document, String> {
201        #[cfg(feature = "roff")]
202        {
203            parse_manual_page(page).map_err(|error| error.to_string())
204        }
205        #[cfg(not(feature = "roff"))]
206        {
207            let _ = page;
208            Err(LoadError::NativeBackendUnavailable { tldr_topic: None }.to_string())
209        }
210    }
211
212    fn parse_manual_input(&self, path: &Path) -> Result<Document, String> {
213        #[cfg(feature = "roff")]
214        {
215            parse_manual_source(path).map_err(|error| error.to_string())
216        }
217        #[cfg(not(feature = "roff"))]
218        {
219            let _ = path;
220            Err(LoadError::NativeBackendUnavailable { tldr_topic: None }.to_string())
221        }
222    }
223
224    fn read_tldr(&self, name: &str) -> Result<Option<TldrDocument>, String> {
225        read_cached_tldr_page(name).map_err(|error| error.to_string())
226    }
227
228    fn read_markdown(&self, path: &Path) -> Result<String, String> {
229        let file = fs::File::open(path).map_err(|error| error.to_string())?;
230        read_capped_utf8(file, MAX_MARKDOWN_BYTES)
231    }
232}
233
234#[cfg(all(test, not(feature = "roff")))]
235mod no_roff_tests {
236    use super::*;
237
238    #[test]
239    fn markdown_only_loader_never_opens_selected_native_inputs() {
240        let loader = DocumentLoader {
241            registered: OnceLock::new(),
242            manual_roots: Vec::new(),
243            manuals: OnceLock::new(),
244            available: OnceLock::new(),
245        };
246        for spec in [
247            LoadSpec::File {
248                path: "does-not-exist.1",
249                format: mant_protocol::InputFormat::Auto,
250            },
251            LoadSpec::Document {
252                selector: "manual/1/does-not-exist",
253                source: None,
254                manual_section: None,
255            },
256        ] {
257            assert_eq!(
258                loader.load(spec, LoadPolicy::Combined),
259                Err(LoadError::NativeBackendUnavailable { tldr_topic: None })
260            );
261        }
262        assert!(loader.manuals.get().is_none());
263        assert!(loader.registered.get().is_none());
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn invalid_discovery_preserves_uninitialized_snapshot_indexes() {
273        let loader = DocumentLoader {
274            registered: OnceLock::new(),
275            manual_roots: Vec::new(),
276            manuals: OnceLock::new(),
277            available: OnceLock::new(),
278        };
279        for query in [
280            CatalogQuery {
281                limit: 0,
282                ..CatalogQuery::default()
283            },
284            CatalogQuery {
285                pattern: Some("[".into()),
286                syntax: mant_protocol::SearchSyntax::Regex,
287                ..CatalogQuery::default()
288            },
289        ] {
290            assert!(loader.discover(&query).is_err());
291            assert!(loader.registered.get().is_none());
292            assert!(loader.manuals.get().is_none());
293            assert!(loader.available.get().is_none());
294        }
295    }
296}