1use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8pub 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 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 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 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
211pub 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 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 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 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
312fn 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 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 "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 "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 "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 "echo_statement", "empty_statement", "exit_statement", "expression_statement",
382 "global_declaration", "goto_statement", "named_label_statement", "unset_statement",
383 "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}