Skip to main content

normalize_languages/
scala.rs

1//! Scala language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::Path;
6use tree_sitter::Node;
7
8/// Scala language support.
9pub struct Scala;
10
11impl Language for Scala {
12    fn name(&self) -> &'static str {
13        "Scala"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["scala", "sc"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "scala"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn signature_suffix(&self) -> &'static str {
27        " {}"
28    }
29
30    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
31        extract_scaladoc(node, content)
32    }
33
34    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
35        extract_scala_annotations(node, content)
36    }
37
38    fn refine_kind(
39        &self,
40        node: &Node,
41        _content: &str,
42        tag_kind: crate::SymbolKind,
43    ) -> crate::SymbolKind {
44        match node.kind() {
45            "trait_definition" => crate::SymbolKind::Trait,
46            _ => tag_kind,
47        }
48    }
49
50    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
51        let mut implements = Vec::new();
52        let mut cursor = node.walk();
53        for child in node.children(&mut cursor) {
54            if child.kind() == "extends_clause" {
55                let mut ec = child.walk();
56                for t in child.children(&mut ec) {
57                    if t.kind() == "type_identifier" {
58                        implements.push(content[t.byte_range()].to_string());
59                    }
60                }
61            }
62        }
63        crate::ImplementsInfo {
64            is_interface: false,
65            implements,
66        }
67    }
68
69    fn build_signature(&self, node: &Node, content: &str) -> String {
70        let name = match self.node_name(node, content) {
71            Some(n) => n,
72            None => {
73                return content[node.byte_range()]
74                    .lines()
75                    .next()
76                    .unwrap_or("")
77                    .trim()
78                    .to_string();
79            }
80        };
81        match node.kind() {
82            "function_definition" | "function_declaration" => {
83                let params = node
84                    .child_by_field_name("parameters")
85                    .map(|p| content[p.byte_range()].to_string())
86                    .unwrap_or_else(|| "()".to_string());
87                let ret = node
88                    .child_by_field_name("return_type")
89                    .map(|r| format!(": {}", &content[r.byte_range()]))
90                    .unwrap_or_default();
91                format!("def {}{}{}", name, params, ret)
92            }
93            "class_definition" => format!("class {}", name),
94            "object_definition" => format!("object {}", name),
95            "trait_definition" => format!("trait {}", name),
96            _ => {
97                let text = &content[node.byte_range()];
98                text.lines().next().unwrap_or(text).trim().to_string()
99            }
100        }
101    }
102
103    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
104        if node.kind() != "import_declaration" {
105            return Vec::new();
106        }
107
108        let text = &content[node.byte_range()];
109        let line = node.start_position().row + 1;
110
111        // import pkg.Class or import pkg.{A, B} or import pkg._
112        if let Some(rest) = text.strip_prefix("import ") {
113            let rest = rest.trim();
114            let is_wildcard = rest.ends_with("._") || rest.ends_with(".*");
115            let has_selectors = rest.contains('{');
116
117            if has_selectors {
118                // import pkg.{A, B, C}
119                if let Some(brace) = rest.find('{') {
120                    let module = rest[..brace].trim_end_matches('.').to_string();
121                    let inner = &rest[brace + 1..];
122                    let inner = inner.strip_suffix('}').unwrap_or(inner);
123                    let names: Vec<String> = inner
124                        .split(',')
125                        .map(|s| s.trim().to_string())
126                        .filter(|s| !s.is_empty() && s != "_")
127                        .collect();
128                    return vec![Import {
129                        module,
130                        names,
131                        alias: None,
132                        is_wildcard: inner.contains('_'),
133                        is_relative: false,
134                        line,
135                    }];
136                }
137            }
138
139            let module = if is_wildcard {
140                rest.strip_suffix("._")
141                    .or_else(|| rest.strip_suffix(".*"))
142                    .unwrap_or(rest)
143                    .to_string()
144            } else {
145                rest.to_string()
146            };
147
148            return vec![Import {
149                module,
150                names: Vec::new(),
151                alias: None,
152                is_wildcard,
153                is_relative: false,
154                line,
155            }];
156        }
157
158        Vec::new()
159    }
160
161    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
162        // Scala: import pkg.Class or import pkg.{A, B, C}
163        let names_to_use: Vec<&str> = names
164            .map(|n| n.to_vec())
165            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
166        if import.is_wildcard {
167            format!("import {}._", import.module)
168        } else if names_to_use.is_empty() {
169            format!("import {}", import.module)
170        } else if names_to_use.len() == 1 {
171            format!("import {}.{}", import.module, names_to_use[0])
172        } else {
173            format!("import {}.{{{}}}", import.module, names_to_use.join(", "))
174        }
175    }
176
177    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
178        {
179            let has_test_attr = symbol.attributes.iter().any(|a| a.contains("@Test"));
180            if has_test_attr {
181                return true;
182            }
183            match symbol.kind {
184                crate::SymbolKind::Class => {
185                    symbol.name.starts_with("Test") || symbol.name.ends_with("Test")
186                }
187                _ => false,
188            }
189        }
190    }
191
192    fn test_file_globs(&self) -> &'static [&'static str] {
193        &[
194            "**/src/test/**/*.scala",
195            "**/*Test.scala",
196            "**/*Spec.scala",
197            "**/*Suite.scala",
198        ]
199    }
200
201    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
202        // Scala uses `access_modifier` child with optional `access_qualifier`.
203        // `private` → Private, `protected` → Protected, no modifier → Public.
204        let mut cursor = node.walk();
205        for child in node.children(&mut cursor) {
206            if child.kind() == "access_modifier" {
207                let text = &content[child.byte_range()];
208                if text.starts_with("private") {
209                    return Visibility::Private;
210                }
211                if text.starts_with("protected") {
212                    return Visibility::Protected;
213                }
214            }
215            if child.kind() == "modifiers" {
216                let mut mc = child.walk();
217                for m in child.children(&mut mc) {
218                    if m.kind() == "access_modifier" {
219                        let text = &content[m.byte_range()];
220                        if text.starts_with("private") {
221                            return Visibility::Private;
222                        }
223                        if text.starts_with("protected") {
224                            return Visibility::Protected;
225                        }
226                    }
227                }
228            }
229        }
230        Visibility::Public
231    }
232
233    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
234        node.child_by_field_name("body")
235    }
236    fn analyze_container_body(
237        &self,
238        body_node: &Node,
239        content: &str,
240        inner_indent: &str,
241    ) -> Option<ContainerBody> {
242        crate::body::analyze_brace_body(body_node, content, inner_indent)
243    }
244
245    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
246        static RESOLVER: ScalaModuleResolver = ScalaModuleResolver;
247        Some(&RESOLVER)
248    }
249}
250
251impl LanguageSymbols for Scala {}
252
253// =============================================================================
254// Scala Module Resolver
255// =============================================================================
256
257/// Module resolver for Scala (Maven/sbt conventions).
258pub struct ScalaModuleResolver;
259
260const SCALA_SRC_DIRS: &[&str] = &["src/main/scala", "src/test/scala", ""];
261
262impl ModuleResolver for ScalaModuleResolver {
263    fn workspace_config(&self, root: &Path) -> ResolverConfig {
264        ResolverConfig {
265            workspace_root: root.to_path_buf(),
266            path_mappings: Vec::new(),
267            search_roots: SCALA_SRC_DIRS.iter().map(|d| root.join(d)).collect(),
268        }
269    }
270
271    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
272        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
273        if ext != "scala" && ext != "sc" {
274            return Vec::new();
275        }
276        for search_root in &cfg.search_roots {
277            if let Ok(rel) = file.strip_prefix(search_root) {
278                let rel_str = rel
279                    .to_str()
280                    .unwrap_or("")
281                    .trim_end_matches(".scala")
282                    .trim_end_matches(".sc")
283                    .replace(['/', '\\'], ".");
284                if !rel_str.is_empty() {
285                    return vec![ModuleId {
286                        canonical_path: rel_str,
287                    }];
288                }
289            }
290        }
291        Vec::new()
292    }
293
294    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
295        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
296        if ext != "scala" && ext != "sc" {
297            return Resolution::NotApplicable;
298        }
299        let raw = &spec.raw;
300        let path_part = raw.replace('.', "/");
301        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
302        for search_root in &cfg.search_roots {
303            let candidate = search_root.join(format!("{}.scala", path_part));
304            if candidate.exists() {
305                return Resolution::Resolved(candidate, exported_name.clone());
306            }
307            let candidate = search_root.join(format!("{}.sc", path_part));
308            if candidate.exists() {
309                return Resolution::Resolved(candidate, exported_name.clone());
310            }
311        }
312        Resolution::NotFound
313    }
314}
315
316/// Extract a ScalaDoc comment (`/** ... */`) preceding a node.
317///
318/// Walks backwards through siblings looking for a `block_comment` starting with `/**`.
319fn extract_scaladoc(node: &Node, content: &str) -> Option<String> {
320    let mut prev = node.prev_sibling();
321    while let Some(sibling) = prev {
322        match sibling.kind() {
323            "block_comment" => {
324                let text = &content[sibling.byte_range()];
325                if text.starts_with("/**") {
326                    return Some(clean_block_doc_comment(text));
327                }
328                return None;
329            }
330            "annotation" => {
331                // Skip annotations between doc comment and declaration
332            }
333            _ => return None,
334        }
335        prev = sibling.prev_sibling();
336    }
337    None
338}
339
340/// Clean a `/** ... */` block doc comment into plain text.
341fn clean_block_doc_comment(text: &str) -> String {
342    let lines: Vec<&str> = text
343        .strip_prefix("/**")
344        .unwrap_or(text)
345        .strip_suffix("*/")
346        .unwrap_or(text)
347        .lines()
348        .map(|l| l.trim().strip_prefix('*').unwrap_or(l).trim())
349        .filter(|l| !l.is_empty())
350        .collect();
351    lines.join(" ")
352}
353
354/// Extract annotations from a Scala definition node.
355///
356/// Scala annotations (`@deprecated`, `@tailrec`, etc.) appear as `annotation`
357/// children of the definition node.
358fn extract_scala_annotations(node: &Node, content: &str) -> Vec<String> {
359    let mut attrs = Vec::new();
360    let mut cursor = node.walk();
361    for child in node.children(&mut cursor) {
362        if child.kind() == "annotation" {
363            attrs.push(content[child.byte_range()].to_string());
364        }
365    }
366    attrs
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use crate::validate_unused_kinds_audit;
373
374    #[test]
375    fn unused_node_kinds_audit() {
376        #[rustfmt::skip]
377        let documented_unused: &[&str] = &[
378            // STRUCTURAL
379            "access_modifier",     // used in get_visibility (audit matched by "if" substring)
380            "access_qualifier", "arrow_renamed_identifier",
381            "as_renamed_identifier", "block_comment", "case_block", "case_class_pattern",
382            "class_parameter", "class_parameters", "derives_clause", "enum_body",
383            "enum_case_definitions", "enum_definition", "enumerator", "enumerators",
384            "export_declaration", "extends_clause", "extension_definition", "field_expression",
385            "full_enum_case", "identifier", "identifiers", "indented_block", "indented_cases",
386            "infix_modifier", "inline_modifier", "instance_expression", "into_modifier",
387            "macro_body", "modifiers", "name_and_type", "opaque_modifier", "open_modifier",
388            "operator_identifier", "package_clause", "package_identifier", "self_type",
389            "simple_enum_case", "template_body", "tracked_modifier", "transparent_modifier",
390            "val_declaration", "val_definition", "var_declaration", "var_definition",
391            "with_template_body",
392            // CLAUSE
393            "finally_clause", "type_case_clause",
394            // EXPRESSION
395            "ascription_expression", "assignment_expression", "call_expression",
396            "generic_function", "interpolated_string_expression", "parenthesized_expression",
397            "postfix_expression", "prefix_expression", "quote_expression", "splice_expression",
398            "tuple_expression",
399            // TYPE
400            "annotated_type", "applied_constructor_type", "compound_type",
401            "contravariant_type_parameter", "covariant_type_parameter", "function_declaration",
402            "function_type", "generic_type", "given_definition", "infix_type", "lazy_parameter_type",
403            "literal_type", "match_type", "named_tuple_type", "parameter_types",
404            "projected_type", "repeated_parameter_type", "singleton_type", "stable_identifier",
405            "stable_type_identifier", "structural_type", "tuple_type", "type_arguments", "type_identifier", "type_lambda", "type_parameters", "typed_pattern",
406            // control flow — not extracted as symbols
407            "while_expression",
408            "match_expression",
409            "catch_clause",
410            "import_declaration",
411            "return_expression",
412            "if_expression",
413            "for_expression",
414            "throw_expression",
415            "block",
416            "infix_expression",
417            "case_clause",
418            "try_expression",
419            "do_while_expression",
420            "lambda_expression",
421        ];
422
423        validate_unused_kinds_audit(&Scala, documented_unused)
424            .expect("Scala unused node kinds audit failed");
425    }
426}