fix(resolution): case-insensitive names pass the prefilter; PHP => fn() is a bare call (#2141)

- The name prefilter checks a case-SENSITIVE set of known names, so in PHP,
  Pascal, CFML, COBOL and VB.NET — whose identifiers ignore case — a call
  written in another case (`formatprice()` for `FormatPrice`, TestBox's
  `ToMatchWithCase()`) was dropped before any strategy ran. Those languages
  now also check a lowercase set (built lazily, only when asked).
- `isBarePhpCall` took any `>` before a name for `->`: every call after `=>`
  in a PHP array (`'total' => count($items)`, `'by' => user()->id`,
  `'label' => trans('…')`) was a member call, and found a same-named method
  — 166 wrong edges on BookStack (`trans` → LocaleDefinition::trans,
  `new Response` → ImageStorageDisk::response, `url()`, `config()`, `count()`).
  It now requires `->` / `?->` / `::`.
- A fuzzy `instantiates` never names a method.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 08:31:10 +00:00
committed by GitHub
parent a3d675ef4d
commit ca3bb444f7
4 changed files with 69 additions and 4 deletions
+2
View File
@@ -14,6 +14,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In PHP, CFML, Pascal, COBOL and VB.NET, a call written in a different case than its definition now resolves, like `formatprice()` for `FormatPrice` or CFML's `ToMatchWithCase()` for `toMatchWithCase`. These languages ignore case in names, but such calls used to be dropped before resolution.
- A PHP function call written after `=>` in an array, like `'count' => count($items)`, `'by' => user()->id` or `'label' => trans('…')`, is now read as a plain function call. It used to be taken for a method call and linked to whichever class had a method of that name. On BookStack, that meant 166 wrong links.
- Vue components written with the Options API (`export default { methods: {…}, computed: {…}, watch: {…}, mounted() {…} }`), as every Vue 2 app is, now have their methods, computed properties, watchers and lifecycle hooks in the graph. Each one has its own callers and calls, and a template's `@click="handleLogin"` links to the method it runs. On vue-element-admin that's over 800 new links, where a component's calls used to belong to the whole file.
- In Java, Kotlin, Swift, C#, C and C++, Go, Scala, Ruby, Dart and the other case-sensitive languages, a name no longer links to a symbol whose name differs only in case. On jsoup, JUnit's `@Test` had linked every test to a `test` method; `new CookieManager()` had linked to a `cookieManager()` getter, and a `Method` type to a `method()`. PHP, Pascal, CFML, COBOL and VB.NET, whose names really do ignore case, are unchanged.
- React Native events whose name is held in a constant now link native code to the JavaScript that listens for them, like NetInfo's `addListener(PrivateTypes.DEVICE_CONNECTIVITY_EVENT, …)` or a Java `.emit(PROGRESS_EVENT, …)`. The constant is read the way the language scopes it, from the same file, an import or its owning class, so a parameter that happens to share a constant's name is never mistaken for it.
+41
View File
@@ -66,4 +66,45 @@ public class ParserTest {
cg.close();
}
});
it('PHP still resolves a function whatever case the call is written in', async () => {
const cg = await project({
'src/helpers.php': `<?php
function FormatPrice($amount) { return $amount; }
`,
'src/view.php': `<?php
function render() { return formatprice(10); }
`,
});
try {
expect(targetsFrom(cg, 'src/view.php')).toContain('function:FormatPrice');
} finally {
cg.close();
}
});
it('PHP: a call written after `=>` in an array is a function call, not some class’s method', async () => {
const cg = await project({
'app/CommentTree.php': `<?php
class CommentTree {
public function count(): int { return 0; }
}
`,
'app/helpers.php': `<?php
function user() { return null; }
`,
'app/Controller.php': `<?php
function summary($items) {
return ['total' => count($items), 'by' => user()];
}
`,
});
try {
const targets = targetsFrom(cg, 'app/Controller.php');
expect(targets).not.toContain('method:count');
expect(targets).toContain('function:user');
} finally {
cg.close();
}
});
});
+20 -1
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope } from './name-matcher';
import { matchJsStoreBindingCall, isUnresolvedJsMemberCall, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { gateSwiftTypeTarget, clearSwiftTypeVisibility, swiftExtendedConformances } from './swift-type-visibility';
@@ -445,6 +445,7 @@ export class ReferenceResolver {
this.fileExistsMemo.clear();
this.manifestScopes.clear();
this.knownNames = null;
this.knownLowerNames = null;
this.knownFiles = null;
this.cachesWarmed = false;
// The import-resolver's and name-matcher's per-context memos assume the
@@ -884,6 +885,21 @@ export class ReferenceResolver {
};
}
/** Lowercased `knownNames`, built the first time a case-insensitive language asks. */
private knownLowerNames: Set<string> | null = null;
/** `hasAnyPossibleMatch` for a language whose names ignore case: the name, or any `.`/`::`/`->` part of it. */
private hasAnyPossibleMatchIgnoringCase(name: string): boolean {
if (!this.knownNames) return true;
if (!this.knownLowerNames || this.knownLowerNames.size === 0) {
this.knownLowerNames = new Set();
for (const known of this.knownNames) this.knownLowerNames.add(known.toLowerCase());
}
const lower = name.toLowerCase();
if (this.knownLowerNames.has(lower)) return true;
return lower.split(/::|->|\./).some((part) => part.length > 0 && this.knownLowerNames!.has(part));
}
/**
* Check if a reference name has any possible match in the codebase.
* Uses the pre-built knownNames set to skip expensive resolution
@@ -1070,6 +1086,9 @@ export class ReferenceResolver {
const preFilterPass =
isNixPathImportRef(ref) ||
this.hasAnyPossibleMatch(existenceName) ||
// PHP, Pascal, CFML, COBOL and VB.NET names ignore case: `formatprice()`
// calls `FormatPrice`, which the exact-name set never lists.
(CASE_INSENSITIVE_LANGUAGES.has(ref.language) && this.hasAnyPossibleMatchIgnoringCase(existenceName)) ||
this.matchesAnyImport(ref) ||
this.frameworks.some((f) => f.claimsReference?.(ref.referenceName));
if (this.profileStages) this.stageAdd('preFilter', ref, preFilterPass, tPre);
+6 -3
View File
@@ -944,7 +944,7 @@ const JS_FAMILY = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx', 'vu
const JS_TS = new Set<string>(['typescript', 'tsx', 'javascript', 'jsx']);
/** Languages whose identifiers resolve regardless of case. */
const CASE_INSENSITIVE_LANGUAGES = new Set<string>(['php', 'pascal', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet']);
export const CASE_INSENSITIVE_LANGUAGES = new Set<string>(['php', 'pascal', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet']);
/**
* Whether a JS/TS `calls` ref is a RECEIVER-LESS call — `serialize(x)`, not
@@ -999,8 +999,9 @@ function isBarePhpCall(ref: UnresolvedRef, context: ResolutionContext): boolean
if (!CALL_OPENER.test(line)) return false;
let end = at;
while (end > 0 && WHITESPACE.test(line[end - 1]!)) end--;
// `$obj->name(` / `Foo::name(` / `$obj?->name(`, should a column ever land on the name.
return !(end > 0 && (line[end - 1] === '>' || line[end - 1] === ':'));
// `$obj->name(` / `Foo::name(` / `$obj?->name(`, should a column ever land on
// the name — but not `'size' => filesize($zip)` or `$x ? a : name()`.
return !(end > 1 && ((line[end - 1] === '>' && line[end - 2] === '-') || (line[end - 1] === ':' && line[end - 2] === ':')));
}
function isReceiverLessCall(ref: UnresolvedRef, context: ResolutionContext): boolean {
@@ -4611,6 +4612,8 @@ export function matchFuzzy(
// resolve a name without regard to case, which is what this fallback's
// lowercase index is for.
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) &&
// `new …MockData()` makes an instance of a type; a method is never what it names.
!(ref.referenceKind === 'instantiates' && n.kind === 'method') &&
!(rustBare && !isRustNameInScope(n, ref, context)) &&
!(!CASE_INSENSITIVE_LANGUAGES.has(ref.language) && n.name !== ref.referenceName) &&
!(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) &&