diff --git a/CHANGELOG.md b/CHANGELOG.md index 766144819..14c4b330a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/fuzzy-case-sensitivity.test.ts b/__tests__/fuzzy-case-sensitivity.test.ts index fcfd301d4..eaeefc65b 100644 --- a/__tests__/fuzzy-case-sensitivity.test.ts +++ b/__tests__/fuzzy-case-sensitivity.test.ts @@ -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': `` in an array is a function call, not some class’s method', async () => { + const cg = await project({ + 'app/CommentTree.php': ` 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(); + } + }); }); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 1a9906d81..f11a525d1 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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 | 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); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 69cda553a..ad9f6d587 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -944,7 +944,7 @@ const JS_FAMILY = new Set(['typescript', 'tsx', 'javascript', 'jsx', 'vu const JS_TS = new Set(['typescript', 'tsx', 'javascript', 'jsx']); /** Languages whose identifiers resolve regardless of case. */ -const CASE_INSENSITIVE_LANGUAGES = new Set(['php', 'pascal', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet']); +export const CASE_INSENSITIVE_LANGUAGES = new Set(['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)) &&