Skip to main content

normalize_languages/
lua.rs

1//! Lua 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/// Lua language support.
9pub struct Lua;
10
11impl Language for Lua {
12    fn name(&self) -> &'static str {
13        "Lua"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["lua"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "lua"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &[] // Lua doesn't have traditional classes
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["function_declaration", "function_definition"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &[]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["function_call"] // require("module")
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["function_declaration", "function_definition"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::NamingConvention // local = private, global = public
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        // Non-local functions are public
52        if node.kind() == "function_declaration" {
53            if let Some(name) = self.node_name(node, content) {
54                return vec![Export {
55                    name: name.to_string(),
56                    kind: SymbolKind::Function,
57                    line: node.start_position().row + 1,
58                }];
59            }
60        }
61        Vec::new()
62    }
63
64    fn scope_creating_kinds(&self) -> &'static [&'static str] {
65        &[
66            "do_statement",
67            "for_statement",
68            "while_statement",
69            "repeat_statement",
70        ]
71    }
72
73    fn control_flow_kinds(&self) -> &'static [&'static str] {
74        &[
75            "if_statement",
76            "for_statement",
77            "while_statement",
78            "repeat_statement",
79            "return_statement",
80            "break_statement",
81            "goto_statement",
82        ]
83    }
84
85    fn complexity_nodes(&self) -> &'static [&'static str] {
86        &[
87            "if_statement",
88            "elseif_statement",
89            "for_statement",
90            "while_statement",
91            "repeat_statement",
92            "and",
93            "or",
94        ]
95    }
96
97    fn nesting_nodes(&self) -> &'static [&'static str] {
98        &[
99            "if_statement",
100            "for_statement",
101            "while_statement",
102            "repeat_statement",
103            "function_declaration",
104            "function_definition",
105        ]
106    }
107
108    fn signature_suffix(&self) -> &'static str {
109        " end"
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        // Check if function text starts with "local"
121        let text = &content[node.byte_range()];
122        let is_local = text.trim_start().starts_with("local ");
123        let keyword = if is_local {
124            "local function"
125        } else {
126            "function"
127        };
128        let signature = format!("{} {}{}", keyword, name, params);
129
130        Some(Symbol {
131            name: name.to_string(),
132            kind: SymbolKind::Function,
133            signature,
134            docstring: self.extract_docstring(node, content),
135            attributes: Vec::new(),
136            start_line: node.start_position().row + 1,
137            end_line: node.end_position().row + 1,
138            visibility: if is_local {
139                Visibility::Private
140            } else {
141                Visibility::Public
142            },
143            children: Vec::new(),
144            is_interface_impl: false,
145            implements: Vec::new(),
146        })
147    }
148
149    fn extract_container(&self, _node: &Node, _content: &str) -> Option<Symbol> {
150        None
151    }
152    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
153        None
154    }
155
156    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
157        // Lua uses --- or --[[ ]] for documentation
158        let mut prev = node.prev_sibling();
159        while let Some(sibling) = prev {
160            let text = &content[sibling.byte_range()];
161            if sibling.kind() == "comment" {
162                // LDoc style: ---
163                if text.starts_with("---") {
164                    let doc = text.strip_prefix("---").unwrap_or(text).trim();
165                    if !doc.starts_with('@') {
166                        return Some(doc.to_string());
167                    }
168                }
169                // Block comment style: --[[ ]]
170                if text.starts_with("--[[") {
171                    let inner = text
172                        .strip_prefix("--[[")
173                        .unwrap_or(text)
174                        .strip_suffix("]]")
175                        .unwrap_or(text)
176                        .trim();
177                    if !inner.is_empty() {
178                        return Some(inner.to_string());
179                    }
180                }
181                break;
182            }
183            prev = sibling.prev_sibling();
184        }
185        None
186    }
187
188    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
189        Vec::new()
190    }
191
192    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
193        // Look for require("module") calls
194        if node.kind() != "function_call" {
195            return Vec::new();
196        }
197
198        let func_name = node
199            .child_by_field_name("name")
200            .map(|n| &content[n.byte_range()]);
201
202        if func_name != Some("require") {
203            return Vec::new();
204        }
205
206        if let Some(args) = node.child_by_field_name("arguments") {
207            let mut cursor = args.walk();
208            for child in args.children(&mut cursor) {
209                if child.kind() == "string" {
210                    let module = content[child.byte_range()]
211                        .trim_matches(|c| c == '"' || c == '\'' || c == '[' || c == ']')
212                        .to_string();
213                    return vec![Import {
214                        module,
215                        names: Vec::new(),
216                        alias: None,
217                        is_wildcard: false,
218                        is_relative: false,
219                        line: node.start_position().row + 1,
220                    }];
221                }
222            }
223        }
224
225        Vec::new()
226    }
227
228    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
229        // Lua: require("module")
230        format!("require(\"{}\")", import.module)
231    }
232
233    fn is_public(&self, node: &Node, content: &str) -> bool {
234        let text = &content[node.byte_range()];
235        !text.trim_start().starts_with("local ")
236    }
237
238    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
239        let text = &content[node.byte_range()];
240        if text.trim_start().starts_with("local ") {
241            Visibility::Private
242        } else {
243            Visibility::Public
244        }
245    }
246
247    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
248        let name = symbol.name.as_str();
249        match symbol.kind {
250            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
251            crate::SymbolKind::Module => name == "tests" || name == "test",
252            _ => false,
253        }
254    }
255
256    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
257        None
258    }
259
260    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
261        node.child_by_field_name("body")
262    }
263
264    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
265        false
266    }
267
268    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
269        node.child_by_field_name("name")
270            .map(|n| &content[n.byte_range()])
271    }
272
273    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
274        let ext = path.extension()?.to_str()?;
275        if ext != "lua" {
276            return None;
277        }
278        let stem = path.file_stem()?.to_str()?;
279        Some(stem.to_string())
280    }
281
282    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
283        let path = module.replace('.', "/");
284        vec![format!("{}.lua", path), format!("{}/init.lua", path)]
285    }
286
287    fn lang_key(&self) -> &'static str {
288        "lua"
289    }
290
291    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
292        // Lua stdlib modules
293        matches!(
294            import_name,
295            "string"
296                | "table"
297                | "math"
298                | "io"
299                | "os"
300                | "debug"
301                | "coroutine"
302                | "package"
303                | "utf8"
304                | "bit32"
305        )
306    }
307
308    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
309        None
310    }
311
312    fn resolve_local_import(
313        &self,
314        import: &str,
315        current_file: &Path,
316        project_root: &Path,
317    ) -> Option<PathBuf> {
318        let path_part = import.replace('.', "/");
319        let paths = [
320            format!("{}.lua", path_part),
321            format!("{}/init.lua", path_part),
322        ];
323
324        // Check relative to current file
325        if let Some(dir) = current_file.parent() {
326            for p in &paths {
327                let full = dir.join(p);
328                if full.is_file() {
329                    return Some(full);
330                }
331            }
332        }
333
334        // Check relative to project root
335        for p in &paths {
336            let full = project_root.join(p);
337            if full.is_file() {
338                return Some(full);
339            }
340        }
341
342        None
343    }
344
345    fn resolve_external_import(
346        &self,
347        _import_name: &str,
348        _project_root: &Path,
349    ) -> Option<ResolvedPackage> {
350        // LuaRocks package resolution would go here
351        None
352    }
353
354    fn get_version(&self, _project_root: &Path) -> Option<String> {
355        None
356    }
357    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
358        None
359    }
360    fn indexable_extensions(&self) -> &'static [&'static str] {
361        &["lua"]
362    }
363    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
364        Vec::new()
365    }
366
367    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
368        use crate::traits::{has_extension, skip_dotfiles};
369        if skip_dotfiles(name) {
370            return true;
371        }
372        !is_dir && !has_extension(name, self.indexable_extensions())
373    }
374
375    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
376        Vec::new()
377    }
378
379    fn package_module_name(&self, entry_name: &str) -> String {
380        entry_name
381            .strip_suffix(".lua")
382            .unwrap_or(entry_name)
383            .to_string()
384    }
385
386    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
387        if path.is_file() {
388            return Some(path.to_path_buf());
389        }
390        let init = path.join("init.lua");
391        if init.is_file() {
392            return Some(init);
393        }
394        None
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::validate_unused_kinds_audit;
402
403    #[test]
404    fn unused_node_kinds_audit() {
405        #[rustfmt::skip]
406        let documented_unused: &[&str] = &[
407            "assignment_statement", "binary_expression", "block",
408            "bracket_index_expression", "dot_index_expression", "else_statement",
409            "empty_statement", "expression_list", "for_generic_clause",
410            "for_numeric_clause", "identifier", "label_statement",
411            "method_index_expression", "parenthesized_expression", "table_constructor",
412            "unary_expression", "vararg_expression", "variable_declaration",
413        ];
414        validate_unused_kinds_audit(&Lua, documented_unused)
415            .expect("Lua unused node kinds audit failed");
416    }
417}