Skip to main content

mant_engine/
manual.rs

1//! Shared native-manual selector semantics.
2
3/// Return whether a value is a conventional native manual section.
4///
5/// Numeric sections may carry an ASCII-alphanumeric extension such as `1p`
6/// or `3type`; the historical single-letter `l` and `n` sections are also
7/// accepted. The length bound keeps selectors finite and matches the public
8/// request boundary.
9#[must_use]
10pub fn is_manual_section(value: &str) -> bool {
11    if value.is_empty() || value.len() > 16 {
12        return false;
13    }
14    let mut characters = value.chars();
15    match characters.next() {
16        Some(first) if first.is_ascii_digit() => {
17            characters.all(|character| character.is_ascii_alphanumeric())
18        }
19        Some('l' | 'n') => characters.next().is_none(),
20        _ => false,
21    }
22}
23
24/// Return whether a manual section belongs to a command-page family.
25///
26/// Sections `1` and `8`, including conventional extensions such as `1p`, are
27/// eligible for a tldr command quick reference. Other manual categories
28/// describe APIs, formats, devices, games, or miscellaneous concepts.
29#[must_use]
30pub fn is_command_manual_section(value: &str) -> bool {
31    if !is_manual_section(value) || !matches!(value.as_bytes().first(), Some(b'1' | b'8')) {
32        return false;
33    }
34    let suffix = &value[1..];
35    suffix.is_empty()
36        || suffix
37            .chars()
38            .next()
39            .is_some_and(|character| character.is_ascii_alphabetic())
40}
41
42/// Split the `name(section)` spelling accepted by manual readers.
43#[must_use]
44pub fn parenthesized_manual_reference(selector: &str) -> Option<(&str, &str)> {
45    if selector.contains(['/', '\\']) || !selector.ends_with(')') {
46        return None;
47    }
48    let opening = selector.rfind('(')?;
49    let name = &selector[..opening];
50    let section = &selector[opening + 1..selector.len() - 1];
51    let valid_name = !name.is_empty()
52        && name.chars().all(|character| {
53            !character.is_whitespace() && !character.is_control() && !matches!(character, '(' | ')')
54        });
55    (valid_name && is_manual_section(section)).then_some((name, section))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::{is_command_manual_section, is_manual_section, parenthesized_manual_reference};
61
62    #[test]
63    fn recognizes_conventional_sections_and_command_families() {
64        for section in ["0", "1", "1p", "3type", "8x", "l", "n"] {
65            assert!(is_manual_section(section), "{section}");
66        }
67        for section in ["1", "1p", "8", "8x"] {
68            assert!(is_command_manual_section(section), "{section}");
69        }
70        for section in ["", "qgroup", "17!", "3-type", "ll"] {
71            assert!(!is_manual_section(section), "{section}");
72        }
73        for section in ["0", "10", "17", "3", "5", "l", "n"] {
74            assert!(!is_command_manual_section(section), "{section}");
75        }
76    }
77
78    #[test]
79    fn splits_parenthesized_manual_references_without_paths() {
80        assert_eq!(
81            parenthesized_manual_reference("systemd.slice(5)"),
82            Some(("systemd.slice", "5"))
83        );
84        assert_eq!(parenthesized_manual_reference("manual/1/git"), None);
85        assert_eq!(parenthesized_manual_reference("two words(1)"), None);
86        assert_eq!(parenthesized_manual_reference("function(arg)(3)"), None);
87    }
88}