Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions __tests__/kernel-php-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
* path), interface multi-extends first-only drop, the call-encoding zoo
* (`this->prop.m`, DOT-joined scoped calls, `Cls::factory().m` fluent,
* nullsafe `?->` nothing, literal receivers kept), instantiation shapes
* (qualified verbatim, `new static/self/parent` literal, `$cls`, the
* anonymous-class garbage ref + file-level-function methods), static value
* reads, php type refs, HOF string/array callables, value-ref targets
* (qualified reduced to its trailing name, `new static/self/parent`
* literal, `$cls`, the anonymous-class garbage ref + file-level-function
* methods), static value reads including namespaced `Foo\Bar::class`
* receivers, php type refs, HOF string/array callables, value-ref targets
* (namespaced top-level consts DROPPED), heredoc/nowdoc/interpolation,
* attributes shifting node lines without emitting.
* - TortureModule.module — drupal extension routing + un-namespaced
Expand Down
111 changes: 111 additions & 0 deletions __tests__/php-qualified-static-member-refs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* PHP namespaced receivers in static-member and constructor position.
*
* `Foo\Bar::class` and `Foo\Bar::CONST` reach extractStaticMemberRef as a
* `class_constant_access_expression` whose receiver is a `qualified_name` —
* the node kind PHP uses for EVERY namespaced name, whether it came from an
* import alias (`use App\SoapTypes as Type;` → `Type\Bankverbindung::class`),
* a fully-qualified path (`\App\SoapTypes\Bankverbindung::class`) or a
* namespace-relative one (`SoapTypes\Bankverbindung::class`). A bare
* `Bankverbindung::class` is a `name` and was always handled; the qualified
* forms produced no edge AND no unresolved ref, so a class referenced only
* that way looked like nothing depended on it.
*
* `new Foo\Bar()` is the same gap one function over: extractInstantiation
* strips a `.` or `::` qualifier but not PHP's `\`, so the ref was pushed as
* the unresolvable literal `Foo\Bar`.
*
* Both now match on the trailing simple name, which is what walkPhpTypePosition
* already does for type hints and what the class node is stored as. That makes
* the alias moot rather than resolved — `Type\Bankverbindung` and
* `\App\SoapTypes\Bankverbindung` both reduce to `Bankverbindung` — so a
* same-named class in another namespace stays ambiguous here, exactly as it is
* for a type hint.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { CodeGraph } from '../src';

describe('PHP qualified static-member and constructor refs', () => {
let dir: string;
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'php-qual-recv-')); });
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });

const write = (rel: string, body: string) => {
const p = path.join(dir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, body);
};

/** Every non-`contains` edge as `<kind> <source-name> -> <target-name>`. */
const load = async (): Promise<string[]> => {
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const rows: { kind: string; src: string; tgt: string }[] = db
.prepare(
`SELECT e.kind kind, s.name src, t.name tgt
FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
WHERE e.kind IN ('references', 'instantiates')`,
)
.all();
cg.close?.();
return rows.map((r) => `${r.kind} ${r.src} -> ${r.tgt}`);
};

const types = `<?php
namespace Vendor\\SoapTypes;
class Bankverbindung { public $iban; }
`;

const consumer = (body: string) => `<?php
namespace Vendor\\App;

use Vendor\\SoapTypes as Type;

class Consumer {
${body}
}
`;

it('binds an aliased qualified receiver in ::class position', async () => {
// Mutation: drop the `qualified_name` branch in extractStaticMemberRef.
write('src/Types.php', types);
write('src/Consumer.php', consumer(' public function aliased() { return Type\\Bankverbindung::class; }'));
expect(await load()).toContain('references aliased -> Bankverbindung');
});

it('binds a fully-qualified receiver in ::class position', async () => {
// Mutation: as above — a leading `\` is the same qualified_name node.
write('src/Types.php', types);
write('src/Consumer.php', consumer(' public function fq() { return \\Vendor\\SoapTypes\\Bankverbindung::class; }'));
expect(await load()).toContain('references fq -> Bankverbindung');
});

it('binds a namespace-relative receiver in ::class position', async () => {
// Mutation: as above — no import needed for the branch to fire.
write('src/Types.php', types);
write('src/Consumer.php', consumer(' public function rel() { return SoapTypes\\Bankverbindung::class; }'));
expect(await load()).toContain('references rel -> Bankverbindung');
});

it('binds a qualified constructor', async () => {
// Mutation: remove `className.lastIndexOf('\\')` from extractInstantiation's
// qualifier strip — the ref is then pushed as the literal `Type\Bankverbindung`
// and resolves to nothing.
write('src/Types.php', types);
write('src/Consumer.php', consumer(' public function make() { return new Type\\Bankverbindung(); }'));
expect(await load()).toContain('instantiates make -> Bankverbindung');
});

