Skip to main content

codesynapse_core/ts_extract/
php.rs

1use super::{add_node_if_missing, make_file_node};
2use crate::error::{CodeSynapseError, Result};
3use crate::extract::{make_id, ImportNode, LanguageExtractor};
4use crate::types::{Edge, ExtractionFragment, Node};
5use std::collections::HashMap;
6use std::path::Path;
7use tree_sitter::{Node as TsNode, Parser};
8
9pub struct TsPhpExtractor;
10
11fn ph_text(source: &[u8], node: &TsNode<'_>) -> String {
12    std::str::from_utf8(&source[node.start_byte()..node.end_byte()])
13        .unwrap_or("")
14        .trim()
15        .to_string()
16}
17
18fn ph_node(id: String, label: String, file_type: &str, path: &Path) -> Node {
19    Node {
20        id,
21        label,
22        file_type: file_type.to_string(),
23        source_file: path.to_string_lossy().to_string(),
24        source_location: None,
25        community: None,
26        rationale: None,
27        docstring: None,
28        metadata: HashMap::new(),
29    }
30}
31
32fn simple_name(s: &str) -> &str {
33    s.split('\\').next_back().unwrap_or(s)
34}
35
36impl TsPhpExtractor {
37    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
38        let (file_id, _, file_node) = make_file_node(path);
39        let mut fragment = ExtractionFragment {
40            nodes: vec![file_node],
41            edges: vec![],
42        };
43
44        let lang = tree_sitter_php::LANGUAGE_PHP_ONLY.into();
45        let mut parser = Parser::new();
46        parser
47            .set_language(&lang)
48            .map_err(|e| CodeSynapseError::Parse(format!("php lang: {}", e)))?;
49
50        let tree = parser
51            .parse(source, None)
52            .ok_or_else(|| CodeSynapseError::Parse("php parse failed".to_string()))?;
53
54        let stem = file_id.clone();
55
56        Self::walk(
57            tree.root_node(),
58            source,
59            path,
60            &file_id,
61            &stem,
62            None,
63            &mut fragment,
64        );
65
66        Ok(fragment)
67    }
68
69    fn walk(
70        node: TsNode<'_>,
71        source: &[u8],
72        path: &Path,
73        file_id: &str,
74        stem: &str,
75        class_id: Option<&str>,
76        fragment: &mut ExtractionFragment,
77    ) {
78        match node.kind() {
79            "namespace_use_clause" | "namespace_use_declaration" => {
80                // use App\Http\Something
81                let text = ph_text(source, &node);
82                let leaf = text
83                    .split('\\')
84                    .next_back()
85                    .unwrap_or(&text)
86                    .trim()
87                    .to_string();
88                if !leaf.is_empty() && leaf != "use" {
89                    let id = make_id(&[&leaf]);
90                    add_node_if_missing(fragment, ph_node(id.clone(), leaf, "module", path));
91                    fragment.edges.push(Edge {
92                        source: file_id.to_string(),
93                        target: id,
94                        relation: "imports".to_string(),
95                        confidence: "EXTRACTED".to_string(),
96                        source_file: Some(path.to_string_lossy().to_string()),
97                        weight: 1.0,
98                        context: Some("import".to_string()),
99                    });
100                }
101            }
102            "class_declaration" => {
103                let name_node = node.child_by_field_name("name");
104                let class_name = match name_node {
105                    Some(n) => {
106                        let t = ph_text(source, &n);
107                        simple_name(&t).to_string()
108                    }
109                    None => return,
110                };
111                let new_class_id = make_id(&[stem, &class_name]);
112                add_node_if_missing(
113                    fragment,
114                    ph_node(new_class_id.clone(), class_name.clone(), "class", path),
115                );
116                fragment.edges.push(Edge {
117                    source: file_id.to_string(),
118                    target: new_class_id.clone(),
119                    relation: "contains".to_string(),
120                    confidence: "EXTRACTED".to_string(),
121                    source_file: Some(path.to_string_lossy().to_string()),
122                    weight: 1.0,
123                    context: None,
124                });
125
126                // base_clause and class_interface_clause are unnamed children (not fields)
127                for i in 0..node.child_count() {
128                    if let Some(child) = node.child(i) {
129                        match child.kind() {
130                            "base_clause" => {
131                                Self::collect_names_as(
132                                    &child,
133                                    source,
134                                    path,
135                                    &new_class_id,
136                                    fragment,
137                                    "inherits",
138                                    stem,
139                                );
140                            }
141                            "class_interface_clause" => {
142                                Self::collect_names_as(
143                                    &child,
144                                    source,
145                                    path,
146                                    &new_class_id,
147                                    fragment,
148                                    "implements",
149                                    stem,
150                                );
151                            }
152                            _ => {}
153                        }
154                    }
155                }
156
157                // Walk body
158                if let Some(body) = node.child_by_field_name("body") {
159                    for i in 0..body.child_count() {
160                        if let Some(child) = body.child(i) {
161                            Self::walk(
162                                child,
163                                source,
164                                path,
165                                file_id,
166                                stem,
167                                Some(&new_class_id),
168                                fragment,
169                            );
170                        }
171                    }
172                }
173            }
174            "use_declaration" => {
175                // trait use inside class: use TraitName;
176                if let Some(owner) = class_id {
177                    for i in 0..node.child_count() {
178                        if let Some(child) = node.child(i) {
179                            if child.kind() == "name" || child.kind() == "qualified_name" {
180                                let trait_name = simple_name(&ph_text(source, &child)).to_string();
181                                let trait_id = make_id(&[stem, &trait_name]);
182                                add_node_if_missing(
183                                    fragment,
184                                    ph_node(trait_id.clone(), trait_name, "code", path),
185                                );
186                                fragment.edges.push(Edge {
187                                    source: owner.to_string(),
188                                    target: trait_id,
189                                    relation: "mixes_in".to_string(),
190                                    confidence: "EXTRACTED".to_string(),
191                                    source_file: Some(path.to_string_lossy().to_string()),
192                                    weight: 1.0,
193                                    context: None,
194                                });
195                            }
196                        }
197                    }
198                }
199            }
200            "method_declaration" => {
201                let owner = class_id.unwrap_or(file_id);
202                Self::extract_method(node, source, path, file_id, stem, owner, fragment);
203            }
204            "function_definition" => {
205                if class_id.is_none() {
206                    // Top-level function
207                    if let Some(name_node) = node.child_by_field_name("name") {
208                        let name = ph_text(source, &name_node);
209                        let id = make_id(&[stem, &name, "()"]);
210                        add_node_if_missing(
211                            fragment,
212                            ph_node(id.clone(), format!("{}()", name), "function", path),
213                        );
214                        fragment.edges.push(Edge {
215                            source: file_id.to_string(),
216                            target: id,
217                            relation: "contains".to_string(),
218                            confidence: "EXTRACTED".to_string(),
219                            source_file: Some(path.to_string_lossy().to_string()),
220                            weight: 1.0,
221                            context: None,
222                        });
223                    }
224                }
225            }
226            "property_declaration" => {
227                if let Some(owner) = class_id {
228                    Self::extract_property(node, source, path, owner, fragment, stem);
229                }
230            }
231            _ => {
232                for i in 0..node.child_count() {
233                    if let Some(child) = node.child(i) {
234                        Self::walk(child, source, path, file_id, stem, class_id, fragment);
235                    }
236                }
237            }
238        }
239    }
240
241    fn collect_names_as(
242        node: &TsNode<'_>,
243        source: &[u8],
244        path: &Path,
245        source_id: &str,
246        fragment: &mut ExtractionFragment,
247        relation: &str,
248        stem: &str,
249    ) {
250        for i in 0..node.child_count() {
251            if let Some(child) = node.child(i) {
252                if child.kind() == "name" || child.kind() == "qualified_name" {
253                    let name = simple_name(&ph_text(source, &child)).to_string();
254                    let id = make_id(&[stem, &name]);
255                    add_node_if_missing(fragment, ph_node(id.clone(), name, "code", path));
256                    fragment.edges.push(Edge {
257                        source: source_id.to_string(),
258                        target: id,
259                        relation: relation.to_string(),
260                        confidence: "EXTRACTED".to_string(),
261                        source_file: Some(path.to_string_lossy().to_string()),
262                        weight: 1.0,
263                        context: None,
264                    });
265                } else {
266                    Self::collect_names_as(
267                        &child, source, path, source_id, fragment, relation, stem,
268                    );
269                }
270            }
271        }
272    }
273
274    fn extract_method(
275        node: TsNode<'_>,
276        source: &[u8],
277        path: &Path,
278        file_id: &str,
279        stem: &str,
280        owner_id: &str,
281        fragment: &mut ExtractionFragment,
282    ) {
283        let name_node = match node.child_by_field_name("name") {
284            Some(n) => n,
285            None => return,
286        };
287        let method_name = ph_text(source, &name_node);
288        let method_id = make_id(&[stem, &method_name, "()"]);
289        add_node_if_missing(
290            fragment,
291            ph_node(
292                method_id.clone(),
293                format!("{}()", method_name),
294                "method",
295                path,
296            ),
297        );
298        fragment.edges.push(Edge {
299            source: owner_id.to_string(),
300            target: method_id.clone(),
301            relation: "method".to_string(),
302            confidence: "EXTRACTED".to_string(),
303            source_file: Some(path.to_string_lossy().to_string()),
304            weight: 1.0,
305            context: None,
306        });
307        fragment.edges.push(Edge {
308            source: file_id.to_string(),
309            target: method_id.clone(),
310            relation: "contains".to_string(),
311            confidence: "EXTRACTED".to_string(),
312            source_file: Some(path.to_string_lossy().to_string()),
313            weight: 1.0,
314            context: None,
315        });
316
317        // Return type
318        if let Some(ret) = node.child_by_field_name("return_type") {
319            Self::emit_type_ref_ph(
320                &ret,
321                source,
322                path,
323                &method_id,
324                fragment,
325                "return_type",
326                stem,
327            );
328        }
329
330        // Parameters
331        if let Some(params) = node.child_by_field_name("parameters") {
332            for i in 0..params.child_count() {
333                if let Some(param) = params.child(i) {
334                    if param.kind() == "simple_parameter"
335                        || param.kind() == "property_promotion_parameter"
336                    {
337                        if let Some(ptype) = param.child_by_field_name("type") {
338                            Self::emit_type_ref_ph(
339                                &ptype,
340                                source,
341                                path,
342                                &method_id,
343                                fragment,
344                                "parameter_type",
345                                stem,
346                            );
347                        }
348                    }
349                }
350            }
351        }
352
353        // Body for calls
354        if let Some(body) = node.child_by_field_name("body") {
355            Self::walk_calls_ph(body, source, path, &method_id, fragment, stem);
356        }
357    }
358
359    fn extract_property(
360        node: TsNode<'_>,
361        source: &[u8],
362        path: &Path,
363        class_id: &str,
364        fragment: &mut ExtractionFragment,
365        stem: &str,
366    ) {
367        if let Some(type_node) = node.child_by_field_name("type") {
368            Self::emit_type_ref_ph(&type_node, source, path, class_id, fragment, "field", stem);
369        }
370    }
371
372    fn emit_type_ref_ph(
373        type_node: &TsNode<'_>,
374        source: &[u8],
375        path: &Path,
376        owner_id: &str,
377        fragment: &mut ExtractionFragment,
378        context: &str,
379        stem: &str,
380    ) {
381        match type_node.kind() {
382            "named_type" | "name" | "qualified_name" => {
383                let name = simple_name(&ph_text(source, type_node)).to_string();
384                if !name.is_empty() {
385                    let id = make_id(&[stem, &name]);
386                    add_node_if_missing(fragment, ph_node(id.clone(), name, "code", path));
387                    fragment.edges.push(Edge {
388                        source: owner_id.to_string(),
389                        target: id,
390                        relation: "references".to_string(),
391                        confidence: "EXTRACTED".to_string(),
392                        source_file: Some(path.to_string_lossy().to_string()),
393                        weight: 1.0,
394                        context: Some(context.to_string()),
395                    });
396                }
397            }
398            _ => {
399                for i in 0..type_node.child_count() {
400                    if let Some(child) = type_node.child(i) {
401                        Self::emit_type_ref_ph(
402                            &child, source, path, owner_id, fragment, context, stem,
403                        );
404                    }
405                }
406            }
407        }
408    }
409
410    fn walk_calls_ph(
411        node: TsNode<'_>,
412        source: &[u8],
413        path: &Path,
414        caller_id: &str,
415        fragment: &mut ExtractionFragment,
416        stem: &str,
417    ) {
418        if node.kind() == "function_call_expression" || node.kind() == "member_call_expression" {
419            let func_name = node
420                .child_by_field_name("function")
421                .or_else(|| node.child_by_field_name("name"))
422                .map(|n| ph_text(source, &n));
423            if let Some(name) = func_name {
424                let callee_id = make_id(&[stem, &name, "()"]);
425                if fragment.nodes.iter().any(|n| n.id == callee_id) {
426                    let already = fragment.edges.iter().any(|e| {
427                        e.relation == "calls" && e.source == caller_id && e.target == callee_id
428                    });
429                    if !already && caller_id != callee_id.as_str() {
430                        fragment.edges.push(Edge {
431                            source: caller_id.to_string(),
432                            target: callee_id,
433                            relation: "calls".to_string(),
434                            confidence: "EXTRACTED".to_string(),
435                            source_file: Some(path.to_string_lossy().to_string()),
436                            weight: 1.0,
437                            context: Some("call".to_string()),
438                        });
439                    }
440                }
441            }
442        }
443        for i in 0..node.child_count() {
444            if let Some(child) = node.child(i) {
445                Self::walk_calls_ph(child, source, path, caller_id, fragment, stem);
446            }
447        }
448    }
449}
450
451impl LanguageExtractor for TsPhpExtractor {
452    fn file_extensions(&self) -> Vec<&'static str> {
453        vec!["php"]
454    }
455    fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
456        Self::extract(source, path)
457    }
458    fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
459        vec![]
460    }
461    fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
462}