From ca1eba98bd52de3fdb321660f10006efd74f513c Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 00:27:15 +0000 Subject: [PATCH] fix(resolution): a bare-recorded call on another expression's value is not the caller (#2223) Generalizes the collapsed-chain self veto beyond TS/JS this./window. roots: a call the extractor recorded by its bare name but the source writes as a member call on anything other than this/self/super is never the calling method; and a value's initializer never calls the value itself. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/self-call-delegation.test.ts | 43 ++++++++++++++++++--- src/resolution/name-matcher.ts | 52 ++++++++++++++++---------- 3 files changed, 70 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e97b338..7108327cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A method that calls a same-named method on another expression's result is no longer linked to itself in any language. Examples are Scala's `requestToArmeria(request).execute()` inside `execute()`, Rust's `self.0.into_route(state)` inside `into_route()`, and Kotlin's `this@Buffer.write(…)` from an inner object. A value whose initializer chain calls a same-named method, like sttp's `val response = basicRequest.get(…).response(…)`, no longer links to itself either. - In Spring projects, a class name now resolves to the one in the reference's own file or package before any guess by folder convention. A class nested inside another file's class is never reached by its bare name. Before, every MyBatis `Example` class's `new Criteria()` in mall linked to the first `Example`'s nested `Criteria`, and halo's Lombok `@Builder` linked to a nested `Builder` class elsewhere. - A method that hands its call on to another object is no longer linked to itself. In TypeScript and JavaScript, a call through `this.` or `window.` counts as recursion only when the field is declared as the method's own class. A guess from a receiver's name alone never lands on the calling method. Before, BookStack's `toggle()` doing `this.container.classList.toggle('open')`, `listen()` doing `window.$events.listen(…)`, and `FileStorage::delete` doing `$storage->delete($path)` each pointed at themselves. - C# names now follow the language's scopes. A block `namespace X { … }` qualifies only the types inside it, so a type declared after the block, or in a file's second namespace, is no longer filed under the first. A namespace written inside another is `Outer.Inner`. A `using` links to the project's namespace instead of another file's `using` of the same name. A `global using` applies only within its own project. A nested type is reachable by bare name only from inside its owner or a type deriving from it, including through another partial part. Before, serilog's `Guard` was filed under `JetBrains.Annotations`, its tests' `Some.InformationEvent()` went to the performance tests' `Some`, and AutoMapper's same-file `new Source()` could reach another test class's nested `Source`. diff --git a/__tests__/self-call-delegation.test.ts b/__tests__/self-call-delegation.test.ts index d23bcae87..40e2a617a 100644 --- a/__tests__/self-call-delegation.test.ts +++ b/__tests__/self-call-delegation.test.ts @@ -1,11 +1,13 @@ /** * A method that hands its call on to another object — a wrapper — is not - * calling itself. TS/JS calls through `this.…` or `window.…` reach - * the resolver by their bare name, so BookStack's `toggle()` doing - * `this.container.classList.toggle('open')` and `listen()` doing - * `window.$events.listen(…)` bound to themselves; a guess from a receiver's - * name alone (`FileStorage::delete` doing `$storage->delete($path)`) did too. - * A recursion through a field the class declares as its own type stays. + * calling itself. Calls through `this.…` / `window.…` (TS/JS) or + * through an expression's value (Scala `requestToArmeria(request).execute()`, + * Rust `self.0.into_route(state)`) reach the resolver by their bare name, so + * BookStack's `toggle()` doing `this.container.classList.toggle('open')` + * bound to itself; a guess from a receiver's name alone (`FileStorage::delete` + * doing `$storage->delete($path)`) did too, and a value's initializer chain + * (`val response = basicRequest.get(…).response(…)`) to the value. A recursion + * through a field the class declares as its own type stays. */ import { describe, it, expect, afterAll, beforeAll } from 'vitest'; import * as fs from 'fs'; @@ -64,6 +66,30 @@ class ImageStorage { } } +`, + 'core/src/main/scala/sttp/ArmeriaBackend.scala': `package sttp + +class ArmeriaBackend { + def requestToArmeria(request: String): Client = new Client + + def execute(request: String): Unit = { + val armeriaRes = requestToArmeria(request).execute() + } +} + +class CurlTest { + val response = basicRequest + .get("http://example.com") + .response(asString) +} +`, + 'src/routing/route.rs': `pub struct BoxedIntoRoute(Box); + +impl BoxedIntoRoute { + pub fn into_route(self, state: u32) -> u32 { + self.0.into_route(state) + } +} `, }; for (const [rel, content] of Object.entries(files)) { @@ -93,6 +119,11 @@ describe('a call a method hands on', () => { expect(selfCallLines('resources/js/tree.ts')).toEqual([5]); }); + it('through another expression’s value is not the method itself', () => { + expect(selfCallLines('core/src/main/scala/sttp/ArmeriaBackend.scala')).toEqual([]); + expect(selfCallLines('src/routing/route.rs')).toEqual([]); + }); + it('through a receiver named like the caller’s class is not the method itself', () => { expect(selfCallLines('app/Uploads/FileStorage.php')).toEqual([]); }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e12f46169..640a2beeb 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1403,31 +1403,35 @@ export const CASE_INSENSITIVE_LANGUAGES = new Set(['php', 'pascal', 'cfm * used to pick over the module-scope function the call actually means. */ /** - * The links between `this` / `super` / `window` and the method of a TS/JS - * call the extractor recorded by its bare name — `this.container.classList - * .toggle()` reaches the resolver as `toggle`, with links `container`, - * `classList`. Null for a call not written that way, `this.toggle()` included. + * The receiver a call the extractor recorded by its bare name is written on, + * read from the source: TS/JS keeps `this.container.classList.toggle()` and + * `window.$events.listen()` bare, Scala `requestToArmeria(request).execute()` + * and `_.get.whenRequestMatchesPartial(…)`. `'self'` for `this.m()` / + * `self.m()` / `super.m()` / `super().m()`; null for a call written bare (or + * not found). `links` are the member names between `this` and the method. */ -function collapsedJsChain(ref: UnresolvedRef, context: ResolutionContext): { root: string; links: string[] } | null { - if (!JS_FAMILY.has(ref.language) || ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null; +function bareCallReceiver(ref: UnresolvedRef, context: ResolutionContext): { receiver: string; links: string[] } | null { + if (ref.referenceKind !== 'calls' || !/^[A-Za-z_$][\w$]*$/.test(ref.referenceName)) return null; const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); if (!lines) return null; - const text = lines.slice(ref.line - 1, ref.line + 3).join('\n').slice(ref.column); + const text = lines.slice(ref.line - 1, ref.line + 7).join('\n').slice(Math.max(0, ref.column)); const name = ref.referenceName.replace(/\$/g, '\\$'); - const m = new RegExp(`^(this|super|window)((?:\\s*\\??\\.\\s*#?[\\w$]+(?:\\([^()]*\\))?)+?)\\s*\\??\\.\\s*${name}\\s*(?:<[^<>()]*>)?\\s*\\(`).exec(text); - if (!m) return null; - return { root: m[1]!, links: m[2]!.split('.').map((l) => l.replace(/[\s?]/g, '')).filter((l) => l !== '') }; + const at = new RegExp(`(?()]*>|\\[(?:[^\\[\\]]|\\[[^\\[\\]]*\\])*\\])?\\s*[({]`).exec(text); + if (!at) return null; + const before = text.slice(0, at.index).replace(/\s+$/, ''); + if (!/\??\.$/.test(before)) return null; + const head = before.replace(/\??\.$/, '').replace(/\s+$/, ''); + if (/(?:^|[^\w$.])(?:this|self|super|Self)$/.test(head) || /(?:^|[^\w$.])super\s*\([^()]*\)$/.test(head)) return { receiver: 'self', links: [] }; + const chain = /(?:^|[^\w$.#])((?:this|super)(?:\s*\??\.\s*#?[\w$]+)+)$/.exec(head); + const links = chain ? chain[1]!.split('.').slice(1).map((l) => l.replace(/[\s?]/g, '')) : []; + return { receiver: head.slice(-40), links }; } -/** - * Whether a collapsed `this..m()` inside `m` can be `m` itself: only - * when the class declares the field as its own type — a tree node's - * `this.left.insert(v)` — never through a field of another type - * (`this.editor.input.focus()`, `this.container.classList.toggle()`). - */ +/** Whether a call recorded by its bare name is written on something other than the caller's own object. */ function isCollapsedNonRecursion(ref: UnresolvedRef, context: ResolutionContext): boolean { - const chain = collapsedJsChain(ref, context); - return chain !== null && !isCollapsedSelfRecursion(chain, ref, context); + const written = bareCallReceiver(ref, context); + if (!written || written.receiver === 'self') return false; + return !(JS_FAMILY.has(ref.language) && isCollapsedSelfRecursion({ root: 'this', links: written.links }, ref, context)); } function isCollapsedSelfRecursion(chain: { root: string; links: string[] }, ref: UnresolvedRef, context: ResolutionContext): boolean { @@ -8379,12 +8383,20 @@ export function matchReference( ): ResolvedRef | null { const result = gateLanguageMatch(matchReferenceInner(ref, context), ref, context); // `this.container.classList.toggle()` inside `toggle()`, `window.$events - // .listen()` inside `listen()`: a member of what the chain reaches, which is - // the calling method only through a field of the caller's own type. + // .listen()` inside `listen()`, Scala's `requestToArmeria(request).execute()` + // inside `execute()`: a member of what the receiver is, which is the calling + // method only through a TS/JS field of the caller's own type. if (result && result.targetNodeId === ref.fromNodeId && isCollapsedNonRecursion(ref, context)) return null; + // Nor does a value's initializer call the value: sttp's `val response = + // basicRequest.get(…).response(asStringAlways)` is a request's `response`. + if (result && result.targetNodeId === ref.fromNodeId && ref.referenceKind === 'calls' && + VALUE_KINDS.has(context.getNodeById?.(ref.fromNodeId)?.kind ?? '')) return null; return result ? retargetSelfOverload(result, ref, context) : result; } +/** Node kinds that hold a value rather than run code. */ +const VALUE_KINDS: ReadonlySet = new Set(['variable', 'constant', 'field', 'property']); + /** Languages whose methods overload by arity. */ const OVERLOADING_LANGUAGES: ReadonlySet = new Set(['csharp', 'java', 'kotlin', 'swift', 'cpp', 'scala', 'dart', 'vbnet']);