Skip to main content

normalize_languages/
php.rs

1//! PHP language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// PHP language support.
9pub struct Php;
10
11impl Language for Php {
12    fn name(&self) -> &'static str {
13        "PHP"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["php", "phtml"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "php"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn signature_suffix(&self) -> &'static str {
27        " {}"
28    }
29
30    fn refine_kind(
31        &self,
32        node: &Node,
33        _content: &str,
34        tag_kind: crate::SymbolKind,
35    ) -> crate::SymbolKind {
36        match node.kind() {
37            "enum_declaration" => crate::SymbolKind::Enum,
38            "interface_declaration" => crate::SymbolKind::Interface,
39            "trait_declaration" => crate::SymbolKind::Trait,
40            _ => tag_kind,
41        }
42    }
43
44    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
45        let mut attrs = Vec::new();
46        let mut cursor = node.walk();
47        for child in node.children(&mut cursor) {
48            if child.kind() == "attribute_list" {
49                let mut ac = child.walk();
50                for attr in child.children(&mut ac) {
51                    if attr.kind() == "attribute_group" || attr.kind() == "attribute" {
52                        attrs.push(content[attr.byte_range()].to_string());
53                    }
54                }
55            }
56        }
57        // Also check preceding siblings (PHP attributes can precede the declaration)
58        let mut prev = node.prev_sibling();
59        while let Some(sibling) = prev {
60            if sibling.kind() == "attribute_list" {
61                let mut ac = sibling.walk();
62                for attr in sibling.children(&mut ac) {
63                    if attr.kind() == "attribute_group" || attr.kind() == "attribute" {
64                        attrs.push(content[attr.byte_range()].to_string());
65                    }
66                }
67                prev = sibling.prev_sibling();
68                continue;
69            }
70            break;
71        }
72        attrs
73    }
74
75    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
76        extract_phpdoc(node, content)
77    }
78
79    fn build_signature(&self, node: &Node, content: &str) -> String {
80        let name = match self.node_name(node, content) {
81            Some(n) => n,
82            None => {
83                let text = &content[node.byte_range()];
84                return text.lines().next().unwrap_or(text).trim().to_string();
85            }
86        };
87        match node.kind() {
88            "function_declaration" | "method_declaration" => {
89                let params = node
90                    .child_by_field_name("parameters")
91                    .map(|p| content[p.byte_range()].to_string())
92                    .unwrap_or_else(|| "()".to_string());
93                let return_type = node
94                    .child_by_field_name("return_type")
95                    .map(|t| format!(": {}", content[t.byte_range()].trim()))
96                    .unwrap_or_default();
97                format!("function {}{}{}", name, params, return_type)
98            }
99            "interface_declaration" => format!("interface {}", name),
100            "trait_declaration" => format!("trait {}", name),
101            "enum_declaration" => format!("enum {}", name),
102            "namespace_definition" => format!("namespace {}", name),
103            _ => format!("class {}", name),
104        }
105    }
106
107    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
108        let mut implements = Vec::new();
109        let mut cursor = node.walk();
110        for child in node.children(&mut cursor) {
111            if child.kind() == "base_clause" || child.kind() == "class_interface_clause" {
112                let mut cl = child.walk();
113                for t in child.children(&mut cl) {
114                    if t.kind() == "name" || t.kind() == "qualified_name" {
115                        implements.push(content[t.byte_range()].to_string());
116                    }
117                }
118            }
119        }
120        crate::ImplementsInfo {
121            is_interface: node.kind() == "interface_declaration",
122            implements,
123        }
124    }
125
126    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
127        if node.kind() != "namespace_use_declaration" {
128            return Vec::new();
129        }
130
131        let line = node.start_position().row + 1;
132        let mut imports = Vec::new();
133
134        let mut cursor = node.walk();
135        for child in node.children(&mut cursor) {
136            if child.kind() == "namespace_use_clause" {
137                let text = content[child.byte_range()].to_string();
138                imports.push(Import {
139                    module: text,
140                    names: Vec::new(),
141                    alias: None,
142                    is_wildcard: false,
143                    is_relative: false,
144                    line,
145                });
146            }
147        }
148
149        imports
150    }
151
152    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
153        // PHP: use Namespace\Class;
154        format!("use {};", import.module)
155    }
156
157    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
158        let name = symbol.name.as_str();
159        match symbol.kind {
160            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
161            crate::SymbolKind::Module => name == "tests" || name == "test",
162            _ => false,
163        }
164    }
165
166    fn test_file_globs(&self) -> &'static [&'static str] {
167        &["**/*Test.php"]
168    }
169
170    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
171        node.child_by_field_name("body")
172    }
173
174    fn analyze_container_body(
175        &self,
176        body_node: &Node,
177        content: &str,
178        inner_indent: &str,
179    ) -> Option<ContainerBody> {
180        crate::body::analyze_brace_body(body_node, content, inner_indent)
181    }
182
183    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
184        let mut cursor = node.walk();
185        for child in node.children(&mut cursor) {
186            if child.kind() == "visibility_modifier" {
187                let mod_text = &content[child.byte_range()];
188                if mod_text == "private" {
189                    return Visibility::Private;
190                }
191                if mod_text == "protected" {
192                    return Visibility::Protected;
193                }
194                if mod_text == "public" {
195                    return Visibility::Public;
196                }
197            }
198        }
199        // PHP default visibility for methods/properties in classes is public
200        Visibility::Public
201    }
202
203    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
204        static RESOLVER: PhpModuleResolver = PhpModuleResolver;
205        Some(&RESOLVER)
206    }
207}
208
209impl LanguageSymbols for Php {}
210
211// =============================================================================
212// PHP Module Resolver
213// =============================================================================
214
215/// Module resolver for PHP (PSR-4 / composer.json conventions).
216///
217/// Reads `composer.json` `autoload.psr-4` to build namespace→directory mappings.
218pub struct PhpModuleResolver;
219
220impl ModuleResolver for PhpModuleResolver {
221    fn workspace_config(&self, root: &Path) -> ResolverConfig {
222        let mut path_mappings: Vec<(String, PathBuf)> = Vec::new();
223
224        let composer_json = root.join("composer.json");
225        if let Ok(content) = std::fs::read_to_string(&composer_json)
226            && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
227        {
228            // Parse autoload.psr-4 and autoload-dev.psr-4
229            for autoload_key in &["autoload", "autoload-dev"] {
230                if let Some(psr4) = json
231                    .get(autoload_key)
232                    .and_then(|a| a.get("psr-4"))
233                    .and_then(|p| p.as_object())
234                {
235                    for (namespace, dir) in psr4 {
236                        let ns = namespace.trim_end_matches('\\').to_string();
237                        if let Some(dir_str) = dir.as_str() {
238                            let target = root.join(dir_str);
239                            path_mappings.push((ns, target));
240                        }
241                    }
242                }
243            }
244        }
245
246        ResolverConfig {
247            workspace_root: root.to_path_buf(),
248            path_mappings,
249            search_roots: vec![root.to_path_buf()],
250        }
251    }
252
253    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
254        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
255        if ext != "php" && ext != "phtml" {
256            return Vec::new();
257        }
258        for (ns, dir) in &cfg.path_mappings {
259            if let Ok(rel) = file.strip_prefix(dir) {
260                let rel_str = rel
261                    .to_str()
262                    .unwrap_or("")
263                    .trim_end_matches(".phtml")
264                    .trim_end_matches(".php")
265                    .replace('/', "\\");
266                let canonical = format!("{}\\{}", ns, rel_str);
267                return vec![ModuleId {
268                    canonical_path: canonical,
269                }];
270            }
271        }
272        Vec::new()
273    }
274
275    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
276        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
277        if ext != "php" && ext != "phtml" {
278            return Resolution::NotApplicable;
279        }
280        let raw = &spec.raw;
281        let exported_name = raw.rsplit('\\').next().unwrap_or(raw).to_string();
282
283        // Try PSR-4 mappings
284        for (ns, dir) in &cfg.path_mappings {
285            let ns_prefix = format!("{}\\", ns);
286            if let Some(rest) = raw
287                .strip_prefix(&ns_prefix)
288                .or_else(|| if raw == ns { Some("") } else { None })
289            {
290                let file_path = rest.replace('\\', "/");
291                let candidate = dir.join(format!("{}.php", file_path));
292                if candidate.exists() {
293                    return Resolution::Resolved(candidate, exported_name);
294                }
295            }
296        }
297
298        // Relative require/include
299        if (spec.is_relative || raw.starts_with('.'))
300            && let Some(parent) = from_file.parent()
301        {
302            let candidate = parent.join(raw);
303            if candidate.exists() {
304                return Resolution::Resolved(candidate, exported_name);
305            }
306        }
307
308        Resolution::NotFound
309    }
310}
311
312/// Extract a PHPDoc comment (`/** ... */`) preceding a PHP declaration.
313fn extract_phpdoc(node: &Node, content: &str) -> Option<String> {
314    let mut prev = node.prev_sibling();
315    while let Some(sibling) = prev {
316        if sibling.kind() == "comment" {
317            let text = &content[sibling.byte_range()];
318            if text.starts_with("/**") {
319                let lines: Vec<&str> = text
320                    .strip_prefix("/**")
321                    .unwrap_or(text)
322                    .strip_suffix("*/")
323                    .unwrap_or(text)
324                    .lines()
325                    .map(|l| l.trim().strip_prefix('*').unwrap_or(l).trim())
326                    .filter(|l| !l.is_empty())
327                    .collect();
328                if !lines.is_empty() {
329                    return Some(lines.join(" "));
330                }
331            }
332            return None;
333        }
334        // Skip attributes between doc comment and declaration
335        if sibling.kind() == "attribute_list" {
336            prev = sibling.prev_sibling();
337            continue;
338        }
339        return None;
340    }
341    None
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::validate_unused_kinds_audit;
348
349    #[test]
350    fn unused_node_kinds_audit() {
351        #[rustfmt::skip]
352        let documented_unused: &[&str] = &[
353            // STRUCTURAL
354            "abstract_modifier", "anonymous_class", "anonymous_function",
355            "anonymous_function_use_clause", "base_clause", "cast_expression", "cast_type",
356            "class_constant_access_expression", "class_interface_clause", "colon_block",
357            "compound_statement", "const_declaration", "declaration_list", "enum_case",
358            "enum_declaration_list", "final_modifier", "formal_parameters", "heredoc_body",
359            "named_type", "namespace_use_clause", "nowdoc_body",
360            "optional_type", "primitive_type", "property_declaration", "qualified_name",
361            "readonly_modifier", "reference_modifier", "static_modifier", "static_variable_declaration",
362            "use_as_clause", "use_declaration", "use_instead_of_clause", "var_modifier",
363            "visibility_modifier",
364            // CLAUSE
365            "declare_statement", "default_statement", "else_clause", "else_if_clause",
366            "finally_clause", "match_block", "match_condition_list", "match_conditional_expression",
367            "match_default_expression", "switch_block",
368            // EXPRESSION
369            "array_creation_expression", "assignment_expression", "augmented_assignment_expression",
370            "binary_expression", "bottom_type", "clone_expression", "disjunctive_normal_form_type",
371            "error_suppression_expression", "function_call_expression", "function_static_declaration",
372            "include_expression", "include_once_expression", "intersection_type",
373            "match_expression", "member_access_expression", "member_call_expression",
374            "nullsafe_member_access_expression", "nullsafe_member_call_expression",
375            "object_creation_expression", "parenthesized_expression", "reference_assignment_expression",
376            "require_expression", "require_once_expression", "scoped_call_expression",
377            "scoped_property_access_expression", "sequence_expression", "shell_command_expression",
378            "subscript_expression", "type_list", "unary_op_expression", "union_type",
379            "update_expression", "yield_expression",
380            // STATEMENT
381            "echo_statement", "empty_statement", "exit_statement", "expression_statement",
382            "global_declaration", "goto_statement", "named_label_statement", "unset_statement",
383            // control flow — not extracted as symbols
384            "do_statement",
385            "break_statement",
386            "arrow_function",
387            "if_statement",
388            "for_statement",
389            "return_statement",
390            "foreach_statement",
391            "case_statement",
392            "namespace_use_declaration",
393            "switch_statement",
394            "throw_expression",
395            "continue_statement",
396            "catch_clause",
397            "conditional_expression",
398            "while_statement",
399            "try_statement",
400        ];
401
402        validate_unused_kinds_audit(&Php, documented_unused)
403            .expect("PHP unused node kinds audit failed");
404    }
405}