Skip to main content

normalize_languages/
dart.rs

1//! Dart language support.
2
3use crate::docstring::extract_preceding_prefix_comments;
4use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
5use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
6use std::path::{Path, PathBuf};
7use tree_sitter::Node;
8
9/// Dart language support.
10pub struct Dart;
11
12impl Language for Dart {
13    fn name(&self) -> &'static str {
14        "Dart"
15    }
16    fn extensions(&self) -> &'static [&'static str] {
17        &["dart"]
18    }
19    fn grammar_name(&self) -> &'static str {
20        "dart"
21    }
22
23    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
24        Some(self)
25    }
26
27    fn signature_suffix(&self) -> &'static str {
28        " {}"
29    }
30
31    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
32        extract_preceding_prefix_comments(node, content, "///")
33    }
34
35    fn refine_kind(
36        &self,
37        node: &Node,
38        _content: &str,
39        tag_kind: crate::SymbolKind,
40    ) -> crate::SymbolKind {
41        match node.kind() {
42            "enum_declaration" => crate::SymbolKind::Enum,
43            "mixin_declaration" => crate::SymbolKind::Trait,
44            _ => tag_kind,
45        }
46    }
47
48    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
49        extract_dart_annotations(node, content)
50    }
51
52    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
53        let mut implements = Vec::new();
54        let mut cursor = node.walk();
55        for child in node.children(&mut cursor) {
56            if child.kind() == "superclass" || child.kind() == "interfaces" {
57                let mut ic = child.walk();
58                for t in child.children(&mut ic) {
59                    if t.kind() == "type_identifier" {
60                        implements.push(content[t.byte_range()].to_string());
61                    }
62                }
63            }
64        }
65        crate::ImplementsInfo {
66            is_interface: false,
67            implements,
68        }
69    }
70
71    fn build_signature(&self, node: &Node, content: &str) -> String {
72        let name = match self.node_name(node, content) {
73            Some(n) => n,
74            None => {
75                return content[node.byte_range()]
76                    .lines()
77                    .next()
78                    .unwrap_or("")
79                    .trim()
80                    .to_string();
81            }
82        };
83        match node.kind() {
84            k if k.contains("function") || k.contains("method") => {
85                let return_type = node
86                    .child_by_field_name("return_type")
87                    .map(|t| content[t.byte_range()].to_string());
88                let params = node
89                    .child_by_field_name("formal_parameters")
90                    .or_else(|| node.child_by_field_name("parameters"))
91                    .map(|p| content[p.byte_range()].to_string())
92                    .unwrap_or_else(|| "()".to_string());
93                if let Some(ret) = return_type {
94                    format!("{} {}{}", ret, name, params)
95                } else {
96                    format!("{}{}", name, params)
97                }
98            }
99            "class_declaration" => {
100                let is_abstract = node
101                    .parent()
102                    .map(|p| content[p.byte_range()].contains("abstract "))
103                    .unwrap_or(false);
104                if is_abstract {
105                    format!("abstract class {}", name)
106                } else {
107                    format!("class {}", name)
108                }
109            }
110            "enum_declaration" => format!("enum {}", name),
111            "mixin_declaration" => format!("mixin {}", name),
112            "extension_declaration" => format!("extension {}", name),
113            _ => {
114                let text = &content[node.byte_range()];
115                text.lines().next().unwrap_or(text).trim().to_string()
116            }
117        }
118    }
119
120    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
121        if node.kind() != "import_specification" && node.kind() != "library_export" {
122            return Vec::new();
123        }
124
125        let text = &content[node.byte_range()];
126        let line = node.start_position().row + 1;
127
128        // Extract the import URI
129        if let Some(start) = text.find('\'').or_else(|| text.find('"')) {
130            // start is a byte offset; slice at it (safe: ASCII quote is single-byte) then take first char
131            let Some(quote) = text[start..].chars().next() else {
132                return Vec::new();
133            };
134            let rest = &text[start + 1..];
135            if let Some(end) = rest.find(quote) {
136                let module = rest[..end].to_string();
137                let is_relative = module.starts_with('.') || module.starts_with('/');
138
139                // Check for 'as' alias
140                let alias = if text.contains(" as ") {
141                    text.split(" as ")
142                        .nth(1)
143                        .and_then(|s| s.split(';').next())
144                        .map(|s| s.trim().to_string())
145                } else {
146                    None
147                };
148
149                // Extract names from 'show' clause; 'hide' imports are named but we
150                // don't suppress names so they remain empty (caller can query show node).
151                // Neither show nor hide is a wildcard import.
152                let (names, is_wildcard) = if text.contains(" show ") {
153                    let show_names: Vec<String> = text
154                        .split(" show ")
155                        .nth(1)
156                        .unwrap_or("")
157                        .split(';')
158                        .next()
159                        .unwrap_or("")
160                        .split(',')
161                        .map(|s| s.trim().to_string())
162                        .filter(|s| !s.is_empty())
163                        .collect();
164                    (show_names, false)
165                } else {
166                    (Vec::new(), false)
167                };
168
169                return vec![Import {
170                    module,
171                    names,
172                    alias,
173                    is_wildcard,
174                    is_relative,
175                    line,
176                }];
177            }
178        }
179
180        Vec::new()
181    }
182
183    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
184        // Dart: import 'package:name/name.dart'; or import '...' show a, b, c;
185        let names_to_use: Vec<&str> = names
186            .map(|n| n.to_vec())
187            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
188        if names_to_use.is_empty() {
189            format!("import '{}';", import.module)
190        } else {
191            format!(
192                "import '{}' show {};",
193                import.module,
194                names_to_use.join(", ")
195            )
196        }
197    }
198
199    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
200        if let Some(name) = self.node_name(node, content) {
201            if name.starts_with('_') {
202                Visibility::Private
203            } else {
204                Visibility::Public
205            }
206        } else {
207            Visibility::Public
208        }
209    }
210
211    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
212        let name = symbol.name.as_str();
213        match symbol.kind {
214            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
215            crate::SymbolKind::Module => name == "tests" || name == "test",
216            _ => false,
217        }
218    }
219
220    fn test_file_globs(&self) -> &'static [&'static str] {
221        &["**/test/**/*.dart", "**/*_test.dart"]
222    }
223
224    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
225        node.child_by_field_name("body")
226    }
227
228    fn analyze_container_body(
229        &self,
230        body_node: &Node,
231        content: &str,
232        inner_indent: &str,
233    ) -> Option<ContainerBody> {
234        crate::body::analyze_brace_body(body_node, content, inner_indent)
235    }
236
237    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
238        static RESOLVER: DartModuleResolver = DartModuleResolver;
239        Some(&RESOLVER)
240    }
241}
242
243impl LanguageSymbols for Dart {}
244
245// =============================================================================
246// Dart Module Resolver
247// =============================================================================
248
249/// Module resolver for Dart (pub package conventions).
250///
251/// Reads `pubspec.yaml` to find the package name. Resolves:
252/// - `package:mypackage/src/foo.dart` → `lib/src/foo.dart`
253/// - `dart:core` etc. → `NotFound` (SDK)
254/// - relative imports → resolved relative to `from_file`
255pub struct DartModuleResolver;
256
257impl ModuleResolver for DartModuleResolver {
258    fn workspace_config(&self, root: &Path) -> ResolverConfig {
259        let mut path_mappings: Vec<(String, PathBuf)> = Vec::new();
260
261        let pubspec = root.join("pubspec.yaml");
262        if let Ok(content) = std::fs::read_to_string(&pubspec) {
263            for line in content.lines() {
264                let trimmed = line.trim();
265                if let Some(rest) = trimmed.strip_prefix("name:") {
266                    let name = rest.trim().trim_matches('"').trim_matches('\'');
267                    if !name.is_empty() {
268                        path_mappings.push((name.to_string(), root.join("lib")));
269                        break;
270                    }
271                }
272            }
273        }
274
275        ResolverConfig {
276            workspace_root: root.to_path_buf(),
277            path_mappings,
278            search_roots: vec![root.join("lib")],
279        }
280    }
281
282    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
283        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
284        if ext != "dart" {
285            return Vec::new();
286        }
287        for (pkg_name, lib_dir) in &cfg.path_mappings {
288            if let Ok(rel) = file.strip_prefix(lib_dir) {
289                let rel_str = rel.to_str().unwrap_or("").replace('\\', "/");
290                let canonical = format!("package:{}/{}", pkg_name, rel_str);
291                return vec![ModuleId {
292                    canonical_path: canonical,
293                }];
294            }
295        }
296        // file under workspace root but not lib/
297        if let Ok(rel) = file.strip_prefix(&cfg.workspace_root) {
298            return vec![ModuleId {
299                canonical_path: rel.to_str().unwrap_or("").replace('\\', "/"),
300            }];
301        }
302        Vec::new()
303    }
304
305    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
306        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
307        if ext != "dart" {
308            return Resolution::NotApplicable;
309        }
310        let raw = &spec.raw;
311
312        // dart: SDK imports
313        if raw.starts_with("dart:") {
314            return Resolution::NotFound;
315        }
316
317        // Relative imports
318        if raw.starts_with('.') {
319            if let Some(parent) = from_file.parent() {
320                let resolved = parent.join(raw);
321                if resolved.exists() {
322                    let name = resolved
323                        .file_stem()
324                        .and_then(|s| s.to_str())
325                        .unwrap_or("")
326                        .to_string();
327                    return Resolution::Resolved(resolved, name);
328                }
329            }
330            return Resolution::NotFound;
331        }
332
333        // package: imports
334        if let Some(rest) = raw.strip_prefix("package:") {
335            // package:pkgname/path/to/file.dart
336            let slash = rest.find('/');
337            let (pkg, path_in_pkg) = if let Some(idx) = slash {
338                (&rest[..idx], &rest[idx + 1..])
339            } else {
340                (rest, "")
341            };
342
343            // Check if it's our own package
344            for (own_pkg, lib_dir) in &cfg.path_mappings {
345                if pkg == own_pkg {
346                    let candidate = lib_dir.join(path_in_pkg);
347                    if candidate.exists() {
348                        let name = candidate
349                            .file_stem()
350                            .and_then(|s| s.to_str())
351                            .unwrap_or("")
352                            .to_string();
353                        return Resolution::Resolved(candidate, name);
354                    }
355                    return Resolution::NotFound;
356                }
357            }
358        }
359
360        Resolution::NotFound
361    }
362}
363
364/// Extract Dart annotations from child and preceding sibling nodes.
365fn extract_dart_annotations(node: &Node, content: &str) -> Vec<String> {
366    let mut attrs = Vec::new();
367    let mut cursor = node.walk();
368    for child in node.children(&mut cursor) {
369        if child.kind() == "annotation" {
370            let text = content[child.byte_range()].trim().to_string();
371            if !text.is_empty() {
372                attrs.push(text);
373            }
374        }
375    }
376    let mut prev = node.prev_sibling();
377    while let Some(sibling) = prev {
378        if sibling.kind() == "annotation" {
379            let text = content[sibling.byte_range()].trim().to_string();
380            if !text.is_empty() {
381                attrs.insert(0, text);
382            }
383            prev = sibling.prev_sibling();
384        } else {
385            break;
386        }
387    }
388    attrs
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::validate_unused_kinds_audit;
395
396    #[test]
397    fn unused_node_kinds_audit() {
398        #[rustfmt::skip]
399        let documented_unused: &[&str] = &[
400            "additive_expression", "additive_operator", "annotation", "as_operator",
401            "assert_statement", "assignable_expression", "assignment_expression",
402            "assignment_expression_without_cascade", "await_expression", "binary_operator",
403            "bitwise_and_expression", "bitwise_operator", "bitwise_or_expression",
404            "bitwise_xor_expression", "cascade_section", "case_builtin",
405            "catch_parameters", "class_body", "const_object_expression",
406            "constant_constructor_signature", "constructor_invocation",
407            "constructor_param", "constructor_signature", "constructor_tearoff",
408            "declaration", "dotted_identifier_list", "enum_body", "enum_constant",
409            "equality_expression", "equality_operator", "expression_statement",
410            "extension_body", "extension_type_declaration", "factory_constructor_signature",
411            "finally_clause", "for_element", "for_loop_parts", "formal_parameter",
412            "formal_parameter_list", "function_expression_body", "function_type",
413            "identifier_dollar_escaped", "identifier_list",
414            "if_element", "if_null_expression", "import_or_export", "increment_operator",
415            "inferred_type", "initialized_identifier", "initialized_identifier_list",
416            "initialized_variable_definition", "initializer_list_entry", "interface",
417            "interfaces", "is_operator", "label", "lambda_expression",
418            "library_import", "library_name", "local_function_declaration",
419            "local_variable_declaration", "logical_and_operator", "logical_or_operator",
420            "minus_operator", "mixin_application_class", "multiplicative_expression",
421            "multiplicative_operator", "named_parameter_types", "negation_operator",
422            "new_expression", "normal_parameter_type", "nullable_type",
423            "operator_signature", "optional_formal_parameters", "optional_parameter_types",
424            "optional_positional_parameter_types", "parameter_type_list",
425            "parenthesized_expression", "pattern_variable_declaration",
426            "postfix_expression", "postfix_operator", "prefix_operator", "qualified",
427            "record_type", "record_type_field", "record_type_named_field",
428            "redirecting_factory_constructor_signature", "relational_expression",
429            "relational_operator", "representation_declaration", "rethrow_builtin",
430            "scoped_identifier", "shift_expression", "shift_operator", "spread_element",
431            "static_final_declaration", "static_final_declaration_list", "superclass",
432            "super_formal_parameter", "switch_block", "switch_expression",
433            "switch_expression_case", "switch_statement_default", "symbol_literal",
434            "throw_expression_without_cascade", "tilde_operator", "type_arguments",
435            "type_bound", "type_cast", "type_cast_expression", "type_identifier",
436            "type_parameter", "type_parameters", "type_test", "type_test_expression",
437            "typed_identifier", "unary_expression", "void_type", "yield_each_statement",
438            "yield_statement",
439            // control flow — not extracted as symbols
440            "logical_and_expression",
441            "for_statement",
442            "do_statement",
443            "try_statement",
444            "return_statement",
445            "continue_statement",
446            "catch_clause",
447            "conditional_expression",
448            "break_statement",
449            "switch_statement_case",
450            "block",
451            "switch_statement",
452            "if_statement",
453            "throw_expression",
454            "rethrow_expression",
455            "function_body",
456            "function_expression",
457            "logical_or_expression",
458            "library_export",
459            "while_statement",
460            "import_specification",
461            "method_signature",
462            "type_alias",
463        ];
464        validate_unused_kinds_audit(&Dart, documented_unused)
465            .expect("Dart unused node kinds audit failed");
466    }
467}