Skip to main content

normalize_languages/
julia.rs

1//! Julia language support.
2
3use std::path::{Path, PathBuf};
4
5use crate::{
6    ContainerBody, Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver,
7    Resolution, ResolverConfig,
8};
9use tree_sitter::Node;
10
11/// Julia language support.
12pub struct Julia;
13
14impl Language for Julia {
15    fn name(&self) -> &'static str {
16        "Julia"
17    }
18    fn extensions(&self) -> &'static [&'static str] {
19        &["jl"]
20    }
21    fn grammar_name(&self) -> &'static str {
22        "julia"
23    }
24
25    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
26        Some(self)
27    }
28
29    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
30        // module_definition has a "name" field
31        if let Some(name_node) = node.child_by_field_name("name") {
32            return Some(&content[name_node.byte_range()]);
33        }
34        // function_definition/macro_definition: name in signature (no named children)
35        // struct_definition/abstract_definition: name in type_head
36        let mut cursor = node.walk();
37        for child in node.children(&mut cursor) {
38            if child.kind() == "signature" || child.kind() == "type_head" {
39                let text = &content[child.byte_range()];
40                // "add(a, b)" → "add", "Foo <: Bar" → "Foo"
41                let end = text
42                    .find(|c: char| c == '(' || c == '<' || c == '{' || c.is_whitespace())
43                    .unwrap_or(text.len());
44                if end > 0 {
45                    return Some(&content[child.start_byte()..child.start_byte() + end]);
46                }
47            }
48            if child.kind() == "identifier" {
49                return Some(&content[child.byte_range()]);
50            }
51        }
52        None
53    }
54
55    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
56        let prev = node.prev_sibling()?;
57        if prev.kind() != "string_literal" {
58            return None;
59        }
60
61        let text = &content[prev.byte_range()];
62        if !text.starts_with("\"\"\"") {
63            return None;
64        }
65
66        // Strip the triple quotes and clean up
67        let inner = text
68            .strip_prefix("\"\"\"")
69            .unwrap_or(text)
70            .strip_suffix("\"\"\"")
71            .unwrap_or(text);
72
73        let lines: Vec<&str> = inner
74            .lines()
75            .map(|l| l.trim())
76            .filter(|l| !l.is_empty())
77            .collect();
78
79        if lines.is_empty() {
80            return None;
81        }
82
83        Some(lines.join(" "))
84    }
85
86    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
87        let text = &content[node.byte_range()];
88        let line = node.start_position().row + 1;
89
90        let (keyword, is_wildcard) = if text.starts_with("using ") {
91            ("using ", true)
92        } else if text.starts_with("import ") {
93            ("import ", false)
94        } else {
95            return Vec::new();
96        };
97
98        let rest = text.strip_prefix(keyword).unwrap_or("");
99        let module = rest
100            .split([':', ','])
101            .next()
102            .map(|s| s.trim().to_string())
103            .unwrap_or_default();
104
105        if module.is_empty() {
106            return Vec::new();
107        }
108
109        vec![Import {
110            module,
111            names: Vec::new(),
112            alias: None,
113            is_wildcard,
114            is_relative: false,
115            line,
116        }]
117    }
118
119    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
120        // Julia: using Module or import Module: a, b, c
121        let names_to_use: Vec<&str> = names
122            .map(|n| n.to_vec())
123            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
124        if names_to_use.is_empty() {
125            format!("using {}", import.module)
126        } else {
127            format!("import {}: {}", import.module, names_to_use.join(", "))
128        }
129    }
130
131    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
132        let name = symbol.name.as_str();
133        match symbol.kind {
134            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
135            crate::SymbolKind::Module => name == "tests" || name == "test",
136            _ => false,
137        }
138    }
139
140    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
141        node.child_by_field_name("body")
142    }
143
144    fn analyze_container_body(
145        &self,
146        body_node: &Node,
147        content: &str,
148        inner_indent: &str,
149    ) -> Option<ContainerBody> {
150        crate::body::analyze_end_body(body_node, content, inner_indent)
151    }
152
153    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
154        static RESOLVER: JuliaModuleResolver = JuliaModuleResolver;
155        Some(&RESOLVER)
156    }
157}
158
159impl LanguageSymbols for Julia {}
160
161// =============================================================================
162// Julia Module Resolver
163// =============================================================================
164
165/// Module resolver for Julia.
166///
167/// `include("utils.jl")` is a relative file inclusion resolved against the
168/// caller's directory. `using`/`import` of a package name is matched against
169/// workspace packages (from `Project.toml`). Other external packages return
170/// `NotFound`.
171pub struct JuliaModuleResolver;
172
173impl ModuleResolver for JuliaModuleResolver {
174    fn workspace_config(&self, root: &Path) -> ResolverConfig {
175        let mut path_mappings: Vec<(String, PathBuf)> = Vec::new();
176
177        let project_toml = root.join("Project.toml");
178        if let Ok(content) = std::fs::read_to_string(&project_toml)
179            && let Ok(parsed) = content.parse::<toml::Value>()
180            && let Some(name) = parsed.get("name").and_then(|v| v.as_str())
181        {
182            let src_dir = root.join("src");
183            path_mappings.push((name.to_string(), src_dir));
184        }
185
186        ResolverConfig {
187            workspace_root: root.to_path_buf(),
188            path_mappings,
189            search_roots: Vec::new(),
190        }
191    }
192
193    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
194        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
195        if ext != "jl" {
196            return Vec::new();
197        }
198
199        // Try stripping from src/ first, then workspace root
200        let src_dir = cfg.workspace_root.join("src");
201        let base = if let Ok(rel) = file.strip_prefix(&src_dir) {
202            rel.to_string_lossy().into_owned()
203        } else if let Ok(rel) = file.strip_prefix(&cfg.workspace_root) {
204            rel.to_string_lossy().into_owned()
205        } else {
206            return Vec::new();
207        };
208
209        let canonical = base.strip_suffix(".jl").unwrap_or(&base).to_string();
210
211        if canonical.is_empty() {
212            return Vec::new();
213        }
214        vec![ModuleId {
215            canonical_path: canonical,
216        }]
217    }
218
219    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
220        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
221        if ext != "jl" {
222            return Resolution::NotApplicable;
223        }
224
225        let raw = &spec.raw;
226
227        // include("file.jl") — relative path, ends with .jl or is_relative
228        if spec.is_relative || raw.ends_with(".jl") {
229            let base_dir = from_file.parent().unwrap_or(&cfg.workspace_root);
230            let candidate = base_dir.join(raw);
231            if candidate.exists() {
232                return Resolution::Resolved(candidate, String::new());
233            }
234            // Try adding .jl if not present
235            if !raw.ends_with(".jl") {
236                let with_ext = base_dir.join(format!("{}.jl", raw));
237                if with_ext.exists() {
238                    return Resolution::Resolved(with_ext, String::new());
239                }
240            }
241            return Resolution::NotFound;
242        }
243
244        // using/import PackageName — check workspace packages
245        for (pkg_name, pkg_src) in &cfg.path_mappings {
246            if raw == pkg_name {
247                // Try src/<PkgName>.jl
248                let main_file = pkg_src.join(format!("{}.jl", pkg_name));
249                if main_file.exists() {
250                    return Resolution::Resolved(main_file, String::new());
251                }
252                // Fallback: src/ directory itself
253                if pkg_src.exists() {
254                    return Resolution::Resolved(pkg_src.clone(), String::new());
255                }
256            }
257        }
258
259        // External package — not resolvable
260        Resolution::NotFound
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::validate_unused_kinds_audit;
268
269    #[test]
270    fn unused_node_kinds_audit() {
271        #[rustfmt::skip]
272        let documented_unused: &[&str] = &[
273            "adjoint_expression", "binary_expression", "block",
274            "block_comment", "break_statement", "broadcast_call_expression", "call_expression",
275            "catch_clause", "compound_assignment_expression", "compound_statement",
276            "comprehension_expression", "continue_statement", "curly_expression", "else_clause",
277            "export_statement", "field_expression", "finally_clause", "for_binding", "for_clause",
278            "generator", "global_statement", "identifier", "if_clause", "import_alias",
279            "import_path", "index_expression", "interpolation_expression",
280            "juxtaposition_expression", "local_statement", "macro_identifier",
281            "macrocall_expression", "matrix_expression", "operator", "parametrized_type_expression",
282            "parenthesized_expression", "public_statement", "quote_expression", "quote_statement",
283            "range_expression", "return_statement", "selected_import", "splat_expression",
284            "tuple_expression", "typed_expression", "unary_expression",
285            "unary_typed_expression", "vector_expression", "where_expression",
286            // covered by tags.scm
287            "const_statement",
288            "arrow_function_expression",
289            "if_statement",
290            "using_statement",
291            "primitive_definition",
292            "for_statement",
293            "let_statement",
294            "ternary_expression",
295            "do_clause",
296            "while_statement",
297            "try_statement",
298            "elseif_clause",
299            "import_statement",
300        ];
301        validate_unused_kinds_audit(&Julia, documented_unused)
302            .expect("Julia unused node kinds audit failed");
303    }
304}