mant_loader/loading/
input.rs1#[cfg(feature = "roff")]
4use super::parse_manual_bytes;
5use super::{
6 InputFormat, LoadError, LoadHost, LoadPolicy, LoadSpec, ManualLoadError, OsStr, Path,
7 ResolvedContent, query_named_document,
8};
9use mant_codec::parse_markdown;
10
11pub(super) fn load_with(
12 spec: LoadSpec<'_>,
13 policy: LoadPolicy,
14 host: &dyn LoadHost,
15) -> Result<ResolvedContent, LoadError> {
16 super::validate_load_spec(spec, policy)?;
17 match spec {
18 LoadSpec::Document {
19 selector,
20 source,
21 manual_section,
22 } => query_named_document(selector, source, manual_section, policy, host),
23 LoadSpec::File { path, format } => query_input_file(path, format, policy, host),
24 }
25}
26
27fn query_input_file(
28 requested_path: &str,
29 format: InputFormat,
30 policy: LoadPolicy,
31 host: &dyn LoadHost,
32) -> Result<ResolvedContent, LoadError> {
33 let path = requested_path.trim();
34 if path.is_empty() {
35 return Err(LoadError::EmptyMarkdownPath);
36 }
37 let format = match format {
38 InputFormat::Auto => {
39 detect_input_format(path).ok_or_else(|| LoadError::UnsupportedInputFormat {
40 path: path.to_owned(),
41 })?
42 }
43 format => format,
44 };
45 match format {
46 InputFormat::Markdown => query_markdown_file(path, policy, host),
47 InputFormat::Roff => {
48 if policy != LoadPolicy::Combined {
49 return Err(LoadError::ConflictingSourceSelectors);
50 }
51 if !host.native_available() {
52 return Err(LoadError::NativeBackendUnavailable { tldr_topic: None });
53 }
54 let document = host.parse_manual_input(Path::new(path)).map_err(|detail| {
55 LoadError::Manual(ManualLoadError::Parse {
56 name: path.to_owned(),
57 detail,
58 })
59 })?;
60 if document.sections.is_empty() && document.blocks.is_empty() {
61 return Err(LoadError::NoReadableContent {
62 name: path.to_owned(),
63 });
64 }
65 let label = document
66 .meta
67 .names
68 .first()
69 .cloned()
70 .or_else(|| document.meta.title.clone())
71 .unwrap_or_else(|| input_file_label(path));
72 Ok(ResolvedContent {
73 label,
74 address: None,
75 document: Some(document),
76 tldr: None,
77 })
78 }
79 InputFormat::Auto => unreachable!("auto input was resolved above"),
80 }
81}
82
83fn detect_input_format(path: &str) -> Option<InputFormat> {
84 let mut name = Path::new(path).file_name()?.to_str()?.to_ascii_lowercase();
85 let mut compressed = false;
86 if Path::new(&name)
87 .extension()
88 .and_then(OsStr::to_str)
89 .is_some_and(|extension| matches!(extension, "gz" | "zst"))
90 {
91 name = Path::new(&name).file_stem()?.to_str()?.to_owned();
92 compressed = true;
93 }
94 let extension = Path::new(&name).extension()?.to_str()?;
95 if matches!(extension, "md" | "markdown") {
96 return (!compressed).then_some(InputFormat::Markdown);
97 }
98 if matches!(extension, "roff" | "man" | "mdoc") {
99 return Some(InputFormat::Roff);
100 }
101 crate::is_manual_section(extension).then_some(InputFormat::Roff)
102}
103
104fn input_file_label(path: &str) -> String {
105 Path::new(path)
106 .file_name()
107 .and_then(OsStr::to_str)
108 .unwrap_or(path)
109 .to_owned()
110}
111
112fn query_markdown_file(
113 requested_path: &str,
114 policy: LoadPolicy,
115 host: &dyn LoadHost,
116) -> Result<ResolvedContent, LoadError> {
117 let path = requested_path.trim();
118 if path.is_empty() {
119 return Err(LoadError::EmptyMarkdownPath);
120 }
121 if policy != LoadPolicy::Combined {
122 return Err(LoadError::Markdown {
123 path: path.to_owned(),
124 detail: "content-only policies do not apply to direct input".to_owned(),
125 });
126 }
127 let source = host
128 .read_markdown(Path::new(path))
129 .map_err(|detail| LoadError::Markdown {
130 path: path.to_owned(),
131 detail,
132 })?;
133 load_markdown_text(&source, Some(path.to_owned()))
134}
135
136pub fn load_markdown_text(
146 source: &str,
147 source_path: Option<String>,
148) -> Result<ResolvedContent, LoadError> {
149 let label = source_path.as_deref().map_or_else(
150 || "stdin".to_owned(),
151 |path| {
152 Path::new(path)
153 .file_name()
154 .and_then(OsStr::to_str)
155 .unwrap_or(path)
156 .to_owned()
157 },
158 );
159 let error_path = source_path.clone().unwrap_or_else(|| "stdin".to_owned());
160 let parsed = parse_markdown(source, source_path).map_err(|error| LoadError::Markdown {
161 path: error_path,
162 detail: error.to_string(),
163 })?;
164 let document_is_empty = parsed.document.heading.is_none()
165 && parsed.document.blocks.is_empty()
166 && parsed.document.sections.is_empty();
167 if document_is_empty && parsed.tldr.is_none() {
168 return Err(LoadError::EmptyMarkdown {
169 label: label.clone(),
170 });
171 }
172 Ok(ResolvedContent {
173 address: None,
174 label,
175 document: (!document_is_empty).then_some(parsed.document),
176 tldr: parsed.tldr,
177 })
178}
179
180#[cfg(feature = "roff")]
186pub fn load_roff_bytes(source: &[u8]) -> Result<ResolvedContent, LoadError> {
187 if u64::try_from(source.len()).unwrap_or(u64::MAX) > crate::MAX_MANUAL_BYTES {
188 return Err(LoadError::Manual(ManualLoadError::Parse {
189 name: "stdin".to_owned(),
190 detail: format!(
191 "roff input exceeds the {}-byte limit",
192 crate::MAX_MANUAL_BYTES
193 ),
194 }));
195 }
196 let document = parse_manual_bytes(Path::new("stdin"), source).map_err(|error| {
197 LoadError::Manual(ManualLoadError::Parse {
198 name: "stdin".to_owned(),
199 detail: error.to_string(),
200 })
201 })?;
202 if document.sections.is_empty() && document.blocks.is_empty() {
203 return Err(LoadError::NoReadableContent {
204 name: "stdin".to_owned(),
205 });
206 }
207 let label = document
208 .meta
209 .names
210 .first()
211 .cloned()
212 .or_else(|| document.meta.title.clone())
213 .unwrap_or_else(|| "stdin".to_owned());
214 Ok(ResolvedContent {
215 address: None,
216 label,
217 document: Some(document),
218 tldr: None,
219 })
220}