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