1use std::ops::Range;
2
3use tree_sitter::{Node, Parser};
4
5use crate::lang::{Lang, parsable};
6
7#[derive(Debug, Clone, Default)]
10pub struct Classified {
11 pub comments: Vec<Range<usize>>,
13 pub imports: Vec<Range<usize>>,
15 pub logging: Vec<Range<usize>>,
17 pub tests: Vec<Range<usize>>,
20 pub error_line: Option<usize>,
23}
24
25pub 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 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
68fn 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
116fn 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
162fn is_logging(lang: Lang, node: Node, src: &str) -> bool {
164 let call = match lang {
165 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 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 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
244fn 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 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}