1use std::{error::Error, fmt};
4
5use mant_ast::{TldrCommandPart, TldrDocument, TldrExample, TldrOrigin};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct TldrPageLocation {
10 pub platform: String,
11 pub language: String,
12 pub source_path: String,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TldrParseError {
18 MissingCommandHeading,
19}
20
21impl fmt::Display for TldrParseError {
22 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::MissingCommandHeading => {
25 formatter.write_str("tldr page is missing its command heading")
26 }
27 }
28 }
29}
30
31impl Error for TldrParseError {}
32
33#[must_use]
35pub fn parse_tldr_command(command: &str) -> Vec<TldrCommandPart> {
36 let mut parts = Vec::new();
37 let mut cursor = 0;
38
39 while cursor < command.len() {
40 let remainder = &command[cursor..];
41 if let Some(escaped) = remainder.strip_prefix(r"\{\{")
42 && let Some(close) = escaped.find(r"\}\}")
43 {
44 push_part(
45 &mut parts,
46 PartKind::Text,
47 format!("{{{{{}}}}}", &escaped[..close]),
48 );
49 cursor += 4 + close + 4;
50 continue;
51 }
52
53 if let Some(placeholder) = remainder.strip_prefix("{{")
54 && let Some(close) = placeholder.find("}}")
55 {
56 let value =
57 resolve_option_placeholder(&placeholder[..close]).unwrap_or(&placeholder[..close]);
58 push_part(&mut parts, PartKind::Placeholder, value.to_owned());
59 cursor += 2 + close + 2;
60 continue;
61 }
62
63 let Some(character) = remainder.chars().next() else {
64 break;
65 };
66 push_part(&mut parts, PartKind::Text, character.to_string());
67 cursor += character.len_utf8();
68 }
69
70 parts
71}
72
73pub fn parse_tldr_page(
80 markdown: &str,
81 location: TldrPageLocation,
82) -> Result<TldrDocument, TldrParseError> {
83 let normalized = markdown.replace("\r\n", "\n").replace('\r', "\n");
84 let mut title = String::new();
85 let mut description = Vec::new();
86 let mut more_information = None;
87 let mut examples = Vec::new();
88 let mut pending_description = None;
89
90 for line in normalized.lines() {
91 let trimmed = line.trim();
92 if trimmed.is_empty() {
93 continue;
94 }
95
96 if title.is_empty()
97 && let Some(heading) = trimmed.strip_prefix("# ")
98 {
99 title = flatten_markdown(heading);
100 continue;
101 }
102
103 if let Some(quote) = trimmed.strip_prefix('>') {
104 let quote = flatten_markdown(quote);
105 if let Some(value) = strip_prefix_ascii_case("e, "More information:") {
106 let value = value.trim();
107 if !value.is_empty() {
108 more_information = Some(value.to_owned());
109 }
110 } else if !quote.is_empty() {
111 description.push(quote);
112 }
113 continue;
114 }
115
116 if let Some(item) = trimmed.strip_prefix("- ") {
117 flush_pending(&mut pending_description, &mut examples);
118 if let Some((example_description, command)) = extract_trailing_code(item) {
119 examples.push(make_example(example_description, command));
120 } else {
121 let value = flatten_markdown(item.trim_end_matches(':'));
122 if !value.is_empty() {
123 pending_description = Some(value);
124 }
125 }
126 continue;
127 }
128
129 if let Some(command) = standalone_code(trimmed)
130 && let Some(example_description) = pending_description.take()
131 {
132 examples.push(make_example(example_description, command.to_owned()));
133 }
134 }
135
136 flush_pending(&mut pending_description, &mut examples);
137 if title.is_empty() {
138 return Err(TldrParseError::MissingCommandHeading);
139 }
140
141 Ok(TldrDocument {
142 title,
143 description,
144 more_information,
145 examples,
146 platform: location.platform,
147 language: location.language,
148 source_path: location.source_path,
149 origin: TldrOrigin::TldrPages,
150 })
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154enum PartKind {
155 Text,
156 Placeholder,
157}
158
159fn push_part(parts: &mut Vec<TldrCommandPart>, kind: PartKind, value: String) {
160 if value.is_empty() {
161 return;
162 }
163 match (parts.last_mut(), kind) {
164 (Some(TldrCommandPart::Text { value: previous }), PartKind::Text)
165 | (Some(TldrCommandPart::Placeholder { value: previous }), PartKind::Placeholder) => {
166 previous.push_str(&value);
167 }
168 (_, PartKind::Text) => parts.push(TldrCommandPart::Text { value }),
169 (_, PartKind::Placeholder) => parts.push(TldrCommandPart::Placeholder { value }),
170 }
171}
172
173fn resolve_option_placeholder(value: &str) -> Option<&str> {
174 let choices = value.strip_prefix('[')?.strip_suffix(']')?;
175 let (_, long) = choices.split_once('|')?;
176 (!long.is_empty()).then_some(long)
177}
178
179fn flush_pending(pending: &mut Option<String>, examples: &mut Vec<TldrExample>) {
180 if let Some(description) = pending.take() {
181 examples.push(make_example(description, String::new()));
182 }
183}
184
185fn make_example(description: String, command: String) -> TldrExample {
186 TldrExample {
187 description,
188 command_parts: parse_tldr_command(&command),
189 command,
190 }
191}
192
193fn extract_trailing_code(value: &str) -> Option<(String, String)> {
194 let trimmed = value.trim_end();
195 let close = trimmed.strip_suffix('`')?;
196 let open = close.rfind('`')?;
197 let command = &close[open + 1..];
198 if command.is_empty() || command.contains('`') {
199 return None;
200 }
201 let description = flatten_markdown(close[..open].trim_end().trim_end_matches(':'));
202 Some((description, command.to_owned()))
203}
204
205fn standalone_code(value: &str) -> Option<&str> {
206 value.strip_prefix('`')?.strip_suffix('`')
207}
208
209fn strip_prefix_ascii_case<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
210 let candidate = value.get(..prefix.len())?;
211 candidate
212 .eq_ignore_ascii_case(prefix)
213 .then(|| &value[prefix.len()..])
214}
215
216fn flatten_markdown(value: &str) -> String {
217 let mut flattened = flatten_links(value);
218 for marker in ["**", "__", "*", "_"] {
219 flattened = strip_paired_marker(&flattened, marker);
220 }
221 flattened = flattened.replace(['`', '<', '>'], "");
222 flattened.split_whitespace().collect::<Vec<_>>().join(" ")
223}
224
225fn flatten_links(value: &str) -> String {
226 let mut flattened = String::new();
227 let mut remainder = value;
228 while let Some(open) = remainder.find('[') {
229 flattened.push_str(&remainder[..open]);
230 let after_open = &remainder[open + 1..];
231 let Some(label_end) = after_open.find("](") else {
232 flattened.push_str(&remainder[open..]);
233 return flattened;
234 };
235 let after_target_open = &after_open[label_end + 2..];
236 let Some(target_end) = after_target_open.find(')') else {
237 flattened.push_str(&remainder[open..]);
238 return flattened;
239 };
240 flattened.push_str(&after_open[..label_end]);
241 remainder = &after_target_open[target_end + 1..];
242 }
243 flattened.push_str(remainder);
244 flattened
245}
246
247fn strip_paired_marker(value: &str, marker: &str) -> String {
248 let mut stripped = String::new();
249 let mut remainder = value;
250 while let Some(open) = remainder.find(marker) {
251 let after_open = &remainder[open + marker.len()..];
252 let Some(close) = after_open.find(marker) else {
253 break;
254 };
255 stripped.push_str(&remainder[..open]);
256 stripped.push_str(&after_open[..close]);
257 remainder = &after_open[close + marker.len()..];
258 }
259 stripped.push_str(remainder);
260 stripped
261}
262
263#[cfg(test)]
264mod tests {
265 use mant_ast::TldrCommandPart;
266
267 use super::{TldrPageLocation, TldrParseError, parse_tldr_command, parse_tldr_page};
268
269 const PAGE: &str = r"# tar
270
271> Archiving utility.
272> More information: <https://www.gnu.org/software/tar>.
273
274- Create an archive:
275 `tar {{[-c|--create]}} {{path/to/archive.tar}} {{path/to/file}}`
276
277- Extract an archive: `tar --extract --file {{path/to/archive.tar}}`
278";
279
280 fn location() -> TldrPageLocation {
281 TldrPageLocation {
282 platform: "linux".to_owned(),
283 language: "en".to_owned(),
284 source_path: "/cache/pages/linux/tar.md".to_owned(),
285 }
286 }
287
288 #[test]
289 fn parses_examples_markup_and_long_option_placeholders() {
290 let page = parse_tldr_page(PAGE, location()).expect("valid tldr page");
291
292 assert_eq!(page.title, "tar");
293 assert_eq!(page.description, ["Archiving utility."]);
294 assert_eq!(
295 page.more_information.as_deref(),
296 Some("https://www.gnu.org/software/tar.")
297 );
298 assert_eq!(page.examples.len(), 2);
299 assert_eq!(
300 page.examples[0].command,
301 "tar {{[-c|--create]}} {{path/to/archive.tar}} {{path/to/file}}"
302 );
303 assert_eq!(
304 page.examples[0].command_parts,
305 [
306 TldrCommandPart::Text {
307 value: "tar ".to_owned()
308 },
309 TldrCommandPart::Placeholder {
310 value: "--create".to_owned()
311 },
312 TldrCommandPart::Text {
313 value: " ".to_owned()
314 },
315 TldrCommandPart::Placeholder {
316 value: "path/to/archive.tar".to_owned()
317 },
318 TldrCommandPart::Text {
319 value: " ".to_owned()
320 },
321 TldrCommandPart::Placeholder {
322 value: "path/to/file".to_owned()
323 },
324 ]
325 );
326 }
327
328 #[test]
329 fn preserves_escaped_braces_and_unicode_text() {
330 assert_eq!(
331 parse_tldr_command(r"echo \{\{不是占位符\}\} {{值}}"),
332 [
333 TldrCommandPart::Text {
334 value: "echo {{不是占位符}} ".to_owned()
335 },
336 TldrCommandPart::Placeholder {
337 value: "值".to_owned()
338 },
339 ]
340 );
341 }
342
343 #[test]
344 fn accepts_inline_examples_and_flattens_description_markup() {
345 let page = parse_tldr_page(
346 "# demo\n> Use **demo** with [docs](https://example.test).\n- Run it: `demo _x_`\n",
347 location(),
348 )
349 .expect("valid tldr page");
350
351 assert_eq!(page.description, ["Use demo with docs."]);
352 assert_eq!(page.examples[0].description, "Run it");
353 assert_eq!(page.examples[0].command, "demo _x_");
354 }
355
356 #[test]
357 fn retains_an_example_description_when_its_command_is_missing() {
358 let page =
359 parse_tldr_page("# demo\n- Explain only:\n", location()).expect("valid tldr page");
360 assert_eq!(page.examples[0].description, "Explain only");
361 assert!(page.examples[0].command.is_empty());
362 }
363
364 #[test]
365 fn rejects_a_page_without_a_command_heading() {
366 assert_eq!(
367 parse_tldr_page("> description only", location()),
368 Err(TldrParseError::MissingCommandHeading)
369 );
370 }
371}