Skip to main content

normalize_languages/
zig.rs

1//! Zig language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// Zig language support.
9pub struct Zig;
10
11impl Language for Zig {
12    fn name(&self) -> &'static str {
13        "Zig"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["zig"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "zig"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["ContainerDecl"]
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["FnProto", "TestDecl"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["ContainerDecl"]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["SuffixExpr"] // @import("module") is a builtin call suffix
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["FnProto", "ContainerDecl"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::ExplicitExport // pub keyword
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        if !self.is_public(node, content) {
52            return Vec::new();
53        }
54
55        let name = match self.node_name(node, content) {
56            Some(n) => n.to_string(),
57            None => return Vec::new(),
58        };
59
60        let kind = match node.kind() {
61            "FnProto" | "TestDecl" => SymbolKind::Function,
62            "ContainerDecl" => SymbolKind::Struct, // Could be struct/enum/union
63            _ => return Vec::new(),
64        };
65
66        vec![Export {
67            name,
68            kind,
69            line: node.start_position().row + 1,
70        }]
71    }
72
73    fn scope_creating_kinds(&self) -> &'static [&'static str] {
74        &["Block", "ForStatement", "WhileStatement"]
75    }
76
77    fn control_flow_kinds(&self) -> &'static [&'static str] {
78        &[
79            "IfStatement",
80            "ForStatement",
81            "WhileStatement",
82            "SwitchExpr",
83        ]
84    }
85
86    fn complexity_nodes(&self) -> &'static [&'static str] {
87        &[
88            "IfStatement",
89            "ForStatement",
90            "WhileStatement",
91            "SwitchExpr",
92            "ErrorUnionExpr",
93            "BinaryExpr",
94        ]
95    }
96
97    fn nesting_nodes(&self) -> &'static [&'static str] {
98        &[
99            "IfStatement",
100            "ForStatement",
101            "WhileStatement",
102            "SwitchExpr",
103            "FnProto",
104            "ContainerDecl",
105        ]
106    }
107
108    fn signature_suffix(&self) -> &'static str {
109        ""
110    }
111
112    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
113        let name = self.node_name(node, content)?;
114
115        let params = node
116            .child_by_field_name("parameters")
117            .map(|p| content[p.byte_range()].to_string())
118            .unwrap_or_else(|| "()".to_string());
119
120        let return_type = node
121            .child_by_field_name("return_type")
122            .map(|t| content[t.byte_range()].to_string());
123
124        let is_pub = self.is_public(node, content);
125        let prefix = if is_pub { "pub fn" } else { "fn" };
126
127        let signature = if let Some(ret) = return_type {
128            format!("{} {}{} {}", prefix, name, params, ret)
129        } else {
130            format!("{} {}{}", prefix, name, params)
131        };
132
133        Some(Symbol {
134            name: name.to_string(),
135            kind: SymbolKind::Function,
136            signature,
137            docstring: self.extract_docstring(node, content),
138            attributes: Vec::new(),
139            start_line: node.start_position().row + 1,
140            end_line: node.end_position().row + 1,
141            visibility: self.get_visibility(node, content),
142            children: Vec::new(),
143            is_interface_impl: false,
144            implements: Vec::new(),
145        })
146    }
147
148    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
149        let name = self.node_name(node, content)?;
150
151        // Detect struct/enum/union from ContainerDeclType child's first token
152        let mut cursor = node.walk();
153        let mut kind = SymbolKind::Struct;
154        let mut keyword = "struct";
155        for child in node.children(&mut cursor) {
156            if child.kind() == "ContainerDeclType" {
157                // First child of ContainerDeclType is the keyword token
158                if let Some(keyword_node) = child.child(0) {
159                    let kw = &content[keyword_node.byte_range()];
160                    if kw == "enum" {
161                        kind = SymbolKind::Enum;
162                        keyword = "enum";
163                    } else if kw == "union" {
164                        keyword = "union";
165                    }
166                }
167                break;
168            }
169        }
170
171        let is_pub = self.is_public(node, content);
172        let prefix = if is_pub {
173            format!("pub {}", keyword)
174        } else {
175            keyword.to_string()
176        };
177
178        Some(Symbol {
179            name: name.to_string(),
180            kind,
181            signature: format!("{} {}", prefix, name),
182            docstring: self.extract_docstring(node, content),
183            attributes: Vec::new(),
184            start_line: node.start_position().row + 1,
185            end_line: node.end_position().row + 1,
186            visibility: self.get_visibility(node, content),
187            children: Vec::new(),
188            is_interface_impl: false,
189            implements: Vec::new(),
190        })
191    }
192
193    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
194        self.extract_container(node, content)
195    }
196
197    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
198        // Zig uses /// for doc comments
199        let mut prev = node.prev_sibling();
200        let mut doc_lines = Vec::new();
201
202        while let Some(sibling) = prev {
203            let text = &content[sibling.byte_range()];
204            if sibling.kind() == "doc_comment" || text.starts_with("///") {
205                let line = text.strip_prefix("///").unwrap_or(text).trim();
206                doc_lines.push(line.to_string());
207                prev = sibling.prev_sibling();
208            } else {
209                break;
210            }
211        }
212
213        if doc_lines.is_empty() {
214            return None;
215        }
216
217        doc_lines.reverse();
218        Some(doc_lines.join(" "))
219    }
220
221    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
222        Vec::new()
223    }
224
225    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
226        // Look for @import("module")
227        if node.kind() != "builtin_call_expression" {
228            return Vec::new();
229        }
230
231        let text = &content[node.byte_range()];
232        if !text.starts_with("@import") {
233            return Vec::new();
234        }
235
236        // Extract the string argument
237        let mut cursor = node.walk();
238        for child in node.children(&mut cursor) {
239            if child.kind() == "string_literal" {
240                let module = content[child.byte_range()].trim_matches('"').to_string();
241                let is_relative = module.starts_with('.');
242                return vec![Import {
243                    module,
244                    names: Vec::new(),
245                    alias: None,
246                    is_wildcard: false,
247                    is_relative,
248                    line: node.start_position().row + 1,
249                }];
250            }
251        }
252
253        Vec::new()
254    }
255
256    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
257        // Zig: @import("module")
258        format!("@import(\"{}\")", import.module)
259    }
260
261    fn is_public(&self, node: &Node, content: &str) -> bool {
262        // Check for pub keyword before the declaration
263        if let Some(prev) = node.prev_sibling() {
264            let text = &content[prev.byte_range()];
265            if text == "pub" {
266                return true;
267            }
268        }
269        // Also check if node starts with pub
270        let text = &content[node.byte_range()];
271        text.starts_with("pub ")
272    }
273
274    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
275        if self.is_public(node, content) {
276            Visibility::Public
277        } else {
278            Visibility::Private
279        }
280    }
281
282    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
283        let name = symbol.name.as_str();
284        match symbol.kind {
285            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
286            crate::SymbolKind::Module => name == "tests" || name == "test",
287            _ => false,
288        }
289    }
290
291    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
292        None
293    }
294
295    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
296        node.child_by_field_name("body")
297    }
298
299    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
300        false
301    }
302
303    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
304        node.child_by_field_name("name")
305            .map(|n| &content[n.byte_range()])
306    }
307
308    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
309        let ext = path.extension()?.to_str()?;
310        if ext != "zig" {
311            return None;
312        }
313        let stem = path.file_stem()?.to_str()?;
314        Some(stem.to_string())
315    }
316
317    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
318        vec![format!("{}.zig", module)]
319    }
320
321    fn lang_key(&self) -> &'static str {
322        "zig"
323    }
324
325    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
326        import_name == "std" || import_name == "builtin"
327    }
328
329    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
330        // Could look for zig installation
331        None
332    }
333
334    fn resolve_local_import(
335        &self,
336        import: &str,
337        current_file: &Path,
338        project_root: &Path,
339    ) -> Option<PathBuf> {
340        if !import.ends_with(".zig") {
341            return None;
342        }
343
344        // Relative imports
345        if import.starts_with('.') {
346            if let Some(dir) = current_file.parent() {
347                let full = dir.join(import);
348                if full.is_file() {
349                    return Some(full);
350                }
351            }
352        }
353
354        // Absolute path from project root
355        let full = project_root.join(import);
356        if full.is_file() {
357            return Some(full);
358        }
359
360        None
361    }
362
363    fn resolve_external_import(
364        &self,
365        _import_name: &str,
366        _project_root: &Path,
367    ) -> Option<ResolvedPackage> {
368        // Zig package manager resolution would go here
369        None
370    }
371
372    fn get_version(&self, project_root: &Path) -> Option<String> {
373        // Check build.zig.zon for version
374        let zon = project_root.join("build.zig.zon");
375        if zon.is_file() {
376            if let Ok(content) = std::fs::read_to_string(&zon) {
377                // Quick parse for .version = "x.y.z"
378                for line in content.lines() {
379                    if line.contains(".version") && line.contains('"') {
380                        if let Some(start) = line.find('"') {
381                            let rest = &line[start + 1..];
382                            if let Some(end) = rest.find('"') {
383                                return Some(rest[..end].to_string());
384                            }
385                        }
386                    }
387                }
388            }
389        }
390        None
391    }
392
393    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
394        None
395    }
396    fn indexable_extensions(&self) -> &'static [&'static str] {
397        &["zig"]
398    }
399    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
400        Vec::new()
401    }
402
403    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
404        use crate::traits::{has_extension, skip_dotfiles};
405        if skip_dotfiles(name) {
406            return true;
407        }
408        if is_dir && name == "zig-cache" {
409            return true;
410        }
411        !is_dir && !has_extension(name, self.indexable_extensions())
412    }
413
414    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
415        Vec::new()
416    }
417
418    fn package_module_name(&self, entry_name: &str) -> String {
419        entry_name
420            .strip_suffix(".zig")
421            .unwrap_or(entry_name)
422            .to_string()
423    }
424
425    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
426        if path.is_file() {
427            return Some(path.to_path_buf());
428        }
429        // Check for src/main.zig or src/root.zig
430        for name in &["src/main.zig", "src/root.zig", "main.zig"] {
431            let entry = path.join(name);
432            if entry.is_file() {
433                return Some(entry);
434            }
435        }
436        None
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::validate_unused_kinds_audit;
444
445    #[test]
446    fn unused_node_kinds_audit() {
447        #[rustfmt::skip]
448        let documented_unused: &[&str] = &[
449            // Zig grammar uses PascalCase node kinds
450            "ArrayTypeStart", "BUILTINIDENTIFIER", "BitShiftOp", "BlockExpr",
451            "BlockExprStatement", "BlockLabel", "BuildinTypeExpr", "ContainerDeclType",
452            "ForArgumentsList", "ForExpr", "ForItem", "ForPrefix", "ForTypeExpr",
453            "FormatSequence", "IDENTIFIER", "IfExpr", "IfPrefix", "IfTypeExpr",
454            "LabeledStatement", "LabeledTypeExpr", "LoopExpr", "LoopStatement",
455            "LoopTypeExpr", "ParamType", "PrefixTypeOp", "PtrTypeStart",
456            "SliceTypeStart", "Statement", "SwitchCase", "WhileContinueExpr",
457            "WhileExpr", "WhilePrefix", "WhileTypeExpr",
458        ];
459        validate_unused_kinds_audit(&Zig, documented_unused)
460            .expect("Zig unused node kinds audit failed");
461    }
462}