Skip to main content

mant_core/tldr/
parser.rs

1//! Parses the constrained tldr-pages Markdown dialect into the shared AST.
2
3use std::{error::Error, fmt};
4
5use mant_ast::{TldrCommandPart, TldrDocument, TldrExample, TldrOrigin};
6
7/// Source identity attached to a parsed tldr page.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct TldrPageLocation {
10    pub platform: String,
11    pub language: String,
12    pub source_path: String,
13}
14
15/// A tldr page lacks the minimum structure required by the contract.
16#[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/// Parse the tldr placeholder extension and choose the long option variant.
34#[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
73/// Parse one tldr Markdown page without performing any I/O.
74///
75/// # Errors
76///
77/// Returns [`TldrParseError::MissingCommandHeading`] when no `# command`
78/// heading is present.
79pub 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(&quote, "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}