use tree_sitter::{Node, Parser};
use crate::error::{CodegraphError, Result};
use crate::graph::types::{
Binding, BindingKind, ByteSpan, FileFacts, RefRole, Reference, Scope, ScopeId, ScopeKind,
Symbol, SymbolKind, TypeRefContext, Visibility,
};
use crate::lang::Language;
use crate::symbol::Descriptor;
use super::{
ExtractCtx, Extractor, MIN_REF_LEN, attach_reference_scopes, child_text,
collect_call_references, definition_bindings, field_text, import_bindings, innermost_scope,
make_symbol, node_span, node_text, one_line_signature, push_binding, push_ref, push_scope,
push_type_ref,
};
const CALL_QUERY: &str = r#"
[
(function_call_expression
function: (name) @callee)
(member_call_expression
name: (name) @callee)
]
"#;
pub struct PhpExtractor;
impl Extractor for PhpExtractor {
fn lang(&self) -> Language {
Language::Php
}
fn extract(&self, source: &str, file: &str) -> Result<FileFacts> {
let ts_language = crate::grammar::php();
let mut parser = Parser::new();
parser
.set_language(&ts_language)
.map_err(|_| CodegraphError::Parse {
path: file.to_owned(),
})?;
let tree = parser
.parse(source, None)
.ok_or_else(|| CodegraphError::Parse {
path: file.to_owned(),
})?;
let root = tree.root_node();
let bytes = source.as_bytes();
let namespaces = php_namespaces(&root, bytes, file);
let ctx = ExtractCtx {
bytes,
file,
lang: Language::Php,
};
let mut defs = Vec::new();
collect_defs(&root, &namespaces, &ctx, &mut defs);
let def_bindings = definition_bindings(&defs);
let mut symbols = defs;
symbols.push(super::module_symbol(
Language::Php,
&namespaces,
file,
source.len(),
));
let mut references =
collect_call_references(&root, &ts_language, CALL_QUERY, Language::Php, bytes, file)?;
collect_inheritance(&root, bytes, file, &mut references);
collect_imports(&root, bytes, file, &mut references);
collect_type_references(&root, bytes, file, &mut references);
collect_read_references(&root, bytes, file, &mut references);
collect_write_references(&root, bytes, file, &mut references);
let scopes = collect_scopes(&root, source.len());
attach_reference_scopes(&mut references, &scopes);
let mut bindings = collect_bindings(&root, bytes, &scopes);
bindings.extend(def_bindings);
bindings.extend(import_bindings(&references, &scopes));
Ok(FileFacts {
file: file.to_owned(),
lang: Language::Php.as_str().to_owned(),
symbols,
references,
scopes,
bindings,
ffi_exports: Vec::new(),
})
}
}
fn php_namespaces(root: &Node, bytes: &[u8], file: &str) -> Vec<String> {
for child in root.children(&mut root.walk()) {
if child.kind() != "namespace_definition" {
continue;
}
if let Some(ns_text) = field_text(&child, "name", bytes) {
let parts: Vec<String> = ns_text
.split('\\')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
if !parts.is_empty() {
return parts;
}
}
}
let p = file.strip_suffix(".php").unwrap_or(file);
let p = p.strip_prefix("src/").unwrap_or(p);
p.split('/')
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect()
}
fn collect_defs(container: &Node, namespaces: &[String], ctx: &ExtractCtx, out: &mut Vec<Symbol>) {
let bytes = ctx.bytes;
let base_descriptors: Vec<Descriptor> = namespaces
.iter()
.cloned()
.map(Descriptor::Namespace)
.collect();
for child in container.children(&mut container.walk()) {
match child.kind() {
"function_definition" => {
let Some(name) = field_text(&child, "name", bytes) else {
continue;
};
let mut descriptors = base_descriptors.clone();
descriptors.push(Descriptor::Method {
name: name.clone(),
disambiguator: String::new(),
});
push_symbol(
out,
ctx,
&child,
name,
SymbolKind::Function,
Visibility::Public,
descriptors,
);
}
kind @ ("class_declaration"
| "interface_declaration"
| "trait_declaration"
| "enum_declaration") => {
let Some(type_name) = field_text(&child, "name", bytes) else {
continue;
};
let type_sym_kind = match kind {
"class_declaration" => SymbolKind::Class,
"interface_declaration" => SymbolKind::Interface,
"trait_declaration" => SymbolKind::Trait,
"enum_declaration" => SymbolKind::Enum,
_ => unreachable!(),
};
let mut type_descriptors = base_descriptors.clone();
type_descriptors.push(Descriptor::Type(type_name.clone()));
push_symbol(
out,
ctx,
&child,
type_name,
type_sym_kind,
Visibility::Public,
type_descriptors.clone(),
);
let implicit_public = kind == "interface_declaration";
if let Some(body) = child.child_by_field_name("body") {
collect_members(&body, &type_descriptors, implicit_public, ctx, out);
}
}
"namespace_definition" => {
if let Some(body) = child.child_by_field_name("body") {
collect_defs(&body, namespaces, ctx, out);
}
}
_ => {}
}
}
}
fn collect_members(
body: &Node,
type_descriptors: &[Descriptor],
implicit_public: bool,
ctx: &ExtractCtx,
out: &mut Vec<Symbol>,
) {
let bytes = ctx.bytes;
for member in body.children(&mut body.walk()) {
match member.kind() {
"method_declaration" => {
let Some(name) = field_text(&member, "name", bytes) else {
continue;
};
let vis = if implicit_public {
Visibility::Public
} else {
read_visibility(&member, bytes)
};
let mut descriptors = type_descriptors.to_vec();
descriptors.push(Descriptor::Method {
name: name.clone(),
disambiguator: String::new(),
});
push_symbol(
out,
ctx,
&member,
name,
SymbolKind::Method,
vis,
descriptors,
);
}
"property_declaration" => {
let vis = if implicit_public {
Visibility::Public
} else {
read_visibility(&member, bytes)
};
for elem in member.children(&mut member.walk()) {
if elem.kind() != "property_element" {
continue;
}
let Some(raw_name) = field_text(&elem, "name", bytes) else {
continue;
};
let name = if let Some(stripped) = raw_name.strip_prefix('$') {
stripped.to_owned()
} else {
raw_name
};
let mut descriptors = type_descriptors.to_vec();
descriptors.push(Descriptor::Term(name.clone()));
push_symbol(
out,
ctx,
&member,
name,
SymbolKind::Static,
vis,
descriptors,
);
}
}
"const_declaration" => {
let vis = if implicit_public {
Visibility::Public
} else {
read_visibility(&member, bytes)
};
for elem in member.children(&mut member.walk()) {
if elem.kind() != "const_element" {
continue;
}
let Some(name) = child_text(&elem, "name", bytes) else {
continue;
};
let mut descriptors = type_descriptors.to_vec();
descriptors.push(Descriptor::Term(name.clone()));
push_symbol(out, ctx, &member, name, SymbolKind::Const, vis, descriptors);
}
}
_ => {}
}
}
}
fn read_visibility(node: &Node, bytes: &[u8]) -> Visibility {
for child in node.children(&mut node.walk()) {
if child.kind() == "visibility_modifier" {
return match node_text(&child, bytes) {
"private" => Visibility::Private,
"protected" => Visibility::Protected,
_ => Visibility::Public,
};
}
}
Visibility::Public
}
fn collect_imports(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "namespace_use_clause" {
for child in node.children(&mut node.walk()) {
if matches!(child.kind(), "qualified_name" | "name") {
let leaf = super::simple_type_name(node_text(&child, bytes), "\\");
super::push_ref(out, leaf, &child, file, RefRole::Import);
break;
}
}
}
for child in node.children(&mut node.walk()) {
collect_imports(&child, bytes, file, out);
}
}
fn collect_inheritance(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if matches!(node.kind(), "class_declaration" | "interface_declaration") {
for child in node.children(&mut node.walk()) {
if matches!(child.kind(), "base_clause" | "class_interface_clause") {
for type_node in child.children(&mut child.walk()) {
if matches!(
type_node.kind(),
"name" | "qualified_name" | "relative_name"
) {
super::push_ref(
out,
super::simple_type_name(node_text(&type_node, bytes), "\\"),
&type_node,
file,
RefRole::IsImplementation,
);
}
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_inheritance(&child, bytes, file, out);
}
}
fn var_bare_name(node: &Node, bytes: &[u8]) -> String {
child_text(node, "name", bytes)
.unwrap_or_else(|| node_text(node, bytes).trim_start_matches('$').to_owned())
}
fn is_non_read_var_position(node: &Node) -> bool {
let parent = match node.parent() {
Some(p) => p,
None => return true,
};
match parent.kind() {
"assignment_expression" => parent.child_by_field_name("left").as_ref() == Some(node),
"simple_parameter" | "variadic_parameter" | "property_promotion_parameter" => {
parent.child_by_field_name("name").as_ref() == Some(node)
}
"foreach_statement" => {
let first = parent.named_children(&mut parent.walk()).next();
let body = parent.child_by_field_name("body");
first.as_ref() != Some(node) && body.as_ref() != Some(node)
}
_ => false,
}
}
fn collect_read_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "variable_name" {
let name = var_bare_name(node, bytes);
if name.len() >= MIN_REF_LEN && !is_non_read_var_position(node) {
push_ref(out, &name, node, file, RefRole::Read);
}
return;
}
for child in node.children(&mut node.walk()) {
collect_read_references(&child, bytes, file, out);
}
}
fn collect_write_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
if node.kind() == "assignment_expression" {
if let Some(lhs) = node.child_by_field_name("left") {
if lhs.kind() == "variable_name" {
let name = var_bare_name(&lhs, bytes);
if name.len() >= MIN_REF_LEN {
push_ref(out, &name, &lhs, file, RefRole::Write);
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_write_references(&child, bytes, file, out);
}
}
fn type_leaf(
type_node: &Node,
bytes: &[u8],
file: &str,
ctx: TypeRefContext,
out: &mut Vec<Reference>,
) {
match type_node.kind() {
"primitive_type" => {}
"named_type" => {
for child in type_node.named_children(&mut type_node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
"union_type" | "intersection_type" | "optional_type" => {
for child in type_node.named_children(&mut type_node.walk()) {
type_leaf(&child, bytes, file, ctx, out);
}
}
"name" => {
let name = node_text(type_node, bytes);
push_type_ref(out, name, type_node, file, ctx);
}
"qualified_name" => {
let raw = node_text(type_node, bytes);
let leaf = raw.rsplit('\\').next().unwrap_or(raw).trim();
if !leaf.is_empty() {
push_type_ref(out, leaf, type_node, file, ctx);
}
}
"relative_name" => {
let raw = node_text(type_node, bytes);
let leaf = raw.rsplit('\\').next().unwrap_or(raw).trim();
if !leaf.is_empty() {
push_type_ref(out, leaf, type_node, file, ctx);
}
}
_ => {}
}
}
fn collect_type_references(node: &Node, bytes: &[u8], file: &str, out: &mut Vec<Reference>) {
match node.kind() {
"simple_parameter" | "property_promotion_parameter" => {
if let Some(type_node) = node.child_by_field_name("type") {
type_leaf(&type_node, bytes, file, TypeRefContext::ParameterType, out);
}
}
"function_definition" | "method_declaration" => {
if let Some(ret_node) = node.child_by_field_name("return_type") {
type_leaf(&ret_node, bytes, file, TypeRefContext::ReturnType, out);
}
for child in node.children(&mut node.walk()) {
collect_type_references(&child, bytes, file, out);
}
return; }
"property_declaration" => {
if let Some(type_node) = node.child_by_field_name("type") {
type_leaf(&type_node, bytes, file, TypeRefContext::Field, out);
}
}
_ => {}
}
for child in node.children(&mut node.walk()) {
collect_type_references(&child, bytes, file, out);
}
}
fn collect_scopes(root: &Node, source_len: usize) -> Vec<Scope> {
let mut scopes = Vec::new();
push_scope(
&mut scopes,
None,
ByteSpan {
start: 0,
end: source_len,
},
ScopeKind::Module,
);
for child in root.children(&mut root.walk()) {
scope_dfs(&child, 0, &mut scopes);
}
scopes
}
fn scope_dfs(node: &Node, parent_id: ScopeId, scopes: &mut Vec<Scope>) {
match node.kind() {
"namespace_definition" => {
if let Some(body) = node.child_by_field_name("body") {
let ns_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Type);
for child in body.children(&mut body.walk()) {
scope_dfs(&child, ns_id, scopes);
}
} else {
for child in node.children(&mut node.walk()) {
scope_dfs(&child, parent_id, scopes);
}
}
}
"class_declaration"
| "interface_declaration"
| "trait_declaration"
| "enum_declaration" => {
let type_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Type);
if let Some(body) = node.child_by_field_name("body") {
for child in body.children(&mut body.walk()) {
scope_dfs(&child, type_id, scopes);
}
}
}
"function_definition" | "method_declaration" | "anonymous_function" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
if let Some(body) = node.child_by_field_name("body") {
for child in body.children(&mut body.walk()) {
scope_dfs(&child, fn_id, scopes);
}
}
}
"arrow_function" => {
let fn_id = push_scope(
scopes,
Some(parent_id),
node_span(node),
ScopeKind::Function,
);
for child in node.children(&mut node.walk()) {
scope_dfs(&child, fn_id, scopes);
}
}
"compound_statement" => {
let block_id = push_scope(scopes, Some(parent_id), node_span(node), ScopeKind::Block);
for child in node.children(&mut node.walk()) {
scope_dfs(&child, block_id, scopes);
}
}
_ => {
for child in node.children(&mut node.walk()) {
scope_dfs(&child, parent_id, scopes);
}
}
}
}
fn collect_bindings(root: &Node, bytes: &[u8], scopes: &[Scope]) -> Vec<Binding> {
let mut out = Vec::new();
collect_bindings_dfs(root, bytes, scopes, &mut out);
out
}
fn collect_bindings_dfs(node: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
match node.kind() {
"function_definition" | "method_declaration" | "anonymous_function" | "arrow_function" => {
if let Some(params) = node.child_by_field_name("parameters") {
collect_params(¶ms, bytes, scopes, out);
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"assignment_expression" => {
if let Some(left) = node.child_by_field_name("left") {
if left.kind() == "variable_name" {
let name = child_text(&left, "name", bytes).unwrap_or_else(|| {
node_text(&left, bytes).trim_start_matches('$').to_owned()
});
if !name.is_empty() {
let intro = left.start_byte();
if let Some(sid) = innermost_scope(intro, scopes) {
if matches!(scopes[sid].kind, ScopeKind::Function | ScopeKind::Block) {
push_binding(out, name, intro, BindingKind::Local, scopes);
}
}
}
}
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
"foreach_statement" => {
let body = node.child_by_field_name("body");
let mut first_seen = false;
for child in node.named_children(&mut node.walk()) {
if let Some(ref b) = body {
if child == *b {
continue;
}
}
if !first_seen {
first_seen = true;
continue;
}
collect_foreach_var(&child, bytes, scopes, out);
}
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
_ => {
for child in node.children(&mut node.walk()) {
collect_bindings_dfs(&child, bytes, scopes, out);
}
}
}
}
fn collect_foreach_var(node: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
if node.kind() == "variable_name" {
let name = child_text(node, "name", bytes)
.unwrap_or_else(|| node_text(node, bytes).trim_start_matches('$').to_owned());
if !name.is_empty() {
let intro = node.start_byte();
if let Some(sid) = innermost_scope(intro, scopes) {
if matches!(scopes[sid].kind, ScopeKind::Function | ScopeKind::Block) {
push_binding(out, name, intro, BindingKind::Local, scopes);
}
}
}
return;
}
for child in node.named_children(&mut node.walk()) {
collect_foreach_var(&child, bytes, scopes, out);
}
}
fn collect_params(params: &Node, bytes: &[u8], scopes: &[Scope], out: &mut Vec<Binding>) {
for child in params.named_children(&mut params.walk()) {
match child.kind() {
"simple_parameter" | "variadic_parameter" | "property_promotion_parameter" => {
let Some(name_node) = child.child_by_field_name("name") else {
continue;
};
let var_node = if name_node.kind() == "by_ref" {
name_node
.named_children(&mut name_node.walk())
.find(|c| c.kind() == "variable_name")
} else if name_node.kind() == "variable_name" {
Some(name_node)
} else {
None
};
let Some(var) = var_node else {
continue;
};
let name = child_text(&var, "name", bytes)
.unwrap_or_else(|| node_text(&var, bytes).trim_start_matches('$').to_owned());
if name.is_empty() {
continue;
}
let intro = var.start_byte();
push_binding(out, name, intro, BindingKind::Param, scopes);
}
_ => {}
}
}
}
fn push_symbol(
out: &mut Vec<Symbol>,
ctx: &ExtractCtx,
node: &Node,
name: String,
kind: SymbolKind,
visibility: Visibility,
descriptors: Vec<Descriptor>,
) {
let signature = one_line_signature(node_text(node, ctx.bytes), &['{', ';']);
out.push(make_symbol(
ctx,
node,
name,
kind,
visibility,
descriptors,
signature,
));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_namespaced_defs() {
let src = r#"<?php
namespace App\Auth;
function format_id($x) { return helper($x); }
class Session {
const MAX = 3;
public $token;
private $secret;
public function validate($t) { return $this->check($t); }
private function internal() {}
}
interface Reader {
function read();
}
"#;
let facts = PhpExtractor.extract(src, "src/app/Session.php").unwrap();
let by_name = |n: &str| facts.symbols.iter().find(|s| s.name == n).cloned();
let format_id = by_name("format_id").unwrap();
assert_eq!(format_id.kind, SymbolKind::Function);
assert_eq!(
format_id.id.to_scip_string(),
"codegraph . . . App/Auth/format_id()."
);
let session = by_name("Session").unwrap();
assert_eq!(session.kind, SymbolKind::Class);
assert_eq!(
session.id.to_scip_string(),
"codegraph . . . App/Auth/Session#"
);
let max = by_name("MAX").unwrap();
assert_eq!(max.kind, SymbolKind::Const);
assert_eq!(
max.id.to_scip_string(),
"codegraph . . . App/Auth/Session#MAX."
);
let token = by_name("token").unwrap();
assert_eq!(token.kind, SymbolKind::Static);
assert_eq!(
token.id.to_scip_string(),
"codegraph . . . App/Auth/Session#token."
);
assert_eq!(token.visibility, Visibility::Public);
let secret = by_name("secret").unwrap();
assert_eq!(secret.kind, SymbolKind::Static);
assert_eq!(secret.visibility, Visibility::Private);
let validate = by_name("validate").unwrap();
assert_eq!(validate.kind, SymbolKind::Method);
assert_eq!(
validate.id.to_scip_string(),
"codegraph . . . App/Auth/Session#validate()."
);
assert_eq!(validate.visibility, Visibility::Public);
let internal = by_name("internal").unwrap();
assert_eq!(internal.kind, SymbolKind::Method);
assert_eq!(internal.visibility, Visibility::Private);
let reader = by_name("Reader").unwrap();
assert_eq!(reader.kind, SymbolKind::Interface);
assert_eq!(
reader.id.to_scip_string(),
"codegraph . . . App/Auth/Reader#"
);
let read = by_name("read").unwrap();
assert_eq!(read.kind, SymbolKind::Method);
assert_eq!(
read.id.to_scip_string(),
"codegraph . . . App/Auth/Reader#read()."
);
assert_eq!(facts.lang, "php");
}
#[test]
fn path_fallback_without_namespace() {
let src = r#"<?php
function format_date($d) {}
"#;
let facts = PhpExtractor.extract(src, "src/helpers.php").unwrap();
let by_name = |n: &str| facts.symbols.iter().find(|s| s.name == n).cloned();
let format_date = by_name("format_date").unwrap();
assert_eq!(format_date.kind, SymbolKind::Function);
assert_eq!(
format_date.id.to_scip_string(),
"codegraph . . . helpers/format_date()."
);
}
#[test]
fn extracts_class_extends_and_implements() {
let src = "<?php\nclass Foo extends Bar implements Baz {}";
let facts = PhpExtractor.extract(src, "src/Foo.php").unwrap();
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"Bar"),
"expected 'Bar' in {inherit_names:?}"
);
assert!(
inherit_names.contains(&"Baz"),
"expected 'Baz' in {inherit_names:?}"
);
}
#[test]
fn extracts_interface_extends_reference() {
let src = "<?php\ninterface I extends J {}";
let facts = PhpExtractor.extract(src, "src/I.php").unwrap();
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"J"),
"expected 'J' in {inherit_names:?}"
);
}
#[test]
fn strips_namespace_from_parent_name() {
let src = r"<?php
class C extends \App\Base {}";
let facts = PhpExtractor.extract(src, "src/C.php").unwrap();
let inherit_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::IsImplementation)
.map(|r| r.name.as_str())
.collect();
assert!(
inherit_names.contains(&"Base"),
"expected 'Base' (leaf of \\App\\Base) in {inherit_names:?}"
);
}
#[test]
fn import_simple_use_statement() {
let src = "<?php\nuse App\\Models\\User;";
let facts = PhpExtractor.extract(src, "src/Foo.php").unwrap();
let import_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert!(
import_names.contains(&"User"),
"expected 'User' in {import_names:?}"
);
assert_eq!(import_names.len(), 1);
}
#[test]
fn import_aliased_use_statement_uses_real_name() {
let src = "<?php\nuse App\\Models\\User as U;";
let facts = PhpExtractor.extract(src, "src/Foo.php").unwrap();
let import_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert!(
import_names.contains(&"User"),
"expected 'User' (real name, not alias) in {import_names:?}"
);
assert!(
!import_names.contains(&"U"),
"alias 'U' must not appear in {import_names:?}"
);
assert_eq!(import_names.len(), 1);
}
#[test]
fn import_grouped_use_statement() {
let src = "<?php\nuse App\\Models\\{User, Post};";
let facts = PhpExtractor.extract(src, "src/Foo.php").unwrap();
let import_names: Vec<&str> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Import)
.map(|r| r.name.as_str())
.collect();
assert!(
import_names.contains(&"User"),
"expected 'User' in {import_names:?}"
);
assert!(
import_names.contains(&"Post"),
"expected 'Post' in {import_names:?}"
);
assert_eq!(import_names.len(), 2);
}
#[test]
fn extracts_call_references() {
let src = r#"<?php
function run() {
validate("t");
$obj->process($data);
}
"#;
let facts = PhpExtractor.extract(src, "src/main.php").unwrap();
let names: Vec<&str> = facts.references.iter().map(|r| r.name.as_str()).collect();
assert!(
names.contains(&"validate"),
"expected 'validate' in {names:?}"
);
assert!(
names.contains(&"process"),
"expected 'process' in {names:?}"
);
}
#[test]
fn func_params_emit_param_bindings() {
let src = "<?php\nfunction greet(string $name, int $age) {}\n";
let facts = PhpExtractor.extract(src, "src/greet.php").unwrap();
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope");
let mut param_names: Vec<(&str, ScopeId)> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Param)
.map(|b| (b.name.as_str(), b.scope))
.collect();
param_names.sort_by_key(|(n, _)| *n);
assert_eq!(
param_names,
vec![("age", fn_scope_id), ("name", fn_scope_id)],
"expected Param bindings for age and name, got {param_names:?}"
);
}
#[test]
fn assignment_local_in_function() {
let src = "<?php\nfunction f(): int { $r = 42; return $r; }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let r = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "r")
.expect("expected a Local binding for 'r'");
assert_eq!(
facts.scopes[r.scope].kind,
ScopeKind::Function,
"Local 'r' should be in a Function scope"
);
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Param && b.name == "r"),
"'r' must not be a Param binding"
);
}
#[test]
fn foreach_value_is_local() {
let src = "<?php\nfunction run(array $items) { foreach ($items as $item) {} }\n";
let facts = PhpExtractor.extract(src, "src/run.php").unwrap();
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Param && b.name == "items"),
"expected Param binding for 'items'"
);
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "item"),
"expected Local binding for 'item'"
);
}
#[test]
fn class_property_is_definition_not_local() {
let src = "<?php\nclass Foo { public string $bar; }\n";
let facts = PhpExtractor.extract(src, "src/Foo.php").unwrap();
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "bar"),
"class property 'bar' must NOT be a Local binding"
);
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Definition && b.name == "bar"),
"expected a Definition binding for 'bar'"
);
}
#[test]
fn nesting_class_method_produces_correct_scopes() {
let src = "<?php\nclass S { public function h() { $x = 1; } }\n";
let facts = PhpExtractor.extract(src, "src/S.php").unwrap();
assert_eq!(
facts.scopes[0].kind,
ScopeKind::Module,
"scopes[0] must be Module"
);
let type_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Type)
.expect("expected a Type scope for the class");
let fn_scope_id = facts
.scopes
.iter()
.position(|s| s.kind == ScopeKind::Function)
.expect("expected a Function scope for the method");
assert_eq!(
facts.scopes[type_scope_id].parent,
Some(0),
"Type scope parent must be Module (0)"
);
assert_eq!(
facts.scopes[fn_scope_id].parent,
Some(type_scope_id),
"Function scope parent must be the Type scope"
);
let x = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "x")
.expect("expected a Local binding for 'x'");
assert_eq!(
facts.scopes[x.scope].kind,
ScopeKind::Function,
"Local 'x' must be in a Function scope"
);
}
#[test]
fn namespace_body_type_scope_local_in_function() {
let src = "<?php\nnamespace App { function init() { $v = 1; } }\n";
let facts = PhpExtractor.extract(src, "src/App.php").unwrap();
let v = facts
.bindings
.iter()
.find(|b| b.kind == BindingKind::Local && b.name == "v")
.expect("expected a Local binding for 'v'");
assert_eq!(
facts.scopes[v.scope].kind,
ScopeKind::Function,
"Local 'v' must be in a Function scope, not the namespace Type scope"
);
}
#[test]
fn closure_and_arrow_params() {
let src = "<?php\nfunction f() { $g = function(int $x) { return $x; }; $h = fn(int $y) => $y; }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let params: Vec<&str> = facts
.bindings
.iter()
.filter(|b| b.kind == BindingKind::Param)
.map(|b| b.name.as_str())
.collect();
assert!(params.contains(&"x"), "expected Param 'x', got {params:?}");
assert!(params.contains(&"y"), "expected Param 'y', got {params:?}");
}
#[test]
fn constructor_promoted_param_is_param_not_local() {
let src = "<?php\nclass Box { public function __construct(public int $size) {} }\n";
let facts = PhpExtractor.extract(src, "src/Box.php").unwrap();
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Param && b.name == "size"),
"expected Param binding for 'size'"
);
assert!(
!facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Local && b.name == "size"),
"'size' must not be a Local binding"
);
}
#[test]
fn same_file_call_ref_has_non_zero_scope() {
let src = "<?php\nfunction helper(): int { return 0; }\nfunction run(): int { return helper(); }\n";
let facts = PhpExtractor.extract(src, "src/main.php").unwrap();
assert!(
facts
.bindings
.iter()
.any(|b| b.kind == BindingKind::Definition && b.name == "helper"),
"expected a Definition binding for 'helper'"
);
let helper_ref = facts
.references
.iter()
.find(|r| r.role == RefRole::Call && r.name == "helper")
.expect("expected a Call ref for 'helper'");
let scope_id = helper_ref
.scope
.expect("helper() Call ref must have a scope attached");
assert_ne!(
scope_id, 0,
"helper() Call ref scope must not be the module root"
);
}
#[test]
fn php_read_ref_at_use_not_at_declaration() {
let src = "<?php\nfunction f() { $base = 1; return $base; }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "base")
.collect();
assert!(
!read_refs.is_empty(),
"expected at least one Read ref for 'base', got none; all refs: {:?}",
facts
.references
.iter()
.map(|r| (&r.name, r.role))
.collect::<Vec<_>>()
);
let use_ref = read_refs.iter().find(|r| r.occ.byte > 20);
assert!(
use_ref.is_some(),
"expected a Read ref for 'base' at the return site (byte > 20); refs: {read_refs:?}"
);
}
#[test]
fn php_write_ref_for_assignment() {
let src = "<?php\nfunction f() { $cnt = 0; $cnt = 5; }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let write_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Write && r.name == "cnt")
.collect();
assert!(
!write_refs.is_empty(),
"expected at least one Write ref for 'cnt', got none; all refs: {:?}",
facts
.references
.iter()
.map(|r| (&r.name, r.role))
.collect::<Vec<_>>()
);
}
#[test]
fn php_function_call_not_a_read() {
let src = "<?php\nfunction f() { helper(); }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "helper")
.collect();
assert!(
read_refs.is_empty(),
"helper() must NOT produce a Read ref; got: {read_refs:?}"
);
let call_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Call && r.name == "helper")
.collect();
assert!(
!call_refs.is_empty(),
"expected a Call ref for 'helper'; all refs: {:?}",
facts
.references
.iter()
.map(|r| (&r.name, r.role))
.collect::<Vec<_>>()
);
}
#[test]
fn php_param_decl_not_a_read_but_use_is() {
let src = "<?php\nfunction f(int $val) { return $val; }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let read_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::Read && r.name == "val")
.collect();
assert!(
!read_refs.is_empty(),
"expected at least one Read ref for 'val' at the return site"
);
let decl_read = read_refs.iter().find(|r| r.occ.byte < 25);
assert!(
decl_read.is_none(),
"param declaration '$val' must NOT be a Read ref; found one at byte {:?}",
decl_read.map(|r| r.occ.byte)
);
}
#[test]
fn php_param_type_ref_emitted() {
let src = "<?php\nfunction f(Config $c) {}\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::ParameterType),
"expected ParameterType ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn php_return_type_ref_emitted() {
let src = "<?php\nfunction f(): Config { return x(); }\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::ReturnType),
"expected ReturnType ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn php_typed_property_type_ref_emitted() {
let src = "<?php\nclass C { public Config $conf; }\n";
let facts = PhpExtractor.extract(src, "src/C.php").unwrap();
let r = facts
.references
.iter()
.find(|r| r.role == RefRole::TypeRef && r.name == "Config")
.expect("expected TypeRef ref for 'Config'");
assert_eq!(
r.type_ref_ctx,
Some(TypeRefContext::Field),
"expected Field ctx, got {:?}",
r.type_ref_ctx
);
}
#[test]
fn php_primitive_param_type_not_emitted() {
let src = "<?php\nfunction f(int $n) {}\n";
let facts = PhpExtractor.extract(src, "src/f.php").unwrap();
let prim_refs: Vec<_> = facts
.references
.iter()
.filter(|r| r.role == RefRole::TypeRef && r.name == "int")
.collect();
assert!(
prim_refs.is_empty(),
"primitive 'int' must NOT produce a TypeRef; got: {prim_refs:?}"
);
}
#[test]
fn visibility_public_method_tagged_public() {
let src = "<?php\nclass C { public function foo() {} }\n";
let facts = PhpExtractor.extract(src, "src/C.php").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "foo")
.expect("expected symbol 'foo'");
assert_eq!(
sym.visibility,
Visibility::Public,
"public function must be tagged Public"
);
}
#[test]
fn visibility_private_method_emitted_and_tagged_private() {
let src = "<?php\nclass C { private function secret() {} }\n";
let facts = PhpExtractor.extract(src, "src/C.php").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "secret")
.expect("private method 'secret' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Private,
"private function must be tagged Private"
);
}
#[test]
fn visibility_protected_method_emitted_and_tagged_protected() {
let src = "<?php\nclass C { protected function hook() {} }\n";
let facts = PhpExtractor.extract(src, "src/C.php").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "hook")
.expect("protected method 'hook' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Protected,
"protected function must be tagged Protected"
);
}
#[test]
fn visibility_no_modifier_method_defaults_to_public() {
let src = "<?php\ninterface I { function read(); }\n";
let facts = PhpExtractor.extract(src, "src/I.php").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "read")
.expect("interface method 'read' must be emitted");
assert_eq!(
sym.visibility,
Visibility::Public,
"method without modifier must default to Public"
);
}
#[test]
fn visibility_all_modifiers_in_one_class() {
let src = r#"<?php
class Multi {
public function pub_fn() {}
protected function prot_fn() {}
private function priv_fn() {}
public $pub_prop;
protected $prot_prop;
private $priv_prop;
}
"#;
let facts = PhpExtractor.extract(src, "src/Multi.php").unwrap();
let by_name = |n: &str| facts.symbols.iter().find(|s| s.name == n).cloned();
assert_eq!(by_name("pub_fn").unwrap().visibility, Visibility::Public);
assert_eq!(
by_name("prot_fn").unwrap().visibility,
Visibility::Protected
);
assert_eq!(by_name("priv_fn").unwrap().visibility, Visibility::Private);
assert_eq!(by_name("pub_prop").unwrap().visibility, Visibility::Public);
assert_eq!(
by_name("prot_prop").unwrap().visibility,
Visibility::Protected
);
assert_eq!(
by_name("priv_prop").unwrap().visibility,
Visibility::Private
);
}
#[test]
fn visibility_top_level_function_is_public() {
let src = "<?php\nfunction helper() {}\n";
let facts = PhpExtractor.extract(src, "src/helpers.php").unwrap();
let sym = facts
.symbols
.iter()
.find(|s| s.name == "helper")
.expect("expected symbol 'helper'");
assert_eq!(
sym.visibility,
Visibility::Public,
"top-level function must be tagged Public"
);
}
}