Skip to main content

bonsai_lang_php/
lib.rs

1//! PHP language adapter.
2use bonsai_common::{FileId, Span};
3use bonsai_lang_api::{
4    collect_modifier_visibility, collect_param_type_aliases, decl_index_with_handler, extract_imports_via,
5    kit::{
6        call_arg_from_node_with_handler, collect_kinds, first_named_child_of_kind, language_from_pack,
7        named_child_call_args_with_handler, node_text, parse_with, span_of,
8    },
9    AdapterContext, AdapterError, AssignValueKind, AssignmentValueIndex, CallArg, CallKind,
10    CallTargetExtraction, DeclIndex, DeclKind, FieldWrite, FlowEvent, FragmentParseContext, GrammarHandler,
11    ImportIndex, ImportScope, ImportSpec, LanguageAdapter, LanguageCapabilities, LanguageId,
12    ModifierVocabulary, TypeAliasVocabulary, Visibility, EMPTY_HANDLER,
13};
14use std::collections::BTreeSet;
15fn php_call_target<'tree>(node: Node<'tree>, src: &[u8]) -> Option<CallTargetExtraction<'tree>> {
16    let (target, full_text) = match node.kind() {
17        "function_call_expression" => {
18            let target = node.child_by_field_name("function")?;
19            (target, node_text(&target, src).trim().to_string())
20        }
21        "member_call_expression" | "nullsafe_member_call_expression" => {
22            let receiver = node.child_by_field_name("object")?;
23            let target = node.child_by_field_name("name")?;
24            (
25                target,
26                format!(
27                    "{}.{}",
28                    node_text(&receiver, src).trim(),
29                    node_text(&target, src).trim()
30                ),
31            )
32        }
33        "scoped_call_expression" => {
34            let receiver = node.child_by_field_name("scope")?;
35            let target = node.child_by_field_name("name")?;
36            (
37                target,
38                format!(
39                    "{}::{}",
40                    node_text(&receiver, src).trim(),
41                    node_text(&target, src).trim()
42                ),
43            )
44        }
45        "object_creation_expression" => {
46            // The PHP grammar exposes the constructed qualified name as the
47            // first named child rather than a `type` field.
48            let target = node.child_by_field_name("type").or_else(|| node.named_child(0))?;
49            (target, node_text(&target, src).trim().to_string())
50        }
51        _ => return None,
52    };
53    (!full_text.is_empty()).then_some(CallTargetExtraction {
54        node: target,
55        full_text,
56    })
57}
58
59const PHP_TYPE_ALIASES: TypeAliasVocabulary = TypeAliasVocabulary {
60    fn_kinds: &["function_definition", "method_declaration"],
61    param_kinds: &["simple_parameter", "property_promotion_parameter"],
62    name_field: "name",
63    type_field: "type",
64};
65
66const PHP_VOCAB: ModifierVocabulary = ModifierVocabulary {
67    decl_kinds: &[
68        "method_declaration",
69        "property_declaration",
70        "class_declaration",
71        "interface_declaration",
72        "trait_declaration",
73        "enum_declaration",
74    ],
75    modifier_container_kinds: &["visibility_modifier", "modifier"],
76    keyword_to_visibility: &[
77        ("private", Visibility::Private),
78        ("protected", Visibility::Protected),
79        ("public", Visibility::Public),
80    ],
81    // PHP's default visibility for class members is `public`.
82    default_visibility: Visibility::Public,
83};
84use tree_sitter::{Language, Node, Tree};
85
86pub const LANG_ID: LanguageId = LanguageId::new("php");
87const PACK_NAME: &str = "php";
88
89fn extract_php_callable_reference(node: Node<'_>, src: &[u8]) -> Option<String> {
90    if !matches!(
91        node.kind(),
92        "function_call_expression"
93            | "member_call_expression"
94            | "nullsafe_member_call_expression"
95            | "scoped_call_expression"
96    ) {
97        return None;
98    }
99    let callee = node
100        .child_by_field_name("function")
101        .or_else(|| node.child_by_field_name("name"))
102        .or_else(|| node.child_by_field_name("target"))?;
103    let arguments = node
104        .child_by_field_name("arguments")
105        .or_else(|| node.child_by_field_name("argument_list"))?;
106    if arguments.named_child_count() != 1 || arguments.named_child(0)?.kind() != "variadic_placeholder" {
107        return None;
108    }
109    let name = node_text(&callee, src).trim();
110    (!name.is_empty()).then(|| name.to_string())
111}
112
113fn php_subscript_parts(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
114    if node.kind() != "subscript_expression" {
115        return None;
116    }
117    // tree-sitter-php does not assign field names to either operand of a
118    // subscript. Its grammar contract is the first named child as the base
119    // and the second named child as the index. Keep the field lookups for
120    // forward compatibility, then use that exact ordered shape.
121    let mut cursor = node.walk();
122    let mut children = node.named_children(&mut cursor);
123    let first = children.next()?;
124    let second = children.next()?;
125    let object = node.child_by_field_name("object").unwrap_or(first);
126    let key = node.child_by_field_name("index").unwrap_or(second);
127    Some((object, key))
128}
129
130fn php_static_subscript_key(node: Node<'_>, src: &[u8]) -> Option<String> {
131    if node.kind() != "string" {
132        return None;
133    }
134    let text = node_text(&node, src).trim();
135    let quote = text.as_bytes().first().copied()?;
136    if !matches!(quote, b'\'' | b'"') || text.as_bytes().last().copied() != Some(quote) {
137        return None;
138    }
139    let value = text.get(1..text.len().checked_sub(1)?)?;
140    (!value.contains('\\')).then(|| value.to_string())
141}
142
143fn php_reference_name(node: Node<'_>, src: &[u8]) -> Option<String> {
144    let raw = node_text(&node, src).trim();
145    // `$` is part of a PHP variable's language identity and distinguishes a
146    // runtime value from a class/function name. Preserve it in every place
147    // fact; generic name comparison already understands adapter-owned sigils.
148    (!raw.is_empty()).then(|| raw.to_string())
149}
150
151fn php_binding_name(node: Node<'_>, src: &[u8]) -> Option<String> {
152    let raw = node_text(&node, src).trim();
153    let binding = raw.strip_prefix('$').unwrap_or(raw);
154    (!binding.is_empty()).then(|| binding.to_string())
155}
156
157/// PHP's append assignment (`$items[] = $value`) is represented by a
158/// `subscript_expression` with no index child.  The append mutates the whole
159/// aggregate value, so its compiler place is the parsed base expression.  A
160/// normal keyed subscript continues through the shared field-sensitive place
161/// lowering.
162fn php_assignment_place(node: Node<'_>, src: &[u8]) -> Option<String> {
163    if node.kind() != "subscript_expression" || node.named_child_count() != 1 {
164        return None;
165    }
166    let base = node.named_child(0)?;
167    if base.kind() == "variable_name" {
168        return php_reference_name(base, src);
169    }
170    None
171}
172
173fn php_aggregate_pairs(node: Node<'_>) -> Vec<(Node<'_>, Node<'_>)> {
174    if node.kind() != "list_literal" {
175        return Vec::new();
176    }
177    let mut pairs = Vec::new();
178    let mut pending_key = None;
179    let mut saw_pair_operator = false;
180    let mut cursor = node.walk();
181    if cursor.goto_first_child() {
182        loop {
183            let child = cursor.node();
184            if child.is_named() {
185                if saw_pair_operator {
186                    if let Some(key) = pending_key.take() {
187                        pairs.push((key, child));
188                    }
189                    saw_pair_operator = false;
190                } else {
191                    pending_key = Some(child);
192                }
193            } else if child.kind() == "=>" {
194                saw_pair_operator = true;
195            }
196            if !cursor.goto_next_sibling() {
197                break;
198            }
199        }
200    }
201    pairs
202}
203
204fn php_foreach_binding(node: Node<'_>) -> Option<(Node<'_>, Node<'_>)> {
205    if node.kind() != "foreach_statement" {
206        return None;
207    }
208    let body_id = node.child_by_field_name("body").map(|body| body.id());
209    let mut cursor = node.walk();
210    let mut header = node
211        .named_children(&mut cursor)
212        .filter(|child| Some(child.id()) != body_id);
213    let iterable = header.next()?;
214    let binding = header.next()?;
215    Some((binding, iterable))
216}
217const HANDLER: GrammarHandler = GrammarHandler {
218    expression_value_kind_extractor: None,
219    literal_value_kinds: &["null", "boolean", "integer", "float"],
220    string_literal_kinds: &["string", "encapsed_string", "heredoc", "nowdoc_string"],
221    comment_kinds: &["comment"],
222    doc_comment_prefixes: &["/**"],
223    decorator_kinds: &["attribute"],
224    parameter_container_kinds: &["formal_parameters"],
225    parameter_kinds: &[
226        "simple_parameter",
227        "variadic_parameter",
228        "property_promotion_parameter",
229    ],
230    parameter_modifier_kinds: &["attribute_list"],
231    parameter_annotation_kinds: &["attribute"],
232    variadic_parameter_kinds: &["variadic_parameter"],
233    binding_identifier_kinds: &["variable_name", "name"],
234    non_binding_pattern_field_names: &["type", "key"],
235    binding_name_extractor: Some(php_binding_name),
236    identifier_kinds: &["variable_name", "name"],
237    aggregate_pattern_kinds: &["list_literal", "array_creation_expression"],
238    named_aggregate_kinds: &["array_creation_expression"],
239    positional_aggregate_kinds: &["array", "list", "list_literal", "array_creation_expression"],
240    two_child_aggregate_pair_kinds: &["array_element_initializer"],
241    aggregate_pair_extractor: Some(php_aggregate_pairs),
242    aggregate_value_field_names: &["value"],
243    static_field_name_kinds: &["name"],
244    lambda_value_container_kinds: &["array_creation_expression", "array_element_initializer"],
245    transparent_call_wrapper_kinds: &[
246        "member_access_expression",
247        "scoped_call_expression",
248        "parenthesized_expression",
249    ],
250    assignment_target_wrapper_kinds: &["variable_declaration", "property_element"],
251    assignment_place_extractor: Some(php_assignment_place),
252    binding_declaration_keyword_spellings: &["static"],
253    fn_kinds: &["function_definition", "method_declaration"],
254    call_kinds: &[
255        "function_call_expression",
256        "member_call_expression",
257        "nullsafe_member_call_expression",
258        "scoped_call_expression",
259        "object_creation_expression",
260    ],
261    constructor_call_kinds: &["object_creation_expression"],
262    call_callee_field_names: &["function"],
263    call_receiver_field_names: &["object", "scope"],
264    call_member_field_names: &["name"],
265    constructor_type_field_names: &["type"],
266    call_target_extractor: Some(php_call_target),
267    call_argument_field_names: &["arguments"],
268    call_argument_container_kinds: &["arguments"],
269    // tree-sitter-php wraps every positional argument in `argument`; named
270    // arguments use the dedicated `named_argument` shape. Unwrapping both is
271    // required for an addressable `$value` to remain an exact CallArg place.
272    argument_wrapper_kinds: &["argument", "named_argument"],
273    argument_name_field_names: &["name"],
274    argument_value_field_names: &["value"],
275    lambda_body_field_names: &["body"],
276    pseudo_call_extractor: Some(extract_php_pseudo_call),
277    syntax_event_extractor: None,
278    argument_passing_mode_extractor: None,
279    call_ref_kinds: &[
280        "function_call_expression",
281        "member_call_expression",
282        "nullsafe_member_call_expression",
283        "scoped_call_expression",
284        "object_creation_expression",
285    ],
286    member_expression_kinds: &[
287        "member_access_expression",
288        "member_expression",
289        "nullsafe_member_access_expression",
290    ],
291    subscript_expression_kinds: &["subscript_expression"],
292    member_base_field_names: &["object"],
293    member_name_field_names: &["name"],
294    subscript_base_field_names: &["object"],
295    subscript_index_field_names: &["index"],
296    static_subscript_key_extractor: Some(php_static_subscript_key),
297    computed_subscript_extractor: Some(php_subscript_parts),
298    sigil_variable_kinds: &["variable_name"],
299    reference_name_extractor: Some(php_reference_name),
300    callable_reference_extractor: Some(extract_php_callable_reference),
301    constructor_names: &["__construct"],
302    runtime_type_guard_operators: &["instanceof"],
303    runtime_type_wrapper_kinds: &["parenthesized_expression"],
304    class_kinds: &[
305        "class_declaration",
306        "interface_declaration",
307        "trait_declaration",
308        "enum_declaration",
309    ],
310    class_decl_kinds: &[
311        ("class_declaration", DeclKind::Class),
312        ("interface_declaration", DeclKind::Interface),
313        ("trait_declaration", DeclKind::Trait),
314        ("enum_declaration", DeclKind::Enum),
315    ],
316    method_kinds: &["method_declaration"],
317    method_context_kinds: &[
318        "class_declaration",
319        "interface_declaration",
320        "trait_declaration",
321        "enum_declaration",
322    ],
323    if_kinds: &[
324        "if_statement",
325        "conditional_expression",
326        "switch_statement",
327        "match_expression",
328    ],
329    branch_then_field_names: &["consequence", "body"],
330    branch_else_field_names: &["alternative"],
331    branch_condition_field_names: &["condition", "value"],
332    loop_body_field_names: &["body"],
333    loop_body_kinds: &["compound_statement", "expression_statement"],
334    branch_arm_kinds: &["compound_statement", "else_clause", "else_if_clause"],
335    additional_alternative_kinds: &["else_clause", "else_if_clause"],
336    for_kinds: &["for_statement"],
337    foreach_kinds: &["foreach_statement"],
338    foreach_binding_extractor: Some(php_foreach_binding),
339    while_kinds: &["while_statement"],
340    do_kinds: &["do_statement"],
341    assignment_kinds: &[
342        "assignment_expression",
343        "augmented_assignment_expression",
344        "reference_assignment_expression",
345        "property_declaration",
346    ],
347    compound_assignment_kinds: &["augmented_assignment_expression"],
348    compound_assignment_operators: &[
349        "+=", "-=", "*=", "/=", "%=", "**=", ".=", "<<=", ">>=", "&=", "^=", "|=", "??=",
350    ],
351    type_only_declaration_kinds: &["property_declaration"],
352    return_kinds: &["return_statement"],
353    throw_kinds: &["throw_expression"],
354    lambda_kinds: &["anonymous_function", "arrow_function"],
355    try_kinds: &["try_statement"],
356    catch_kinds: &["catch_clause"],
357    finally_kinds: &["finally_clause"],
358    break_kinds: &["break_statement"],
359    continue_kinds: &["continue_statement"],
360    control_label_field_names: &[],
361    yield_kinds: &["yield_expression"],
362    yield_value_field_names: &["value"],
363    try_body_field_names: &["body"],
364    implicit_receiver_names: &["$this", "this"],
365    ..EMPTY_HANDLER
366};
367
368fn extract_php_pseudo_call(
369    node: Node<'_>,
370    file: FileId,
371    src: &[u8],
372    handler: &GrammarHandler,
373) -> Option<FlowEvent> {
374    let name = match node.kind() {
375        "echo_statement" => "echo",
376        "unset_statement" => "unset",
377        _ => return None,
378    };
379    Some(FlowEvent::Call {
380        span: span_of(file, &node),
381        receiver: None,
382        receiver_types: Vec::new(),
383        name: name.to_string(),
384        call_kind: CallKind::Function,
385        args: named_child_call_args_with_handler(&node, file, src, handler),
386    })
387}
388
389/// Tree-sitter adapter for PHP.
390#[derive(Debug, Default, Copy, Clone)]
391pub struct PhpAdapter;
392
393impl PhpAdapter {
394    /// Construct a fresh adapter; the type carries no state.
395    #[must_use]
396    pub fn new() -> Self {
397        Self
398    }
399}
400
401impl LanguageAdapter for PhpAdapter {
402    fn language_id(&self) -> LanguageId {
403        LANG_ID
404    }
405    fn display_name(&self) -> &'static str {
406        "PHP"
407    }
408    fn file_extensions(&self) -> &'static [&'static str] {
409        &["php"]
410    }
411    fn tree_sitter_language(&self) -> Result<Language, AdapterError> {
412        language_from_pack(PACK_NAME)
413    }
414    fn fragment_parse_context(&self) -> FragmentParseContext {
415        // The PHP grammar starts in HTML host mode and enters PHP only after
416        // this grammar token. Central renderers must not know that syntax.
417        FragmentParseContext {
418            prefix: "<?php\n",
419            suffix: "",
420        }
421    }
422    fn capabilities(&self) -> LanguageCapabilities {
423        LanguageCapabilities {
424            module_default_export_names: &[],
425            universal_type_names: &["mixed", "object"],
426            receiver_types: bonsai_lang_api::CapabilityLevel::Partial,
427            module_path_syntax: bonsai_lang_api::ModulePathSyntax {
428                rooted_prefixes: &["\\"],
429                repeatable_rooted_prefixes: &[],
430            },
431            constructor_method_names: &["__construct"],
432            super_receiver_tokens: &["parent"],
433            // PHP distinguishes the current object (`$this`) from current-
434            // class dispatch (`self` / late-bound `static`). None denotes a
435            // parent type; that role belongs exclusively to `parent` above.
436            implicit_receiver_tokens: &["$this", "self", "static"],
437            receiver_type_syntax: bonsai_lang_api::ReceiverTypeSyntax {
438                wrapper_calls: &[],
439                class_object_suffixes: &["::class"],
440            },
441            quoted_callable_literals: true,
442            ..LanguageCapabilities::partial_baseline()
443        }
444    }
445    fn extract_declarations(&self, file: FileId, ctx: &AdapterContext<'_>) -> DeclIndex {
446        let mut idx = decl_index_with_handler(PACK_NAME, file, ctx, &HANDLER);
447        let parsed = parse_with(PACK_NAME, file, ctx);
448        let source = parsed
449            .as_ref()
450            .map(|(snapshot, _)| snapshot.text.to_string())
451            .unwrap_or_default();
452        // Synthesize Call FlowEvents for PHP language constructs the
453        // tree-sitter grammar exposes as dedicated expression kinds
454        // rather than call_expression nodes:
455        //   - `include $tainted` / `include_once $tainted`
456        //   - `require $tainted` / `require_once $tainted`
457        //   - `` `cmd $tainted` `` (shell_command_expression)
458        // Without this lowering the shipped php.eval.{include,require}_*
459        // and php.cmdi.backtick rules can't match real code.
460        if let Some((_, tree)) = parsed.as_ref() {
461            let synthesized = synthesize_php_construct_events(tree, source.as_bytes(), file);
462            if !synthesized.is_empty() {
463                attach_synthesized_calls_to_decls(&mut idx, synthesized);
464            }
465        }
466        // Use the `namespace Foo\Bar;` segments as the module path so
467        // private symbols cross-link only inside the namespace.
468        let namespace_segments = parsed
469            .as_ref()
470            .and_then(|(snapshot, tree)| extract_php_namespace(tree.root_node(), snapshot.text.as_bytes()));
471        if let Some(segments) = namespace_segments {
472            bonsai_lang_api::apply_module_path_semantic_identity(&mut idx, segments);
473        } else {
474            // No `namespace` declaration — fall back to file-stem.
475            bonsai_lang_api::apply_file_stem_semantic_identity(&mut idx, ctx);
476        }
477        if let Some((snapshot, tree)) = parsed.as_ref() {
478            let src = snapshot.text.as_bytes();
479            let visibility_by_span = collect_modifier_visibility(tree.root_node(), file, src, &PHP_VOCAB);
480            let aliases_by_span = collect_param_type_aliases(tree, file, src, &PHP_TYPE_ALIASES);
481            for decl in &mut idx.defs {
482                if let Some(visibility) = visibility_by_span.get(&decl.span).copied() {
483                    decl.visibility = visibility;
484                }
485                if let Some(aliases) = aliases_by_span.get(&decl.span) {
486                    decl.type_aliases = aliases.clone();
487                }
488            }
489            // Per-class `bases`: `class Echo extends Base implements I, J`
490            // → ["Base", "I", "J"]. PHP exposes them as separate
491            // `base_clause` (single) and `class_interface_clause`
492            // (one or more) children of the class node.
493            let bases_by_span = collect_php_class_bases(tree, file, src);
494            for decl in &mut idx.defs {
495                if !is_class_like(decl.kind) {
496                    continue;
497                }
498                if let Some(bases) = bases_by_span.iter().find_map(|(span, name, bases)| {
499                    (*span == decl.span || name == &decl.name).then_some(bases)
500                }) {
501                    decl.bases = bases.clone();
502                }
503            }
504            let promoted_writes_by_span = collect_php_property_promotion_writes(tree, file, src);
505            for decl in &mut idx.defs {
506                if !matches!(decl.kind, DeclKind::Constructor) {
507                    continue;
508                }
509                let Some(promotions) = promoted_writes_by_span
510                    .iter()
511                    .find_map(|(span, promotions)| (*span == decl.span).then_some(promotions))
512                else {
513                    continue;
514                };
515                for promotion in promotions {
516                    let Some(param_idx) = decl.params.iter().position(|param| {
517                        php_param_matches_promoted_property(
518                            param,
519                            &promotion.param_name,
520                            &promotion.field_name,
521                        )
522                    }) else {
523                        continue;
524                    };
525                    decl.receiver_field_writes.push(FieldWrite {
526                        span: promotion.span,
527                        target: format!("this.{}", promotion.field_name),
528                        source_param_indices: vec![param_idx],
529                    });
530                }
531                decl.receiver_field_writes.sort_by_key(|write| {
532                    (
533                        write.span.start,
534                        write.target.clone(),
535                        write.source_param_indices.clone(),
536                    )
537                });
538                decl.receiver_field_writes.dedup();
539            }
540        }
541        let assignment_values = AssignmentValueIndex::new(&idx.assignment_values);
542        for decl in &mut idx.defs {
543            let invoked_variables = php_invoked_variables(&decl.flow_events);
544            augment_php_quoted_callable_literals(
545                &mut decl.flow_events,
546                &source,
547                &assignment_values,
548                &invoked_variables,
549            );
550            bonsai_lang_api::normalize_call_result_assignment_sources(&mut decl.flow_events);
551        }
552        // Precompute `self.<field> → Type` bindings from each
553        // class's constructor `receiver_field_writes` so receiver-
554        // typed dispatch through stable instance state is an O(1)
555        // lookup against the method's `type_aliases` instead of a
556        // per-call walk over sibling decls.
557        // Local constructor-result receiver typing
558        // (`$x = new Foo()` → `$x: Foo`) so `$x->method(...)` carries a
559        // resolved receiver type for `receiver_type_in` / `[Type, method]`
560        // rules. The constructor identity comes from the adapter-lowered
561        // object-creation node, never from identifier casing.
562        bonsai_lang_api::apply_constructor_result_type_aliases(&mut idx);
563        bonsai_lang_api::apply_class_field_type_aliases(&mut idx);
564        let capabilities = self.capabilities();
565        bonsai_lang_api::apply_call_receiver_types_with_language_syntax(
566            &mut idx,
567            capabilities.super_receiver_tokens,
568            capabilities.implicit_receiver_tokens,
569            capabilities.constructor_method_names,
570            capabilities.receiver_type_syntax,
571        );
572        idx
573    }
574    fn extract_imports(&self, file: FileId, ctx: &AdapterContext<'_>) -> ImportIndex {
575        extract_imports_via(PACK_NAME, file, ctx, parse_imports)
576    }
577}
578
579/// PHP permits a string literal containing a function name to be invoked as a
580/// runtime callable (`$cb = 'helper'; $cb($value)`). The assignment and its
581/// RHS span are selected by Tree-sitter before this adapter hook runs; this
582/// step decodes only that literal value. Expression structure and value
583/// carriers remain exclusively compiler-owned [`FlowEvent`] facts.
584fn augment_php_quoted_callable_literals(
585    events: &mut [FlowEvent],
586    source: &str,
587    assignment_values: &AssignmentValueIndex,
588    invoked_variables: &BTreeSet<String>,
589) {
590    for event in events {
591        match event {
592            FlowEvent::Assign {
593                target,
594                span,
595                source_name,
596                value_kind,
597                ..
598            } => {
599                if matches!(value_kind, Some(AssignValueKind::Destructure)) {
600                    continue;
601                }
602                if invoked_variables.contains(target) {
603                    if let Some(rhs) = assignment_values.rendering(*span, source) {
604                        if let Some(callable) = target
605                            .trim_start()
606                            .starts_with('$')
607                            .then(|| php_quoted_bare_callable_literal(rhs))
608                            .flatten()
609                        {
610                            *source_name = Some(callable.to_string());
611                            *value_kind = Some(AssignValueKind::CallableReference);
612                        }
613                    }
614                }
615            }
616            FlowEvent::Branch {
617                then_events,
618                else_events,
619                ..
620            } => {
621                augment_php_quoted_callable_literals(
622                    then_events,
623                    source,
624                    assignment_values,
625                    invoked_variables,
626                );
627                augment_php_quoted_callable_literals(
628                    else_events,
629                    source,
630                    assignment_values,
631                    invoked_variables,
632                );
633            }
634            FlowEvent::Loop { body, .. } | FlowEvent::Defer { body, .. } | FlowEvent::Using { body, .. } => {
635                augment_php_quoted_callable_literals(body, source, assignment_values, invoked_variables);
636            }
637            FlowEvent::Try {
638                body,
639                catch_events,
640                finally_events,
641                ..
642            } => {
643                augment_php_quoted_callable_literals(body, source, assignment_values, invoked_variables);
644                augment_php_quoted_callable_literals(
645                    catch_events,
646                    source,
647                    assignment_values,
648                    invoked_variables,
649                );
650                augment_php_quoted_callable_literals(
651                    finally_events,
652                    source,
653                    assignment_values,
654                    invoked_variables,
655                );
656            }
657            _ => {}
658        }
659    }
660}
661
662/// Return variables that the parsed PHP function actually invokes as
663/// callables. A quoted string is a normal literal unless the CST-derived call
664/// event proves that the assigned variable is used in callable position.
665fn php_invoked_variables(events: &[FlowEvent]) -> BTreeSet<String> {
666    let mut invoked = BTreeSet::new();
667    bonsai_lang_api::for_each_flow_event(events, &mut |event| {
668        if let FlowEvent::Call {
669            name,
670            call_kind: CallKind::Function | CallKind::Indirect,
671            ..
672        } = event
673        {
674            if name.starts_with('$') {
675                invoked.insert(name.clone());
676            }
677        }
678    });
679    invoked
680}
681
682fn php_quoted_bare_callable_literal(value: &str) -> Option<&str> {
683    let value = value.trim();
684    let quote = value.as_bytes().first().copied()?;
685    if !matches!(quote, b'\'' | b'"') || value.as_bytes().last().copied() != Some(quote) {
686        return None;
687    }
688    let inner = value.get(1..value.len().saturating_sub(1))?.trim();
689    if inner.is_empty()
690        || inner
691            .chars()
692            .any(|ch| !(ch == '_' || ch == '\\' || ch.is_ascii_alphanumeric()))
693        || inner.chars().next().is_some_and(|ch| ch.is_ascii_digit())
694    {
695        return None;
696    }
697    Some(inner)
698}
699
700/// Parse PHP `use`/`require`/`include` statements into `ImportSpec`s.
701fn parse_imports(tree: &Tree, src: &[u8], file: FileId) -> Vec<ImportSpec> {
702    let mut imports = Vec::new();
703    // Two PHP import shapes:
704    //   1. `use X\Y;` / `use X\Y as Z;` / `use X\{A, B};`
705    //      → only `namespace_use_clause`. The outer
706    //        `namespace_use_declaration` wraps one or more clauses,
707    //        so collecting both kinds emits each import twice.
708    //   2. `require '...';` / `require_once '...';` / `include '...';`
709    //      → dedicated expression nodes (NOT call expressions)
710    for clause in collect_kinds(tree, &["namespace_use_clause"]) {
711        let raw = node_text(&clause, src)
712            .trim_start_matches("use ")
713            .trim_end_matches(';')
714            .trim();
715        if raw.is_empty() {
716            continue;
717        }
718        // Split off `as Alias` if present.
719        let (module_text, explicit_alias) = if let Some((module_part, alias_part)) = raw.rsplit_once(" as ") {
720            (
721                module_part.trim().to_string(),
722                Some(alias_part.trim().to_string()),
723            )
724        } else {
725            (raw.to_string(), None)
726        };
727        // Grouped import `use Foo\{A, B as BB};` lowers each member
728        // to a `namespace_use_clause` whose text is just `A` / `B as
729        // BB` — the `Foo\` prefix lives on the outer
730        // `namespace_use_group`'s namespace child. Walk up and
731        // prepend so resolve sees the fully-qualified module path.
732        let qualified_module = match group_namespace_prefix(&clause, src) {
733            Some(prefix) if !module_text.starts_with(&prefix) => format!("{prefix}\\{module_text}"),
734            _ => module_text,
735        };
736        // PHP `use App\Middle;` binds `Middle` even without an explicit
737        // `as` clause. This is grammar semantics, so emit the binding here
738        // instead of asking the language-neutral resolver to infer a basename
739        // from every path-like import.
740        let alias = explicit_alias.or_else(|| canonical_php_base_name(&qualified_module));
741        imports.push(ImportSpec {
742            span: span_of(file, &clause),
743            module: qualified_module,
744            alias,
745            is_wildcard: false,
746            original_name: None,
747            scope: ImportScope::Module,
748        });
749    }
750    for node in collect_kinds(
751        tree,
752        &[
753            "require_expression",
754            "require_once_expression",
755            "include_expression",
756            "include_once_expression",
757        ],
758    ) {
759        // The argument can be a bare string or a binary expression like
760        // `__DIR__ . '/foo.php'`. Surface the FIRST string descendant —
761        // that matches what the user would query against.
762        let module = first_string_descendant(&node, src);
763        if module.is_empty() {
764            continue;
765        }
766        imports.push(ImportSpec {
767            span: span_of(file, &node),
768            module,
769            alias: None,
770            is_wildcard: true,
771            original_name: None,
772            scope: ImportScope::Module,
773        });
774    }
775    imports
776}
777
778/// For a `namespace_use_clause` nested inside `use Foo\{A, B};`,
779/// return the `Foo` prefix carried by the enclosing
780/// `namespace_use_group`'s namespace child. Returns `None` for
781/// non-grouped `use Foo\Bar;` clauses.
782fn group_namespace_prefix(clause: &tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
783    let mut ancestor = clause.parent();
784    while let Some(parent) = ancestor {
785        if parent.kind() == "namespace_use_group" {
786            // tree-sitter-php doesn't expose the prefix as a named
787            // field on the enclosing `namespace_use_declaration`;
788            // walk the outer declaration's named children up to the
789            // group node and pick the last `namespace_name` /
790            // `qualified_name` we see (handles multi-segment
791            // prefixes like `Foo\Bar\{A, B}`).
792            let outer = parent.parent()?;
793            let mut outer_cursor = outer.walk();
794            let mut last_prefix: Option<String> = None;
795            for child in outer.named_children(&mut outer_cursor) {
796                // Stop scanning once we reach the group itself —
797                // anything past it is a member, not a prefix.
798                if child.id() == parent.id() {
799                    break;
800                }
801                if matches!(child.kind(), "namespace_name" | "qualified_name") {
802                    let text = node_text(&child, src).trim_end_matches('\\').trim().to_string();
803                    if !text.is_empty() {
804                        last_prefix = Some(text);
805                    }
806                }
807            }
808            return last_prefix;
809        }
810        ancestor = parent.parent();
811    }
812    None
813}
814
815/// Return the source text of the first `string` literal descendant
816/// (without quotes) under `node`, or an empty string if none.
817fn first_string_descendant(node: &tree_sitter::Node<'_>, src: &[u8]) -> String {
818    let mut stack = vec![*node];
819    while let Some(current) = stack.pop() {
820        if current.kind() == "string" {
821            // Prefer the unquoted `string_content` child; otherwise
822            // strip surrounding quotes manually.
823            if let Some(content) = first_named_child_of_kind(&current, "string_content") {
824                return node_text(&content, src).to_string();
825            }
826            return node_text(&current, src)
827                .trim_matches(|ch: char| matches!(ch, '"' | '\''))
828                .to_string();
829        }
830        let mut cursor = current.walk();
831        for child in current.named_children(&mut cursor) {
832            stack.push(child);
833        }
834    }
835    String::new()
836}
837
838/// Synthesize Call FlowEvents for PHP language constructs that
839/// tree-sitter exposes as dedicated expression kinds.
840///
841/// Mappings:
842///   - include_expression           → name = "include"
843///   - include_once_expression      → name = "include_once"
844///   - require_expression           → name = "require"
845///   - require_once_expression      → name = "require_once"
846///   - shell_command_expression     → name = "shell_exec" (matches
847///     the existing php.cmdi.shell_exec rule's name without needing
848///     a new rule for backtick literal)
849///
850/// The expression's argument (the included path / shell command)
851/// becomes a single positional CallArg whose `value_text` is the
852/// argument's source text — typically a `variable_name` like `$t`
853/// or a string literal. The taint engine reads `value_text` to
854/// decide whether the argument is tainted.
855fn synthesize_php_construct_events(tree: &Tree, src: &[u8], file: FileId) -> Vec<(Span, FlowEvent)> {
856    // Pairs of (grammar kind, synthesized callee name). The callee
857    // name must match what the rulepack queries against.
858    const CONSTRUCT_KINDS: &[(&str, &str)] = &[
859        ("include_expression", "include"),
860        ("include_once_expression", "include_once"),
861        ("require_expression", "require"),
862        ("require_once_expression", "require_once"),
863        ("shell_command_expression", "shell_exec"),
864    ];
865    let mut synthesized = Vec::new();
866    for (kind, callee) in CONSTRUCT_KINDS {
867        for node in collect_kinds(tree, &[*kind]) {
868            let span = span_of(file, &node);
869            let mut args: Vec<CallArg> = Vec::new();
870            // For include/require the first non-keyword child is the
871            // argument expression; for shell_command_expression the
872            // interpolated scalars / identifiers inside become args.
873            if *kind == "shell_command_expression" {
874                let mut cursor = node.walk();
875                let mut stack: Vec<tree_sitter::Node<'_>> = Vec::new();
876                for child in node.named_children(&mut cursor) {
877                    stack.push(child);
878                }
879                while let Some(current) = stack.pop() {
880                    // Only nodes that can carry user data become args;
881                    // literal text inside the backticks is ignored.
882                    if matches!(
883                        current.kind(),
884                        "variable_name" | "subscript_expression" | "member_access_expression"
885                    ) {
886                        if let Some(argument) =
887                            call_arg_from_node_with_handler(current, file, src, None, &HANDLER)
888                        {
889                            args.push(argument);
890                        }
891                        continue;
892                    }
893                    let mut child_cursor = current.walk();
894                    for child in current.named_children(&mut child_cursor) {
895                        stack.push(child);
896                    }
897                }
898            } else {
899                let mut cursor = node.walk();
900                for child in node.named_children(&mut cursor) {
901                    if let Some(argument) = call_arg_from_node_with_handler(child, file, src, None, &HANDLER)
902                    {
903                        args.push(argument);
904                        // include/require has exactly one argument.
905                        break;
906                    }
907                }
908            }
909            let event = FlowEvent::Call {
910                span,
911                name: (*callee).to_string(),
912                receiver: None,
913                receiver_types: Vec::new(),
914                call_kind: CallKind::Function,
915                args,
916            };
917            synthesized.push((span, event));
918        }
919    }
920    synthesized
921}
922
923/// Assign each synthesized call event to the smallest decl whose body
924/// contains the event span.
925fn attach_synthesized_calls_to_decls(idx: &mut DeclIndex, events: Vec<(Span, FlowEvent)>) {
926    // PHP allows nested `function inner()` inside another function
927    // body. tree-sitter-php parses both as `function_definition`, so
928    // pre-order extraction yields [outer, inner]. Picking the FIRST
929    // containing decl would route the synthesized event (require /
930    // include / backtick) to `outer`, hiding it from `inner`'s
931    // intra-taint pass. Pick the smallest containing decl instead.
932    for (event_span, event) in events {
933        let mut best_decl: Option<usize> = None;
934        let mut best_body_len: u64 = u64::MAX;
935        for (decl_idx, decl) in idx.defs.iter().enumerate() {
936            let body = decl.body_span.unwrap_or(decl.span);
937            if event_span.file == body.file && event_span.start >= body.start && event_span.end <= body.end {
938                let body_len = body.end.saturating_sub(body.start);
939                if body_len < best_body_len {
940                    best_decl = Some(decl_idx);
941                    best_body_len = body_len;
942                }
943            }
944        }
945        if let Some(decl_idx) = best_decl {
946            idx.defs[decl_idx].flow_events.push(event);
947        }
948    }
949}
950
951/// True for decl kinds that can declare an `extends`/`implements`
952/// list — used to gate which decls receive `bases` entries.
953fn is_class_like(kind: DeclKind) -> bool {
954    matches!(
955        kind,
956        DeclKind::Class | DeclKind::Interface | DeclKind::Trait | DeclKind::Struct | DeclKind::Enum
957    )
958}
959
960#[derive(Clone, Debug)]
961struct PhpPromotedPropertyWrite {
962    span: Span,
963    param_name: String,
964    field_name: String,
965}
966
967fn collect_php_property_promotion_writes(
968    tree: &Tree,
969    file: FileId,
970    src: &[u8],
971) -> Vec<(Span, Vec<PhpPromotedPropertyWrite>)> {
972    let mut out = Vec::new();
973    for method_node in collect_kinds(tree, &["method_declaration"]) {
974        let Some(name_node) = method_node
975            .child_by_field_name("name")
976            .or_else(|| first_named_child_of_kind(&method_node, "name"))
977        else {
978            continue;
979        };
980        if node_text(&name_node, src).trim() != "__construct" {
981            continue;
982        }
983        let mut promotions = Vec::new();
984        collect_php_property_promotion_writes_inner(method_node, file, src, &mut promotions);
985        if !promotions.is_empty() {
986            out.push((span_of(file, &method_node), promotions));
987        }
988    }
989    out
990}
991
992fn collect_php_property_promotion_writes_inner(
993    node: tree_sitter::Node<'_>,
994    file: FileId,
995    src: &[u8],
996    out: &mut Vec<PhpPromotedPropertyWrite>,
997) {
998    if node.kind() == "property_promotion_parameter" {
999        if let Some(name_node) = node.child_by_field_name("name") {
1000            let param_name = node_text(&name_node, src).trim().to_string();
1001            let field_name = php_promoted_property_field_name(&name_node, src)
1002                .unwrap_or_else(|| param_name.trim_start_matches('$').to_string());
1003            if !param_name.is_empty() && !field_name.is_empty() {
1004                out.push(PhpPromotedPropertyWrite {
1005                    span: span_of(file, &node),
1006                    param_name,
1007                    field_name,
1008                });
1009            }
1010        }
1011        return;
1012    }
1013    let mut cursor = node.walk();
1014    let children: Vec<_> = node.named_children(&mut cursor).collect();
1015    for child in children {
1016        collect_php_property_promotion_writes_inner(child, file, src, out);
1017    }
1018}
1019
1020fn php_promoted_property_field_name(name_node: &tree_sitter::Node<'_>, src: &[u8]) -> Option<String> {
1021    if name_node.kind() == "variable_name" {
1022        let mut cursor = name_node.walk();
1023        for child in name_node.named_children(&mut cursor) {
1024            if child.kind() == "name" {
1025                let name = node_text(&child, src).trim();
1026                if !name.is_empty() {
1027                    return Some(name.to_string());
1028                }
1029            }
1030        }
1031    }
1032    let raw = node_text(name_node, src);
1033    let bare = raw.trim().trim_start_matches('$');
1034    (!bare.is_empty()).then(|| bare.to_string())
1035}
1036
1037fn php_param_matches_promoted_property(param: &str, promoted_param: &str, field_name: &str) -> bool {
1038    let param = param.trim();
1039    let promoted_param = promoted_param.trim();
1040    param == promoted_param
1041        || param.trim_start_matches('$') == promoted_param.trim_start_matches('$')
1042        || param.trim_start_matches('$') == field_name
1043}
1044
1045/// Walk PHP class / interface / trait declarations and collect bare
1046/// base type names. Grammar shape (verified):
1047///
1048///   `class Handler extends Base implements I, J { ... }` →
1049///     (class_declaration name: (name)
1050///        (base_clause (name))
1051///        (class_interface_clause (name) (name))
1052///        body: (declaration_list))
1053///
1054/// `interface_declaration` uses its own `interface_base_clause` (just
1055/// `extends`). `trait_declaration` has no parent list.
1056fn collect_php_class_bases(
1057    tree: &Tree,
1058    file: FileId,
1059    src: &[u8],
1060) -> Vec<(bonsai_common::Span, String, Vec<String>)> {
1061    let mut bases_by_class = Vec::new();
1062    let class_kinds = &["class_declaration", "interface_declaration", "enum_declaration"];
1063    for class_node in collect_kinds(tree, class_kinds) {
1064        let Some(name_node) = class_node
1065            .child_by_field_name("name")
1066            .or_else(|| first_named_child_of_kind(&class_node, "name"))
1067            .or_else(|| first_named_child_of_kind(&class_node, "qualified_name"))
1068        else {
1069            continue;
1070        };
1071        let class_name = node_text(&name_node, src).trim();
1072        if class_name.is_empty() {
1073            continue;
1074        }
1075        let mut bases: Vec<String> = Vec::new();
1076        let mut cursor = class_node.walk();
1077        for child in class_node.named_children(&mut cursor) {
1078            match child.kind() {
1079                "base_clause" | "class_interface_clause" | "interface_base_clause" => {
1080                    let mut clause_cursor = child.walk();
1081                    for entry in child.named_children(&mut clause_cursor) {
1082                        // Children of the parent-clause are
1083                        // `name` / `qualified_name` identifiers in
1084                        // tree-sitter-php.
1085                        if matches!(entry.kind(), "name" | "qualified_name") {
1086                            let raw = node_text(&entry, src);
1087                            if let Some(name) = canonical_php_base_name(raw) {
1088                                if !bases.iter().any(|existing| existing == &name) {
1089                                    bases.push(name);
1090                                }
1091                            }
1092                        }
1093                    }
1094                }
1095                _ => {}
1096            }
1097        }
1098        if !bases.is_empty() {
1099            bases_by_class.push((span_of(file, &class_node), class_name.to_string(), bases));
1100        }
1101    }
1102    bases_by_class
1103}
1104
1105/// Strip namespace qualifiers from a base name. `\Foo\Bar` → `Bar`,
1106/// `Bar` → `Bar`. Returns `None` for empty input.
1107fn canonical_php_base_name(raw: &str) -> Option<String> {
1108    let trimmed = raw.trim().trim_start_matches('\\');
1109    let bare = trimmed.rsplit('\\').next().unwrap_or(trimmed).trim();
1110    if bare.is_empty() {
1111        return None;
1112    }
1113    Some(bare.to_string())
1114}
1115
1116/// Return the namespace path (split on `\`) of the file's
1117/// `namespace Foo\Bar;` declaration, or `None` if absent.
1118fn extract_php_namespace(root: tree_sitter::Node<'_>, src: &[u8]) -> Option<Vec<String>> {
1119    let mut cursor = root.walk();
1120    for child in root.children(&mut cursor) {
1121        if child.kind() != "namespace_definition" {
1122            continue;
1123        }
1124        if let Some(name_node) = child.child_by_field_name("name") {
1125            let text = node_text(&name_node, src);
1126            let segments: Vec<String> = text
1127                .split('\\')
1128                .map(str::trim)
1129                .filter(|segment| !segment.is_empty())
1130                .map(str::to_string)
1131                .collect();
1132            if !segments.is_empty() {
1133                return Some(segments);
1134            }
1135        }
1136    }
1137    None
1138}
1139
1140#[cfg(test)]
1141mod callable_reference_tests {
1142    use super::*;
1143
1144    #[test]
1145    fn first_class_callable_placeholder_is_adapter_owned() {
1146        let language = language_from_pack(PACK_NAME).expect("php grammar");
1147        let mut parser = tree_sitter::Parser::new();
1148        parser.set_language(&language).expect("set php grammar");
1149        let src = "<?php function f() { $cb = system(...); $value = system($x); }";
1150        let tree = parser.parse(src, None).expect("parse php source");
1151        let refs = collect_kinds(
1152            &tree,
1153            &[
1154                "function_call_expression",
1155                "member_call_expression",
1156                "nullsafe_member_call_expression",
1157                "scoped_call_expression",
1158            ],
1159        )
1160        .into_iter()
1161        .filter_map(|node| extract_php_callable_reference(node, src.as_bytes()))
1162        .collect::<Vec<_>>();
1163        assert_eq!(refs, vec!["system"]);
1164    }
1165}