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