Skip to main content

normalize_languages/
lua.rs

1//! Lua language support.
2
3use crate::docstring::extract_preceding_prefix_comments;
4use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
5use crate::{Import, Language, LanguageSymbols, Visibility};
6use std::path::Path;
7use tree_sitter::Node;
8
9/// Lua language support.
10pub struct Lua;
11
12impl Language for Lua {
13    fn name(&self) -> &'static str {
14        "Lua"
15    }
16    fn extensions(&self) -> &'static [&'static str] {
17        &["lua"]
18    }
19    fn grammar_name(&self) -> &'static str {
20        "lua"
21    }
22
23    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
24        Some(self)
25    }
26
27    fn signature_suffix(&self) -> &'static str {
28        " end"
29    }
30
31    fn build_signature(&self, node: &Node, content: &str) -> String {
32        let name = match self.node_name(node, content) {
33            Some(n) => n,
34            None => {
35                let text = &content[node.byte_range()];
36                return text.lines().next().unwrap_or(text).trim().to_string();
37            }
38        };
39        let params = node
40            .child_by_field_name("parameters")
41            .map(|p| content[p.byte_range()].to_string())
42            .unwrap_or_else(|| "()".to_string());
43        let text = &content[node.byte_range()];
44        let is_local = text.trim_start().starts_with("local ");
45        let keyword = if is_local {
46            "local function"
47        } else {
48            "function"
49        };
50        format!("{} {}{}", keyword, name, params)
51    }
52
53    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
54        // Look for require("module") calls
55        if node.kind() != "function_call" {
56            return Vec::new();
57        }
58
59        let func_name = node
60            .child_by_field_name("name")
61            .map(|n| &content[n.byte_range()]);
62
63        if func_name != Some("require") {
64            return Vec::new();
65        }
66
67        if let Some(args) = node.child_by_field_name("arguments") {
68            let mut cursor = args.walk();
69            for child in args.children(&mut cursor) {
70                if child.kind() == "string" {
71                    let module = content[child.byte_range()]
72                        .trim_matches(|c| c == '"' || c == '\'' || c == '[' || c == ']')
73                        .to_string();
74                    return vec![Import {
75                        module,
76                        names: Vec::new(),
77                        alias: None,
78                        is_wildcard: false,
79                        is_relative: false,
80                        line: node.start_position().row + 1,
81                    }];
82                }
83            }
84        }
85
86        Vec::new()
87    }
88
89    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
90        // Lua: require("module")
91        format!("require(\"{}\")", import.module)
92    }
93
94    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
95        let text = &content[node.byte_range()];
96        if text.trim_start().starts_with("local ") {
97            Visibility::Private
98        } else {
99            Visibility::Public
100        }
101    }
102
103    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
104        let name = symbol.name.as_str();
105        match symbol.kind {
106            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
107            crate::SymbolKind::Module => name == "tests" || name == "test",
108            _ => false,
109        }
110    }
111
112    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
113        // LuaDoc comments start with ---
114        extract_preceding_prefix_comments(node, content, "---")
115    }
116
117    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
118        node.child_by_field_name("body")
119    }
120
121    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
122        static RESOLVER: LuaModuleResolver = LuaModuleResolver;
123        Some(&RESOLVER)
124    }
125}
126
127impl LanguageSymbols for Lua {}
128
129// =============================================================================
130// Lua Module Resolver
131// =============================================================================
132
133/// Module resolver for Lua.
134///
135/// `require("foo.bar")` → `foo/bar.lua` or `foo/bar/init.lua`.
136pub struct LuaModuleResolver;
137
138impl ModuleResolver for LuaModuleResolver {
139    fn workspace_config(&self, root: &Path) -> ResolverConfig {
140        ResolverConfig {
141            workspace_root: root.to_path_buf(),
142            path_mappings: Vec::new(),
143            search_roots: vec![root.to_path_buf()],
144        }
145    }
146
147    fn module_of_file(&self, root: &Path, file: &Path, _cfg: &ResolverConfig) -> Vec<ModuleId> {
148        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
149        if ext != "lua" {
150            return Vec::new();
151        }
152        if let Ok(rel) = file.strip_prefix(root) {
153            let rel_str = rel.to_str().unwrap_or("");
154            // Handle init.lua → directory name as module
155            let module = if rel_str.ends_with("/init.lua") || rel_str == "init.lua" {
156                rel_str
157                    .trim_end_matches("/init.lua")
158                    .trim_end_matches("init.lua")
159                    .trim_end_matches('/')
160                    .replace('/', ".")
161            } else {
162                rel_str.trim_end_matches(".lua").replace('/', ".")
163            };
164            if !module.is_empty() {
165                return vec![ModuleId {
166                    canonical_path: module,
167                }];
168            }
169        }
170        Vec::new()
171    }
172
173    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
174        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
175        if ext != "lua" {
176            return Resolution::NotApplicable;
177        }
178        let raw = &spec.raw;
179        let path_part = raw.replace('.', "/");
180        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
181
182        let direct = cfg.workspace_root.join(format!("{}.lua", path_part));
183        if direct.exists() {
184            return Resolution::Resolved(direct, exported_name);
185        }
186        let init = cfg.workspace_root.join(format!("{}/init.lua", path_part));
187        if init.exists() {
188            return Resolution::Resolved(init, exported_name);
189        }
190        Resolution::NotFound
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::validate_unused_kinds_audit;
198
199    #[test]
200    fn unused_node_kinds_audit() {
201        #[rustfmt::skip]
202        let documented_unused: &[&str] = &[ "binary_expression", "block",
203            "bracket_index_expression", "else_statement",
204            "empty_statement", "for_generic_clause",
205            "for_numeric_clause", "identifier", "label_statement", "parenthesized_expression", "table_constructor",
206            "unary_expression", "vararg_expression", "variable_declaration",
207            // control flow — not extracted as symbols
208            "return_statement",
209            "while_statement",
210            "elseif_statement",
211            "for_statement",
212            "goto_statement",
213            "do_statement",
214            "if_statement",
215            "break_statement",
216            "repeat_statement",
217            "function_call",
218        ];
219        validate_unused_kinds_audit(&Lua, documented_unused)
220            .expect("Lua unused node kinds audit failed");
221    }
222}