Skip to main content

normalize_languages/
php.rs

1//! PHP 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/// 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 has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &[
28            "class_declaration",
29            "interface_declaration",
30            "trait_declaration",
31            "enum_declaration",
32            "namespace_definition",
33        ]
34    }
35
36    fn function_kinds(&self) -> &'static [&'static str] {
37        &[
38            "function_definition",
39            "method_declaration",
40            "arrow_function",
41        ]
42    }
43
44    fn type_kinds(&self) -> &'static [&'static str] {
45        &[
46            "class_declaration",
47            "interface_declaration",
48            "trait_declaration",
49            "enum_declaration",
50        ]
51    }
52
53    fn import_kinds(&self) -> &'static [&'static str] {
54        &["namespace_use_declaration"]
55    }
56
57    fn public_symbol_kinds(&self) -> &'static [&'static str] {
58        &[
59            "class_declaration",
60            "interface_declaration",
61            "trait_declaration",
62            "function_definition",
63            "method_declaration",
64        ]
65    }
66
67    fn visibility_mechanism(&self) -> VisibilityMechanism {
68        VisibilityMechanism::AccessModifier
69    }
70
71    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
72        if self.get_visibility(node, content) != Visibility::Public {
73            return Vec::new();
74        }
75
76        let name = match self.node_name(node, content) {
77            Some(n) => n.to_string(),
78            None => return Vec::new(),
79        };
80
81        let kind = match node.kind() {
82            "class_declaration" => SymbolKind::Class,
83            "interface_declaration" => SymbolKind::Interface,
84            "trait_declaration" => SymbolKind::Class, // traits are like mixins
85            "enum_declaration" => SymbolKind::Enum,
86            "function_definition" => SymbolKind::Function,
87            "method_declaration" => SymbolKind::Method,
88            _ => return Vec::new(),
89        };
90
91        vec![Export {
92            name,
93            kind,
94            line: node.start_position().row + 1,
95        }]
96    }
97
98    fn scope_creating_kinds(&self) -> &'static [&'static str] {
99        &[
100            "for_statement",
101            "foreach_statement",
102            "while_statement",
103            "do_statement",
104            "try_statement",
105            "catch_clause",
106            "switch_statement",
107        ]
108    }
109
110    fn control_flow_kinds(&self) -> &'static [&'static str] {
111        &[
112            "if_statement",
113            "for_statement",
114            "foreach_statement",
115            "while_statement",
116            "do_statement",
117            "switch_statement",
118            "try_statement",
119            "return_statement",
120            "break_statement",
121            "continue_statement",
122            "throw_expression",
123        ]
124    }
125
126    fn complexity_nodes(&self) -> &'static [&'static str] {
127        &[
128            "if_statement",
129            "for_statement",
130            "foreach_statement",
131            "while_statement",
132            "do_statement",
133            "case_statement",
134            "catch_clause",
135            "conditional_expression",
136        ]
137    }
138
139    fn nesting_nodes(&self) -> &'static [&'static str] {
140        &[
141            "if_statement",
142            "for_statement",
143            "foreach_statement",
144            "while_statement",
145            "do_statement",
146            "switch_statement",
147            "try_statement",
148            "function_definition",
149            "method_declaration",
150            "class_declaration",
151            "arrow_function",
152        ]
153    }
154
155    fn signature_suffix(&self) -> &'static str {
156        " {}"
157    }
158
159    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
160        let name = self.node_name(node, content)?;
161
162        let params = node
163            .child_by_field_name("parameters")
164            .map(|p| content[p.byte_range()].to_string())
165            .unwrap_or_else(|| "()".to_string());
166
167        let return_type = node
168            .child_by_field_name("return_type")
169            .map(|t| format!(": {}", content[t.byte_range()].trim()));
170
171        let kind = if node.kind() == "method_declaration" {
172            SymbolKind::Method
173        } else {
174            SymbolKind::Function
175        };
176
177        let signature = format!(
178            "function {}{}{}",
179            name,
180            params,
181            return_type.unwrap_or_default()
182        );
183
184        Some(Symbol {
185            name: name.to_string(),
186            kind,
187            signature,
188            docstring: self.extract_docstring(node, content),
189            attributes: Vec::new(),
190            start_line: node.start_position().row + 1,
191            end_line: node.end_position().row + 1,
192            visibility: self.get_visibility(node, content),
193            children: Vec::new(),
194            is_interface_impl: false,
195            implements: Vec::new(),
196        })
197    }
198
199    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
200        let name = self.node_name(node, content)?;
201        let (kind, keyword) = match node.kind() {
202            "interface_declaration" => (SymbolKind::Interface, "interface"),
203            "trait_declaration" => (SymbolKind::Class, "trait"),
204            "enum_declaration" => (SymbolKind::Enum, "enum"),
205            "namespace_definition" => (SymbolKind::Module, "namespace"),
206            _ => (SymbolKind::Class, "class"),
207        };
208
209        Some(Symbol {
210            name: name.to_string(),
211            kind,
212            signature: format!("{} {}", keyword, name),
213            docstring: self.extract_docstring(node, content),
214            attributes: Vec::new(),
215            start_line: node.start_position().row + 1,
216            end_line: node.end_position().row + 1,
217            visibility: self.get_visibility(node, content),
218            children: Vec::new(),
219            is_interface_impl: false,
220            implements: Vec::new(),
221        })
222    }
223
224    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
225        self.extract_container(node, content)
226    }
227
228    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
229        // PHP uses /** */ for PHPDoc comments
230        let mut prev = node.prev_sibling();
231        while let Some(sibling) = prev {
232            let text = &content[sibling.byte_range()];
233            if sibling.kind() == "comment" {
234                if text.starts_with("/**") {
235                    let inner = text
236                        .strip_prefix("/**")
237                        .unwrap_or(text)
238                        .strip_suffix("*/")
239                        .unwrap_or(text);
240                    let lines: Vec<&str> = inner
241                        .lines()
242                        .map(|l| l.trim().strip_prefix("*").unwrap_or(l).trim())
243                        .filter(|l| !l.is_empty() && !l.starts_with('@'))
244                        .collect();
245                    if !lines.is_empty() {
246                        return Some(lines.join(" "));
247                    }
248                }
249                break;
250            } else if sibling.kind() != "text" {
251                break;
252            }
253            prev = sibling.prev_sibling();
254        }
255        None
256    }
257
258    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
259        Vec::new()
260    }
261
262    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
263        if node.kind() != "namespace_use_declaration" {
264            return Vec::new();
265        }
266
267        let line = node.start_position().row + 1;
268        let mut imports = Vec::new();
269
270        let mut cursor = node.walk();
271        for child in node.children(&mut cursor) {
272            if child.kind() == "namespace_use_clause" {
273                let text = content[child.byte_range()].to_string();
274                imports.push(Import {
275                    module: text,
276                    names: Vec::new(),
277                    alias: None,
278                    is_wildcard: false,
279                    is_relative: false,
280                    line,
281                });
282            }
283        }
284
285        imports
286    }
287
288    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
289        // PHP: use Namespace\Class;
290        format!("use {};", import.module)
291    }
292
293    fn is_public(&self, node: &Node, content: &str) -> bool {
294        self.get_visibility(node, content) == Visibility::Public
295    }
296
297    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
298        let name = symbol.name.as_str();
299        match symbol.kind {
300            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
301            crate::SymbolKind::Module => name == "tests" || name == "test",
302            _ => false,
303        }
304    }
305
306    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
307        None
308    }
309
310    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
311        node.child_by_field_name("body")
312    }
313
314    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
315        false
316    }
317
318    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
319        node.child_by_field_name("name")
320            .map(|n| &content[n.byte_range()])
321    }
322
323    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
324        let ext = path.extension()?.to_str()?;
325        if ext != "php" && ext != "phtml" {
326            return None;
327        }
328        let stem = path.file_stem()?.to_str()?;
329        Some(stem.to_string())
330    }
331
332    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
333        let path = module.replace('\\', "/");
334        vec![
335            format!("{}.php", path),
336            format!("src/{}.php", path),
337            format!("app/{}.php", path),
338        ]
339    }
340
341    fn is_stdlib_import(&self, _import_name: &str, _project_root: &Path) -> bool {
342        // PHP doesn't have namespaced stdlib in the same way
343        false
344    }
345
346    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
347        None
348    }
349
350    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
351        let mut cursor = node.walk();
352        for child in node.children(&mut cursor) {
353            if child.kind() == "visibility_modifier" {
354                let mod_text = &content[child.byte_range()];
355                if mod_text == "private" {
356                    return Visibility::Private;
357                }
358                if mod_text == "protected" {
359                    return Visibility::Protected;
360                }
361                if mod_text == "public" {
362                    return Visibility::Public;
363                }
364            }
365        }
366        // PHP default visibility for methods/properties in classes is public
367        Visibility::Public
368    }
369
370    fn lang_key(&self) -> &'static str {
371        "php"
372    }
373
374    fn resolve_local_import(
375        &self,
376        import: &str,
377        _current_file: &Path,
378        project_root: &Path,
379    ) -> Option<PathBuf> {
380        // PSR-4 style: namespace maps to directory structure
381        let path_part = import.replace('\\', "/");
382
383        let paths = [
384            format!("{}.php", path_part),
385            format!("src/{}.php", path_part),
386            format!("app/{}.php", path_part),
387            format!("lib/{}.php", path_part),
388        ];
389
390        for path in &paths {
391            let full_path = project_root.join(path);
392            if full_path.is_file() {
393                return Some(full_path);
394            }
395        }
396
397        None
398    }
399
400    fn resolve_external_import(
401        &self,
402        _import_name: &str,
403        _project_root: &Path,
404    ) -> Option<ResolvedPackage> {
405        // Composer package resolution would go here
406        None
407    }
408
409    fn get_version(&self, project_root: &Path) -> Option<String> {
410        // Check composer.json for PHP version requirement
411        let composer = project_root.join("composer.json");
412        if composer.is_file() {
413            if let Ok(content) = std::fs::read_to_string(&composer) {
414                // Quick parse for "php": "^8.1" or similar
415                if let Some(idx) = content.find("\"php\"") {
416                    let rest = &content[idx..];
417                    if let Some(start) = rest.find(':') {
418                        let after_colon = rest[start + 1..].trim();
419                        if let Some(ver_start) = after_colon.find('"') {
420                            let ver_rest = &after_colon[ver_start + 1..];
421                            if let Some(ver_end) = ver_rest.find('"') {
422                                return Some(ver_rest[..ver_end].to_string());
423                            }
424                        }
425                    }
426                }
427            }
428        }
429        None
430    }
431
432    fn find_package_cache(&self, project_root: &Path) -> Option<PathBuf> {
433        // Composer vendor directory
434        let vendor = project_root.join("vendor");
435        if vendor.is_dir() {
436            return Some(vendor);
437        }
438        None
439    }
440
441    fn indexable_extensions(&self) -> &'static [&'static str] {
442        &["php"]
443    }
444
445    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
446        Vec::new()
447    }
448
449    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
450        use crate::traits::{has_extension, skip_dotfiles};
451        if skip_dotfiles(name) {
452            return true;
453        }
454        if is_dir && (name == "vendor" || name == "cache" || name == "tests") {
455            return true;
456        }
457        !is_dir && !has_extension(name, self.indexable_extensions())
458    }
459
460    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
461        Vec::new()
462    }
463
464    fn package_module_name(&self, entry_name: &str) -> String {
465        entry_name
466            .strip_suffix(".php")
467            .unwrap_or(entry_name)
468            .to_string()
469    }
470
471    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
472        if path.is_file() {
473            return Some(path.to_path_buf());
474        }
475        None
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::validate_unused_kinds_audit;
483
484    #[test]
485    fn unused_node_kinds_audit() {
486        #[rustfmt::skip]
487        let documented_unused: &[&str] = &[
488            // STRUCTURAL
489            "abstract_modifier", "anonymous_class", "anonymous_function",
490            "anonymous_function_use_clause", "base_clause", "cast_expression", "cast_type",
491            "class_constant_access_expression", "class_interface_clause", "colon_block",
492            "compound_statement", "const_declaration", "declaration_list", "enum_case",
493            "enum_declaration_list", "final_modifier", "formal_parameters", "heredoc_body",
494            "named_type", "namespace_use_clause", "nowdoc_body",
495            "optional_type", "primitive_type", "property_declaration", "qualified_name",
496            "readonly_modifier", "reference_modifier", "static_modifier", "static_variable_declaration",
497            "use_as_clause", "use_declaration", "use_instead_of_clause", "var_modifier",
498            "visibility_modifier",
499            // CLAUSE
500            "declare_statement", "default_statement", "else_clause", "else_if_clause",
501            "finally_clause", "match_block", "match_condition_list", "match_conditional_expression",
502            "match_default_expression", "switch_block",
503            // EXPRESSION
504            "array_creation_expression", "assignment_expression", "augmented_assignment_expression",
505            "binary_expression", "bottom_type", "clone_expression", "disjunctive_normal_form_type",
506            "error_suppression_expression", "function_call_expression", "function_static_declaration",
507            "include_expression", "include_once_expression", "intersection_type",
508            "match_expression", "member_access_expression", "member_call_expression",
509            "nullsafe_member_access_expression", "nullsafe_member_call_expression",
510            "object_creation_expression", "parenthesized_expression", "reference_assignment_expression",
511            "require_expression", "require_once_expression", "scoped_call_expression",
512            "scoped_property_access_expression", "sequence_expression", "shell_command_expression",
513            "subscript_expression", "type_list", "unary_op_expression", "union_type",
514            "update_expression", "yield_expression",
515            // STATEMENT
516            "echo_statement", "empty_statement", "exit_statement", "expression_statement",
517            "global_declaration", "goto_statement", "named_label_statement", "unset_statement",
518        ];
519
520        validate_unused_kinds_audit(&Php, documented_unused)
521            .expect("PHP unused node kinds audit failed");
522    }
523}