Skip to main content

declutter/
lang.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use tree_sitter::Language;
5
6/// A language whose comments declutter can find.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Lang {
9    Swift,
10    TypeScript,
11    Tsx,
12    /// Parsed with the TSX grammar, which accepts plain JavaScript and JSX.
13    JavaScript,
14    Python,
15    Go,
16    Rust,
17    Kotlin,
18}
19
20impl Lang {
21    pub fn from_path(path: &str) -> Option<Lang> {
22        let ext = Path::new(path).extension()?.to_str()?;
23        match ext {
24            "swift" => Some(Lang::Swift),
25            "ts" | "mts" | "cts" => Some(Lang::TypeScript),
26            "tsx" => Some(Lang::Tsx),
27            "js" | "mjs" | "cjs" | "jsx" => Some(Lang::JavaScript),
28            "py" | "pyi" => Some(Lang::Python),
29            "go" => Some(Lang::Go),
30            "rs" => Some(Lang::Rust),
31            "kt" | "kts" => Some(Lang::Kotlin),
32            _ => None,
33        }
34    }
35
36    pub fn name(self) -> &'static str {
37        match self {
38            Lang::Swift => "Swift",
39            Lang::TypeScript => "TypeScript",
40            Lang::Tsx => "TSX",
41            Lang::JavaScript => "JavaScript",
42            Lang::Python => "Python",
43            Lang::Go => "Go",
44            Lang::Rust => "Rust",
45            Lang::Kotlin => "Kotlin",
46        }
47    }
48
49    pub fn grammar(self) -> Language {
50        match self {
51            Lang::Swift => tree_sitter_swift::LANGUAGE.into(),
52            Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
53            Lang::Tsx | Lang::JavaScript => tree_sitter_typescript::LANGUAGE_TSX.into(),
54            Lang::Python => tree_sitter_python::LANGUAGE.into(),
55            Lang::Go => tree_sitter_go::LANGUAGE.into(),
56            Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
57            Lang::Kotlin => tree_sitter_kotlin_ng::LANGUAGE.into(),
58        }
59    }
60
61    pub fn is_comment(self, kind: &str) -> bool {
62        match self {
63            Lang::Swift => matches!(kind, "comment" | "multiline_comment"),
64            Lang::TypeScript | Lang::Tsx | Lang::JavaScript => {
65                matches!(kind, "comment" | "html_comment")
66            }
67            Lang::Python | Lang::Go => kind == "comment",
68            Lang::Rust | Lang::Kotlin => matches!(kind, "line_comment" | "block_comment"),
69        }
70    }
71}
72
73/// The text handed to the parser in place of `src`: always the same length, byte for
74/// byte, so every range found in it is a range in `src` too.
75///
76/// It papers over constructs tree-sitter-swift 0.7 cannot parse, each of which leaves
77/// an error that can swallow the rest of a function:
78///
79/// - the empty tuple `()` used as a value (`.success(())`, `resume(returning: ())`,
80///   `value ?? ()`) becomes the empty array literal `[]`, which parses there;
81/// - `await` opening an `if`/`while`/`guard` condition (`if await a != b`) is blanked;
82/// - `nonisolated(unsafe)` loses its `(unsafe)`.
83///
84/// None of these touch a comment, an import or a logging call's extent.
85pub fn parsable(lang: Lang, src: &str) -> Cow<'_, str> {
86    if lang != Lang::Swift {
87        return Cow::Borrowed(src);
88    }
89    let mut out = src.as_bytes().to_vec();
90    let mut blank = |range: std::ops::Range<usize>, with: &[u8]| {
91        out[range.clone()].copy_from_slice(&with[..range.len()]);
92    };
93
94    for (at, _) in src.match_indices("()") {
95        let before = src[..at].trim_end();
96        let after = src[at + 2..].trim_start();
97        let value_position = matches!(before.chars().last(), Some('(' | ',' | ':' | '=' | '?'))
98            && !before.ends_with("->")
99            && !["->", "throws", "async"]
100                .iter()
101                .any(|word| after.starts_with(word));
102        if value_position {
103            blank(at..at + 2, b"[]");
104        }
105    }
106    for (at, _) in src.match_indices("await ") {
107        let before = src[..at].trim_end();
108        let opens_condition = ["if", "while", "guard"].iter().any(|keyword| {
109            before.ends_with(keyword)
110                && !before[..before.len() - keyword.len()]
111                    .ends_with(|c: char| c.is_alphanumeric() || c == '_')
112        });
113        if opens_condition {
114            blank(at..at + 5, b"     ");
115        }
116    }
117    for (at, _) in src.match_indices("nonisolated(unsafe)") {
118        blank(at + 11..at + 19, b"        ");
119    }
120
121    if out == src.as_bytes() {
122        return Cow::Borrowed(src);
123    }
124    // Only ASCII bytes were swapped for ASCII bytes, so the text is still valid UTF-8.
125    Cow::Owned(String::from_utf8(out).unwrap_or_else(|_| src.to_string()))
126}