1use 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
29pub 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 #[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 pub fn load(
57 &self,
58 spec: LoadSpec<'_>,
59 policy: LoadPolicy,
60 ) -> Result<ResolvedContent, LoadError> {
61 load_with(spec, policy, self)
62 }
63
64 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 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}