it('leaves a lowercase-headed qualified receiver alone', async () => {
// Mutation: drop the /^[A-Z]/ test in the new branch. `$conn::TIMEOUT` on a
// namespaced variable is not a type reference, and emitting one would let
// bare-name matching bind it to an unrelated same-named symbol.
write('src/Types.php', types);
write('src/Consumer.php', consumer(' public function low() { return config\\bankverbindung::TIMEOUT; }'));
expect(await load()).not.toContain('references low -> Bankverbindung');
});
});
20 changes: 16 additions & 4 deletions codegraph-kernel/src/php.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,17 @@ impl<'t> Walker<'t> {
.or_else(|| node.child_by_field_name("scope"))
.or_else(|| node.named_child(0));
let Some(recv) = recv else { return };
// A namespaced receiver is a `qualified_name` — `Foo\Bar::class`,
// `\App\Models\User::TABLE`, and the alias form after `use X as Type;`.
// Match on the trailing simple name, as the php type-position walk
// already does: that is what the class node is stored as.
if recv.kind() == "qualified_name" {
let last = self.text(recv).rsplit('\\').next().unwrap_or("");
if capitalized_re().is_match(last) {
self.push_ref_at(owner, &last.to_string(), edge_kind_index("references").unwrap(), recv);
}
return;
}
if matches!(
recv.kind(),
"identifier" | "type_identifier" | "simple_identifier" | "name" | "scoped_type_identifier"
Expand Down Expand Up @@ -1504,9 +1515,9 @@ fn find_anonymous_class_body(node: Node) -> Option<Node> {
}

/// The shared `new ns.Foo<T>()` normalization: strip `<...` from the first
/// `<` (index > 0), keep the segment after the last `.`/`::`, strip ONE
/// leading `:` or `.`, trim. Backslashes are NOT handled — php qualified
/// names pass through whole.
/// `<` (index > 0), keep the segment after the last `.`/`::`/`\`, strip ONE
/// leading `:` or `.`, trim. The backslash is php's own separator, so
/// `new \App\Models\User()` reduces to the name the class node carries.
fn strip_generic_and_qualifier(raw: &str) -> String {
let mut name = raw.to_string();
if let Some(lt) = name.find('<') {
Expand All @@ -1518,7 +1529,8 @@ fn strip_generic_and_qualifier(raw: &str) -> String {
.rfind('.')
.map(|i| i as isize)
.unwrap_or(-1)
.max(name.rfind("::").map(|i| i as isize).unwrap_or(-1));
.max(name.rfind("::").map(|i| i as isize).unwrap_or(-1))
.max(name.rfind('\\').map(|i| i as isize).unwrap_or(-1));
if last_dot >= 0 {
name = name[(last_dot as usize + 1)..].to_string();
if name.starts_with(':') || name.starts_with('.') {
Expand Down
18 changes: 16 additions & 2 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4876,10 +4876,13 @@ export class TreeSitterExtractor {
}
// For namespaced/qualified constructors (`new ns.Foo()`,
// `new ns::Foo()`) keep the trailing identifier — that's what
// matches a class node in the index.
// matches a class node in the index. PHP spells the separator `\`
// (`new \App\Models\User()`), so it belongs in the same strip; scoped to
// php because a backslash carries no qualifier meaning in the others.
const lastDot = Math.max(
className.lastIndexOf('.'),
className.lastIndexOf('::')
className.lastIndexOf('::'),
this.language === 'php' ? className.lastIndexOf('\\') : -1
);
if (lastDot >= 0) className = className.slice(lastDot + 1).replace(/^[:.]/, '');
className = className.trim();
Expand Down Expand Up @@ -4987,6 +4990,17 @@ export class TreeSitterExtractor {
node.namedChild(0);
if (!recv) return;
const t = recv.type;
// PHP writes any namespaced receiver as a `qualified_name` — `Foo\Bar::class`,
// `\App\Models\User::TABLE`, and the alias form `Type\Bankverbindung::class`
// after `use App\SoapTypes as Type;`. Without this branch every one of them is
// dropped with no unresolved ref to show for it. Match on the trailing simple
// name, as walkPhpTypePosition already does — that is what the class node is
// stored as, and what a `use` import brings into scope.
if (this.language === 'php' && t === 'qualified_name') {
const last = getNodeText(recv, this.source).split('\\').pop() ?? '';
if (/^[A-Z][A-Za-z0-9_]*$/.test(last)) this.pushStaticMemberRef(last, ownerId, recv);
return;
}
if (
t === 'identifier' || t === 'type_identifier' || t === 'simple_identifier' ||
t === 'name' || t === 'scoped_type_identifier'
Expand Down