Skip to main content

normalize_languages/
powershell.rs

1//! PowerShell language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{
5    Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism,
6    simple_function_symbol,
7};
8use std::path::{Path, PathBuf};
9use tree_sitter::Node;
10
11/// PowerShell language support.
12pub struct PowerShell;
13
14impl Language for PowerShell {
15    fn name(&self) -> &'static str {
16        "PowerShell"
17    }
18    fn extensions(&self) -> &'static [&'static str] {
19        &["ps1", "psm1", "psd1"]
20    }
21    fn grammar_name(&self) -> &'static str {
22        "powershell"
23    }
24
25    fn has_symbols(&self) -> bool {
26        true
27    }
28
29    fn container_kinds(&self) -> &'static [&'static str] {
30        &["class_statement"]
31    }
32
33    fn function_kinds(&self) -> &'static [&'static str] {
34        &["function_statement"]
35    }
36
37    fn type_kinds(&self) -> &'static [&'static str] {
38        &["class_statement", "enum_statement"]
39    }
40
41    fn import_kinds(&self) -> &'static [&'static str] {
42        &["pipeline"] // Import-Module is a command in a pipeline
43    }
44
45    fn public_symbol_kinds(&self) -> &'static [&'static str] {
46        &["function_statement", "class_statement"]
47    }
48
49    fn visibility_mechanism(&self) -> VisibilityMechanism {
50        VisibilityMechanism::AllPublic
51    }
52
53    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
54        let name = match self.node_name(node, content) {
55            Some(n) => n.to_string(),
56            None => return Vec::new(),
57        };
58
59        let kind = match node.kind() {
60            "function_statement" => SymbolKind::Function,
61            "class_statement" => SymbolKind::Class,
62            _ => return Vec::new(),
63        };
64
65        vec![Export {
66            name,
67            kind,
68            line: node.start_position().row + 1,
69        }]
70    }
71
72    fn scope_creating_kinds(&self) -> &'static [&'static str] {
73        &["function_statement", "class_statement", "script_block"]
74    }
75
76    fn control_flow_kinds(&self) -> &'static [&'static str] {
77        &[
78            "if_statement",
79            "while_statement",
80            "for_statement",
81            "foreach_statement",
82            "switch_statement",
83            "try_statement",
84        ]
85    }
86
87    fn complexity_nodes(&self) -> &'static [&'static str] {
88        &[
89            "if_statement",
90            "elseif_clause",
91            "while_statement",
92            "for_statement",
93            "foreach_statement",
94            "switch_statement",
95            "catch_clause",
96        ]
97    }
98
99    fn nesting_nodes(&self) -> &'static [&'static str] {
100        &[
101            "function_statement",
102            "class_statement",
103            "if_statement",
104            "while_statement",
105            "for_statement",
106            "try_statement",
107        ]
108    }
109
110    fn signature_suffix(&self) -> &'static str {
111        ""
112    }
113
114    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
115        let name = self.node_name(node, content)?;
116        Some(simple_function_symbol(
117            node,
118            content,
119            name,
120            self.extract_docstring(node, content),
121        ))
122    }
123
124    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
125        if node.kind() != "class_statement" {
126            return None;
127        }
128
129        let name = self.node_name(node, content)?;
130        let text = &content[node.byte_range()];
131        let first_line = text.lines().next().unwrap_or(text);
132
133        Some(Symbol {
134            name: name.to_string(),
135            kind: SymbolKind::Class,
136            signature: first_line.trim().to_string(),
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: Visibility::Public,
142            children: Vec::new(),
143            is_interface_impl: false,
144            implements: Vec::new(),
145        })
146    }
147
148    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
149        let name = self.node_name(node, content)?;
150        let kind = match node.kind() {
151            "class_statement" => SymbolKind::Class,
152            "enum_statement" => SymbolKind::Enum,
153            _ => return None,
154        };
155
156        Some(Symbol {
157            name: name.to_string(),
158            kind,
159            signature: format!("{} {}", node.kind().replace("_statement", ""), name),
160            docstring: self.extract_docstring(node, content),
161            attributes: Vec::new(),
162            start_line: node.start_position().row + 1,
163            end_line: node.end_position().row + 1,
164            visibility: Visibility::Public,
165            children: Vec::new(),
166            is_interface_impl: false,
167            implements: Vec::new(),
168        })
169    }
170
171    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
172        // PowerShell uses <# #> for block comments
173        let mut prev = node.prev_sibling();
174        while let Some(sibling) = prev {
175            let text = &content[sibling.byte_range()];
176            if sibling.kind() == "comment" {
177                if text.starts_with("<#") {
178                    let inner = text.trim_start_matches("<#").trim_end_matches("#>").trim();
179                    if !inner.is_empty() {
180                        return Some(inner.lines().next().unwrap_or(inner).to_string());
181                    }
182                } else if text.starts_with('#') {
183                    let line = text.strip_prefix('#').unwrap_or(text).trim();
184                    return Some(line.to_string());
185                }
186            }
187            prev = sibling.prev_sibling();
188        }
189        None
190    }
191
192    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
193        Vec::new()
194    }
195
196    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
197        if node.kind() != "pipeline" {
198            return Vec::new();
199        }
200
201        let text = &content[node.byte_range()];
202        let line = node.start_position().row + 1;
203
204        // Import-Module ModuleName
205        if let Some(rest) = text.strip_prefix("Import-Module ") {
206            let module = rest.split_whitespace().next().map(|s| s.to_string());
207            if let Some(module) = module {
208                return vec![Import {
209                    module,
210                    names: Vec::new(),
211                    alias: None,
212                    is_wildcard: true,
213                    is_relative: false,
214                    line,
215                }];
216            }
217        }
218
219        Vec::new()
220    }
221
222    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
223        // PowerShell: Import-Module or using module
224        format!("Import-Module {}", import.module)
225    }
226
227    fn is_public(&self, _node: &Node, _content: &str) -> bool {
228        true
229    }
230    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
231        Visibility::Public
232    }
233
234    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
235        let name = symbol.name.as_str();
236        match symbol.kind {
237            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
238            crate::SymbolKind::Module => name == "tests" || name == "test",
239            _ => false,
240        }
241    }
242
243    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
244        None
245    }
246
247    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
248        node.child_by_field_name("body")
249    }
250
251    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
252        false
253    }
254
255    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
256        node.child_by_field_name("name")
257            .map(|n| &content[n.byte_range()])
258    }
259
260    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
261        let ext = path.extension()?.to_str()?;
262        if !["ps1", "psm1", "psd1"].contains(&ext) {
263            return None;
264        }
265        let stem = path.file_stem()?.to_str()?;
266        Some(stem.to_string())
267    }
268
269    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
270        vec![format!("{}.psm1", module), format!("{}.ps1", module)]
271    }
272
273    fn lang_key(&self) -> &'static str {
274        "powershell"
275    }
276
277    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
278        matches!(
279            import_name,
280            "Microsoft.PowerShell.Core"
281                | "Microsoft.PowerShell.Utility"
282                | "Microsoft.PowerShell.Management"
283                | "Microsoft.PowerShell.Security"
284        )
285    }
286
287    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
288        None
289    }
290    fn resolve_local_import(&self, import: &str, _: &Path, project_root: &Path) -> Option<PathBuf> {
291        let full = project_root.join(format!("{}.psm1", import));
292        if full.is_file() { Some(full) } else { None }
293    }
294    fn resolve_external_import(&self, _: &str, _: &Path) -> Option<ResolvedPackage> {
295        None
296    }
297
298    fn get_version(&self, project_root: &Path) -> Option<String> {
299        // Check for module manifest
300        for entry in std::fs::read_dir(project_root).ok()? {
301            let entry = entry.ok()?;
302            let path = entry.path();
303            if path.extension().map_or(false, |e| e == "psd1") {
304                return Some("PowerShell Module".to_string());
305            }
306        }
307        None
308    }
309
310    fn find_package_cache(&self, _: &Path) -> Option<PathBuf> {
311        if let Some(home) = std::env::var_os("HOME") {
312            let modules = PathBuf::from(home).join(".local/share/powershell/Modules");
313            if modules.is_dir() {
314                return Some(modules);
315            }
316        }
317        None
318    }
319
320    fn indexable_extensions(&self) -> &'static [&'static str] {
321        &["ps1", "psm1"]
322    }
323    fn package_sources(&self, _: &Path) -> Vec<crate::PackageSource> {
324        Vec::new()
325    }
326
327    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
328        use crate::traits::{has_extension, skip_dotfiles};
329        if skip_dotfiles(name) {
330            return true;
331        }
332        !is_dir && !has_extension(name, self.indexable_extensions())
333    }
334
335    fn discover_packages(&self, _: &crate::PackageSource) -> Vec<(String, PathBuf)> {
336        Vec::new()
337    }
338
339    fn package_module_name(&self, entry_name: &str) -> String {
340        entry_name
341            .strip_suffix(".psm1")
342            .or_else(|| entry_name.strip_suffix(".ps1"))
343            .or_else(|| entry_name.strip_suffix(".psd1"))
344            .unwrap_or(entry_name)
345            .to_string()
346    }
347
348    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
349        if path.is_file() {
350            return Some(path.to_path_buf());
351        }
352        // Look for .psm1 in directory
353        if path.is_dir() {
354            let name = path.file_name()?.to_str()?;
355            let module = path.join(format!("{}.psm1", name));
356            if module.is_file() {
357                return Some(module);
358            }
359        }
360        None
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::validate_unused_kinds_audit;
368
369    #[test]
370    fn unused_node_kinds_audit() {
371        #[rustfmt::skip]
372        let documented_unused: &[&str] = &[
373            "additive_argument_expression", "additive_expression", "argument_expression",
374            "argument_expression_list", "array_expression", "array_literal_expression",
375            "array_type_name", "assignement_operator", "assignment_expression",
376            "bitwise_argument_expression", "bitwise_expression", "block_name", "cast_expression",
377            "catch_clauses", "catch_type_list", "class_attribute", "class_method_definition",
378            "class_method_parameter", "class_method_parameter_list", "class_property_definition",
379            "command_invokation_operator", "comparison_argument_expression",
380            "comparison_expression", "comparison_operator", "data_statement", "do_statement",
381            "else_clause", "elseif_clauses", "empty_statement", "enum_member",
382            "expression_with_unary_operator", "file_redirection_operator", "finally_clause",
383            "flow_control_statement", "for_condition", "for_initializer", "for_iterator",
384            "foreach_command", "foreach_parameter", "format_argument_expression",
385            "format_expression", "format_operator", "function_name",
386            "function_parameter_declaration", "generic_type_arguments", "generic_type_name",
387            "hash_entry", "hash_literal_body", "hash_literal_expression",
388            "inlinescript_statement", "invokation_expression", "invokation_foreach_expression",
389            "key_expression", "label_expression", "left_assignment_expression",
390            "logical_argument_expression", "logical_expression", "merging_redirection_operator",
391            "multiplicative_argument_expression", "multiplicative_expression", "named_block",
392            "named_block_list", "parallel_statement", "param_block", "parenthesized_expression",
393            "post_decrement_expression", "post_increment_expression", "pre_decrement_expression",
394            "pre_increment_expression", "range_argument_expression", "range_expression",
395            "script_block_body", "script_block_expression", "sequence_statement",
396            "statement_block", "statement_list", "sub_expression", "switch_body",
397            "switch_clause", "switch_clause_condition", "switch_clauses", "trap_statement",
398            "type_identifier", "type_literal", "type_name", "type_spec", "unary_expression",
399            "while_condition",
400        ];
401        validate_unused_kinds_audit(&PowerShell, documented_unused)
402            .expect("PowerShell unused node kinds audit failed");
403    }
404}