Skip to main content

normalize_languages/
perl.rs

1//! Perl language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::Path;
6use tree_sitter::Node;
7
8/// Perl language support.
9pub struct Perl;
10
11impl Language for Perl {
12    fn name(&self) -> &'static str {
13        "Perl"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["pl", "pm", "t"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "perl"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
27        let text = &content[node.byte_range()];
28        let line = node.start_position().row + 1;
29
30        // use Module::Name;
31        // require Module::Name;
32        let module = if let Some(rest) = text
33            .strip_prefix("use ")
34            .or_else(|| text.strip_prefix("require "))
35        {
36            rest.split([';', ' ']).next()
37        } else {
38            None
39        };
40
41        if let Some(module) = module {
42            let module = module.trim().to_string();
43            return vec![Import {
44                module: module.clone(),
45                names: Vec::new(),
46                alias: None,
47                is_wildcard: false,
48                is_relative: false,
49                line,
50            }];
51        }
52
53        Vec::new()
54    }
55
56    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
57        // Perl: use Module; or use Module qw(a b c);
58        let names_to_use: Vec<&str> = names
59            .map(|n| n.to_vec())
60            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
61        if names_to_use.is_empty() {
62            format!("use {};", import.module)
63        } else {
64            format!("use {} qw({});", import.module, names_to_use.join(" "))
65        }
66    }
67
68    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
69        if self
70            .node_name(node, content)
71            .is_none_or(|n| !n.starts_with('_'))
72        {
73            Visibility::Public
74        } else {
75            Visibility::Private
76        }
77    }
78
79    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
80        let name = symbol.name.as_str();
81        match symbol.kind {
82            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
83            crate::SymbolKind::Module => name == "tests" || name == "test",
84            _ => false,
85        }
86    }
87
88    fn test_file_globs(&self) -> &'static [&'static str] {
89        &["**/t/**/*.t", "**/*.t"]
90    }
91
92    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
93        node.child_by_field_name("body")
94    }
95
96    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
97        let mut doc_lines: Vec<String> = Vec::new();
98        let mut prev = node.prev_sibling();
99
100        while let Some(sibling) = prev {
101            if sibling.kind() == "comment" || sibling.kind() == "comments" {
102                let text = &content[sibling.byte_range()];
103                if let Some(line) = text.strip_prefix('#') {
104                    let line = line.strip_prefix(' ').unwrap_or(line);
105                    doc_lines.push(line.to_string());
106                } else {
107                    break;
108                }
109            } else {
110                break;
111            }
112            prev = sibling.prev_sibling();
113        }
114
115        if doc_lines.is_empty() {
116            return None;
117        }
118
119        doc_lines.reverse();
120        let joined = doc_lines.join("\n").trim().to_string();
121        if joined.is_empty() {
122            None
123        } else {
124            Some(joined)
125        }
126    }
127
128    fn analyze_container_body(
129        &self,
130        body_node: &Node,
131        content: &str,
132        inner_indent: &str,
133    ) -> Option<ContainerBody> {
134        crate::body::analyze_brace_body(body_node, content, inner_indent)
135    }
136
137    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
138        static RESOLVER: PerlModuleResolver = PerlModuleResolver;
139        Some(&RESOLVER)
140    }
141}
142
143impl LanguageSymbols for Perl {}
144
145// =============================================================================
146// Perl Module Resolver
147// =============================================================================
148
149/// Module resolver for Perl.
150///
151/// `use Module::Name` → `Module/Name.pm` in `lib/`.
152pub struct PerlModuleResolver;
153
154impl ModuleResolver for PerlModuleResolver {
155    fn workspace_config(&self, root: &Path) -> ResolverConfig {
156        ResolverConfig {
157            workspace_root: root.to_path_buf(),
158            path_mappings: Vec::new(),
159            search_roots: vec![root.join("lib"), root.to_path_buf()],
160        }
161    }
162
163    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
164        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
165        if ext != "pm" {
166            return Vec::new();
167        }
168        for search_root in &cfg.search_roots {
169            if let Ok(rel) = file.strip_prefix(search_root) {
170                let module = rel
171                    .to_str()
172                    .unwrap_or("")
173                    .trim_end_matches(".pm")
174                    .replace('/', "::");
175                if !module.is_empty() {
176                    return vec![ModuleId {
177                        canonical_path: module,
178                    }];
179                }
180            }
181        }
182        Vec::new()
183    }
184
185    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
186        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
187        if ext != "pl" && ext != "pm" && ext != "t" {
188            return Resolution::NotApplicable;
189        }
190        let raw = &spec.raw;
191        let path_part = raw.replace("::", "/");
192        let exported_name = raw.rsplit("::").next().unwrap_or(raw).to_string();
193
194        for search_root in &cfg.search_roots {
195            let candidate = search_root.join(format!("{}.pm", path_part));
196            if candidate.exists() {
197                return Resolution::Resolved(candidate, exported_name);
198            }
199        }
200        Resolution::NotFound
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::validate_unused_kinds_audit;
208
209    #[test]
210    fn unused_node_kinds_audit() {
211        #[rustfmt::skip]
212        let documented_unused: &[&str] = &[
213            "ambiguous_function_call_expression", "amper_deref_expression",
214            "anonymous_array_expression", "anonymous_hash_expression",
215            "anonymous_method_expression", "anonymous_slice_expression",
216            "anonymous_subroutine_expression", "array_deref_expression",
217            "array_element_expression", "arraylen_deref_expression", "assignment_expression",
218            "await_expression", "binary_expression", "block_statement", "class_phaser_statement",
219            "class_statement", "coderef_call_expression",
220            "defer_statement", "do_expression", "else", "elsif",
221            "equality_expression", "eval_expression", "expression_statement",
222            "fileglob_expression", "func0op_call_expression", "func1op_call_expression",
223            "function", "function_call_expression", "glob_deref_expression",
224            "glob_slot_expression", "goto_expression", "hash_deref_expression",
225            "hash_element_expression", "identifier", "keyval_expression",
226            "list_expression", "localization_expression",
227            "loopex_expression", "lowprec_logical_expression", "map_grep_expression",
228            "match_regexp", "match_regexp_modifiers", "method", "method_call_expression",
229            "method_declaration_statement", "phaser_statement", "postfix_conditional_expression",
230            "postfix_for_expression", "postfix_loop_expression", "postinc_expression",
231            "preinc_expression", "prototype", "quoted_regexp_modifiers", "readline_expression",
232            "refgen_expression", "relational_expression",
233            "require_version_expression", "return_expression", "role_statement",
234            "scalar_deref_expression", "slice_expression", "sort_expression", "statement_label",
235            "stub_expression", "substitution_regexp_modifiers", "transliteration_expression",
236            "transliteration_modifiers", "try_statement", "unary_expression", "undef_expression",
237            "use_version_statement", "variable_declaration",
238            // control flow — not extracted as symbols
239            "for_statement",
240            "conditional_statement",
241            "loop_statement",
242            "cstyle_for_statement",
243            "require_expression",
244            "block",
245            "use_statement",
246            "conditional_expression",
247        ];
248        validate_unused_kinds_audit(&Perl, documented_unused)
249            .expect("Perl unused node kinds audit failed");
250    }
251}