Skip to main content

declutter/
classify.rs

1use std::ops::Range;
2
3use tree_sitter::{Node, Parser};
4
5use crate::lang::{Lang, parsable};
6
7/// The span layers of one version of a file. Each is a list of byte ranges in
8/// document order, non-overlapping within the layer.
9#[derive(Debug, Clone, Default)]
10pub struct Classified {
11    /// Comments and docstrings.
12    pub comments: Vec<Range<usize>>,
13    /// Import statements (and re-exports, which are imports in disguise).
14    pub imports: Vec<Range<usize>>,
15    /// Statements that only log: `print(…)`, `console.log(…)`, `logger.debug(…)`.
16    pub logging: Vec<Range<usize>>,
17    /// Test code inside a source file — in Rust, a `#[cfg(test)]` module or a `#[test]`
18    /// function, attributes included. Whole test files are found by path instead.
19    pub tests: Vec<Range<usize>>,
20    /// One-based line of the first syntax error the parser recovered from. Layers are
21    /// still collected, but some may be missed or misplaced near it.
22    pub error_line: Option<usize>,
23}
24
25/// Finds every comment, import, logging statement and test block in `src`. Returns `None` only if
26/// the parser could not run at all.
27pub fn classify(lang: Lang, src: &str) -> Option<Classified> {
28    let mut parser = Parser::new();
29    parser.set_language(&lang.grammar()).ok()?;
30    let tree = parser.parse(parsable(lang, src).as_ref(), None)?;
31
32    // Comments are tree-sitter "extras": they can appear at any depth, so walk every node.
33    let mut classified = Classified::default();
34    let mut cursor = tree.walk();
35    'walk: loop {
36        let node = cursor.node();
37        let test_block = (lang == Lang::Rust)
38            .then(|| rust_test_block(node, src))
39            .flatten();
40        let layer = if let Some(block) = &test_block {
41            classified.tests.push(block.clone());
42            None
43        } else if lang.is_comment(node.kind()) || (lang == Lang::Python && is_docstring(node)) {
44            Some(&mut classified.comments)
45        } else if is_import(lang, node) {
46            Some(&mut classified.imports)
47        } else if is_logging(lang, node, src) {
48            Some(&mut classified.logging)
49        } else {
50            None
51        };
52        if let Some(layer) = layer {
53            layer.push(node.byte_range());
54        } else if test_block.is_none() && cursor.goto_first_child() {
55            continue;
56        }
57        while !cursor.goto_next_sibling() {
58            if !cursor.goto_parent() {
59                break 'walk;
60            }
61        }
62    }
63
64    classified.error_line = first_error(tree.root_node()).map(|node| node.start_position().row + 1);
65    Some(classified)
66}
67
68/// A string literal on its own as the first statement of a module, class or function body.
69fn is_docstring(node: Node) -> bool {
70    if node.kind() != "expression_statement" || node.named_child_count() != 1 {
71        return false;
72    }
73    let Some(expr) = node.named_child(0) else {
74        return false;
75    };
76    if !matches!(expr.kind(), "string" | "concatenated_string") {
77        return false;
78    }
79    let Some(parent) = node.parent() else {
80        return false;
81    };
82    let is_body = match parent.kind() {
83        "module" => true,
84        "block" => parent.parent().is_some_and(|owner| {
85            matches!(owner.kind(), "function_definition" | "class_definition")
86        }),
87        _ => false,
88    };
89    if !is_body {
90        return false;
91    }
92    let mut cursor = parent.walk();
93    let first_statement = parent
94        .named_children(&mut cursor)
95        .find(|child| child.kind() != "comment");
96    first_statement.is_some_and(|first| first.id() == node.id())
97}
98
99fn is_import(lang: Lang, node: Node) -> bool {
100    match lang {
101        Lang::Swift | Lang::Go => node.kind() == "import_declaration",
102        Lang::TypeScript | Lang::Tsx | Lang::JavaScript => {
103            node.kind() == "import_statement"
104                || (node.kind() == "export_statement"
105                    && node.child_by_field_name("source").is_some())
106        }
107        Lang::Python => matches!(
108            node.kind(),
109            "import_statement" | "import_from_statement" | "future_import_statement"
110        ),
111        Lang::Rust => matches!(node.kind(), "use_declaration" | "extern_crate_declaration"),
112        Lang::Kotlin => node.kind() == "import",
113    }
114}
115
116/// A Rust item marked as test code, from its first attribute to its end: a module under
117/// `#[cfg(test)]`, or a function under `#[test]`, `#[tokio::test]`, `#[rstest]`,
118/// `#[test_case(…)]` and the like.
119fn rust_test_block(node: Node, src: &str) -> Option<Range<usize>> {
120    if !matches!(node.kind(), "mod_item" | "function_item") {
121        return None;
122    }
123    let mut start = None;
124    let mut is_test = false;
125    let mut sibling = node.prev_sibling();
126    while let Some(previous) = sibling {
127        match previous.kind() {
128            "attribute_item" => {
129                start = Some(previous.start_byte());
130                is_test |= src
131                    .get(previous.byte_range())
132                    .is_some_and(is_test_attribute);
133            }
134            "line_comment" | "block_comment" => {}
135            _ => break,
136        }
137        sibling = previous.prev_sibling();
138    }
139    is_test.then(|| start.unwrap_or(node.start_byte())..node.end_byte())
140}
141
142fn is_test_attribute(attribute: &str) -> bool {
143    let inner = attribute
144        .trim()
145        .trim_start_matches("#[")
146        .trim_end_matches(']')
147        .trim();
148    let words = |text: &str| -> Vec<String> {
149        text.split(|c: char| !(c.is_alphanumeric() || c == '_'))
150            .filter(|word| !word.is_empty())
151            .map(str::to_string)
152            .collect()
153    };
154    if inner.starts_with("cfg") {
155        return words(inner).iter().any(|word| word == "test");
156    }
157    let path = inner.split('(').next().unwrap_or(inner);
158    let last = path.rsplit("::").next().unwrap_or(path).trim();
159    last == "test" || last == "rstest" || last.starts_with("test_")
160}
161
162/// A statement that is nothing but a call to a logging function.
163fn is_logging(lang: Lang, node: Node, src: &str) -> bool {
164    let call = match lang {
165        // Swift and Kotlin have no expression statements: a call sits directly in the
166        // statement list.
167        Lang::Swift | Lang::Kotlin => (node.kind() == "call_expression"
168            && node
169                .parent()
170                .is_some_and(|p| matches!(p.kind(), "statements" | "source_file" | "block")))
171        .then_some(node),
172        _ => (node.kind() == "expression_statement")
173            .then(|| node.named_child(0))
174            .flatten()
175            .filter(|child| {
176                matches!(
177                    child.kind(),
178                    "call_expression" | "call" | "macro_invocation"
179                )
180            }),
181    };
182    let Some(call) = call else {
183        return false;
184    };
185    let callee = match (lang, call.kind()) {
186        (Lang::Swift | Lang::Kotlin, _) => call.named_child(0),
187        (_, "macro_invocation") => call.child_by_field_name("macro"),
188        _ => call.child_by_field_name("function"),
189    };
190    callee
191        .and_then(|callee| src.get(callee.byte_range()))
192        .is_some_and(|callee| is_logging_callee(lang, callee))
193}
194
195fn is_logging_callee(lang: Lang, callee: &str) -> bool {
196    let callee: String = callee.chars().filter(|c| !c.is_whitespace()).collect();
197    let segments: Vec<&str> = callee
198        .split(['.', '?', '!', ':'])
199        .filter(|s| !s.is_empty())
200        .collect();
201    let Some((&method, receivers)) = segments.split_last() else {
202        return false;
203    };
204    if receivers.is_empty() {
205        return match lang {
206            Lang::Swift => matches!(method, "print" | "debugPrint" | "NSLog" | "os_log" | "dump"),
207            Lang::Python => method == "print",
208            Lang::Go | Lang::Kotlin => matches!(method, "print" | "println"),
209            Lang::Rust => matches!(
210                method,
211                "println"
212                    | "eprintln"
213                    | "print"
214                    | "eprint"
215                    | "dbg"
216                    | "trace"
217                    | "debug"
218                    | "info"
219                    | "warn"
220                    | "error"
221            ),
222            _ => false,
223        };
224    }
225    let receiver = receivers
226        .last()
227        .map(|receiver| receiver.to_ascii_lowercase())
228        .unwrap_or_default();
229    let level = method.to_ascii_lowercase();
230    match lang {
231        // `fmt.Fprint*` writes to a writer (perhaps a response), and `log.Fatal*` and
232        // `log.Panic*` end the program: those are code, not logging.
233        Lang::Go if receiver == "fmt" => matches!(method, "Print" | "Println" | "Printf"),
234        Lang::Go if receiver == "log" => method.starts_with("Print"),
235        Lang::Go if level.starts_with("fatal") || level.starts_with("panic") => false,
236        // Android's `Log.d(…)` and Timber's `Timber.w(…)` use one-letter levels.
237        Lang::Kotlin if receiver == "log" || receiver == "timber" => {
238            matches!(method, "d" | "i" | "w" | "e" | "v" | "wtf") || is_level(&level)
239        }
240        _ => is_level(&level) && receivers.iter().any(|receiver| is_logger(receiver)),
241    }
242}
243
244/// A logging level, including Go's formatted variants (`Infof`, `Debugw`).
245fn is_level(level: &str) -> bool {
246    const LEVELS: [&str; 13] = [
247        "log",
248        "debug",
249        "info",
250        "notice",
251        "warn",
252        "warning",
253        "error",
254        "fault",
255        "trace",
256        "critical",
257        "exception",
258        "verbose",
259        "fatal",
260    ];
261    LEVELS.contains(&level)
262        || level
263            .strip_suffix(['f', 'w'])
264            .is_some_and(|stem| LEVELS.contains(&stem))
265}
266
267fn is_logger(receiver: &str) -> bool {
268    let receiver = receiver.to_ascii_lowercase();
269    matches!(
270        receiver.as_str(),
271        "console" | "logging" | "log" | "tracing" | "slog" | "timber"
272    ) || receiver.ends_with("logger")
273}
274
275fn first_error(root: Node) -> Option<Node> {
276    if !root.has_error() {
277        return None;
278    }
279    // Go as deep as the error goes: the parser often wraps everything from the start of
280    // the file in one ERROR node, and the line that matters is the innermost one.
281    let mut node = root;
282    'descend: loop {
283        let mut cursor = node.walk();
284        for child in node.children(&mut cursor) {
285            if child.has_error() || child.is_missing() {
286                node = child;
287                continue 'descend;
288            }
289        }
290        return Some(node);
291    }
292}