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    /// One-based line of the first syntax error the parser recovered from. Layers are
18    /// still collected, but some may be missed or misplaced near it.
19    pub error_line: Option<usize>,
20}
21
22/// Finds every comment, import and logging statement in `src`. Returns `None` only if
23/// the parser could not run at all.
24pub fn classify(lang: Lang, src: &str) -> Option<Classified> {
25    let mut parser = Parser::new();
26    parser.set_language(&lang.grammar()).ok()?;
27    let tree = parser.parse(parsable(lang, src).as_ref(), None)?;
28
29    // Comments are tree-sitter "extras": they can appear at any depth, so walk every node.
30    let mut classified = Classified::default();
31    let mut cursor = tree.walk();
32    'walk: loop {
33        let node = cursor.node();
34        let layer = if lang.is_comment(node.kind()) || (lang == Lang::Python && is_docstring(node))
35        {
36            Some(&mut classified.comments)
37        } else if is_import(lang, node) {
38            Some(&mut classified.imports)
39        } else if is_logging(lang, node, src) {
40            Some(&mut classified.logging)
41        } else {
42            None
43        };
44        if let Some(layer) = layer {
45            layer.push(node.byte_range());
46        } else if cursor.goto_first_child() {
47            continue;
48        }
49        while !cursor.goto_next_sibling() {
50            if !cursor.goto_parent() {
51                break 'walk;
52            }
53        }
54    }
55
56    classified.error_line = first_error(tree.root_node()).map(|node| node.start_position().row + 1);
57    Some(classified)
58}
59
60/// A string literal on its own as the first statement of a module, class or function body.
61fn is_docstring(node: Node) -> bool {
62    if node.kind() != "expression_statement" || node.named_child_count() != 1 {
63        return false;
64    }
65    let Some(expr) = node.named_child(0) else {
66        return false;
67    };
68    if !matches!(expr.kind(), "string" | "concatenated_string") {
69        return false;
70    }
71    let Some(parent) = node.parent() else {
72        return false;
73    };
74    let is_body = match parent.kind() {
75        "module" => true,
76        "block" => parent.parent().is_some_and(|owner| {
77            matches!(owner.kind(), "function_definition" | "class_definition")
78        }),
79        _ => false,
80    };
81    if !is_body {
82        return false;
83    }
84    let mut cursor = parent.walk();
85    let first_statement = parent
86        .named_children(&mut cursor)
87        .find(|child| child.kind() != "comment");
88    first_statement.is_some_and(|first| first.id() == node.id())
89}
90
91fn is_import(lang: Lang, node: Node) -> bool {
92    match lang {
93        Lang::Swift => node.kind() == "import_declaration",
94        Lang::TypeScript | Lang::Tsx | Lang::JavaScript => {
95            node.kind() == "import_statement"
96                || (node.kind() == "export_statement"
97                    && node.child_by_field_name("source").is_some())
98        }
99        Lang::Python => matches!(
100            node.kind(),
101            "import_statement" | "import_from_statement" | "future_import_statement"
102        ),
103    }
104}
105
106/// A statement that is nothing but a call to a logging function.
107fn is_logging(lang: Lang, node: Node, src: &str) -> bool {
108    let call = match lang {
109        // Swift has no expression statements: a call sits directly in the statement list.
110        Lang::Swift => (node.kind() == "call_expression"
111            && node
112                .parent()
113                .is_some_and(|p| matches!(p.kind(), "statements" | "source_file")))
114        .then_some(node),
115        _ => (node.kind() == "expression_statement")
116            .then(|| node.named_child(0))
117            .flatten()
118            .filter(|child| matches!(child.kind(), "call_expression" | "call")),
119    };
120    let Some(call) = call else {
121        return false;
122    };
123    let callee = match lang {
124        Lang::Swift => call.named_child(0),
125        _ => call.child_by_field_name("function"),
126    };
127    callee
128        .and_then(|callee| src.get(callee.byte_range()))
129        .is_some_and(|callee| is_logging_callee(lang, callee))
130}
131
132fn is_logging_callee(lang: Lang, callee: &str) -> bool {
133    let callee: String = callee.chars().filter(|c| !c.is_whitespace()).collect();
134    let segments: Vec<&str> = callee
135        .split(['.', '?', '!'])
136        .filter(|s| !s.is_empty())
137        .collect();
138    let Some((&method, receivers)) = segments.split_last() else {
139        return false;
140    };
141    if receivers.is_empty() {
142        return match lang {
143            Lang::Swift => matches!(method, "print" | "debugPrint" | "NSLog" | "os_log" | "dump"),
144            Lang::Python => method == "print",
145            _ => false,
146        };
147    }
148    const LEVELS: [&str; 13] = [
149        "log",
150        "debug",
151        "info",
152        "notice",
153        "warn",
154        "warning",
155        "error",
156        "fault",
157        "trace",
158        "critical",
159        "exception",
160        "verbose",
161        "fatal",
162    ];
163    LEVELS.contains(&method)
164        && receivers.iter().any(|receiver| {
165            let receiver = receiver.to_ascii_lowercase();
166            receiver == "console"
167                || receiver == "logging"
168                || receiver == "log"
169                || receiver.ends_with("logger")
170        })
171}
172
173fn first_error(root: Node) -> Option<Node> {
174    if !root.has_error() {
175        return None;
176    }
177    let mut node = root;
178    'descend: loop {
179        if node.is_error() || node.is_missing() {
180            return Some(node);
181        }
182        let mut cursor = node.walk();
183        for child in node.children(&mut cursor) {
184            if child.has_error() || child.is_missing() {
185                node = child;
186                continue 'descend;
187            }
188        }
189        return Some(node);
190    }
191}