From 52a459976cf4184cbbfec20bb934d9cd5ba1dea3 Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Fri, 23 Oct 2026 20:25:28 -0500 Subject: [PATCH] fix(java): a call resolves on its receiver's declared type, and a library type gets no guess Java typed a call receiver from a local declaration or a plain field of the enclosing class only. A field of an outer class read inside an inner or anonymous class (mHandler.post(...) inside new Runnable() {...}), Outer.this.f, a chain of fields (this.mOwner.mRepo.save()) and a local whose type has type arguments fell through to the name-only strategies, and so did every receiver whose declared type is a library class: parcel.readInt() on an android.os.Parcel went to a project VersionedParcel.readInt, list.size() to any project size(). The declared type is now read for each of those shapes, a chain one hop at a time with each hop's own imports, keeping a written package or outer class (Map.Entry, play.api.mvc.BodyParser). A receiver whose declared type the project does not declare, or that an import or a written package pins outside the project, gets no edge. When no import decides between same-named types, a Java call prefers the Java declaration over a Scala or Kotlin one. Also, from review: a field the class around the call declares is read as written before the member reader, which keeps only the last name (PauseTest.Suite, a field written with its package); a local or loop variable keeps the package its declaration writes; a field typed by a type parameter goes to its bound, and a class the class head names as a type argument (Comparable) is not taken for one; a nested type a superclass declares, or one an on-demand import of its class names (import play.mvc.Http.*), counts as named; and a typed miss on a type no project class or interface is named (a URI, an enum's name()) gets no guess, as on main. Validation: - java-receiver-type.test.ts: 19 tests, the PR's 5 red on main; the other 14 pass on main, and our 8 each fail without their change. - A/B against main 25776014 (with #2942 and #2950; the same sites change against 7cceba52), sites added/removed/retargeted: jextract +2/-1/106, spring-modulith +1/-3/2, Minestom +52/-91/71, SystemUI +53/-319/96, spring-ai +69/-659/532, kestra-java +5/-504/162, playframework +0/-196/599; click and gin identical. Of the removed edges main had at 0.9, 1,004 were wrong (an import oracle plus 16 read by hand) and 4 right (a lambda parameter, two nested types of a Scala object, a field read under a static import of a same-named member). Retargets land on the declared type or the supertype declaring the method, except about 16 build()/clone() calls on spring-ai's nested option builders, which move from one wrong target to another. - Cost (3 interleaved rounds): spring-ai wall 27.9 s on main vs 25.9 s, CPU 90.2 s vs 88.1 s. SystemUI read 71.3 vs 79.4 s wall under other jobs' load; CPU profiles of both arms put the new code at about 0.6 s of 115 s. Original PR #1942 by @danusha2345. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/java-receiver-type.test.ts | 523 +++++++++++++++++++++++++++ src/resolution/name-matcher.ts | 384 +++++++++++++++++++- 3 files changed, 889 insertions(+), 19 deletions(-) create mode 100644 __tests__/java-receiver-type.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e62c965aa..6c3d20477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -251,6 +251,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Java and TypeScript, a class that implements or extends a parameterized type, like `implements Store`, `extends Base`, an interface's `extends Store` or TypeScript's `implements Store`, is now linked to that type, so its methods get their interface-to-implementation links and `codegraph_explore` follows a call on the interface into the class. Before, such a supertype was read with its type arguments and matched nothing. Re-index Java and TypeScript projects after upgrading. Thanks @danusha2345, and @paiyahooJay for the earlier pull request. (#2705) - In Java and C#, a method called on a newly constructed object, like `new SqlSessionFactoryBuilder().build(reader)` or `new JsonSerializer().Serialize(writer, value)`, now links to that class's own or inherited method. When several packages or namespaces declare a class of that name, it links to the one the file imports, and to none when the file could mean either. Re-index Java and C# projects after upgrading. - In Java, a `record` is now indexed as a class, with its methods, its constructors, a field for each component and the accessor each component gets, like `info.remoteAddress()`. Before, a record had no symbol of its own and its methods were filed under the package or the class around it, so a call on a record-typed value linked to nothing or to the wrong method, and records were missing from callers, impact and implementations. A call like `Circle.unit()` on a type that a sealed interface's `permits` list names first now links too. Re-index Java projects after upgrading. Thanks @danusha2345. +- In Java, a call now resolves on the type its receiver is declared with in more cases: a field of an outer class read inside an inner or anonymous class, `Outer.this.field`, a chain of fields like `this.owner.repo.save()`, a local declared with type arguments, a for-each variable, a variable declared with its package, like `org.app.Message message`, and a nested class that a parent class declares. When that type comes from a library, like Android's `Parcel`, `Handler` or `Notification.Builder`, or `java.util.List`, the call no longer links to a same-named method of your own. A type the file imports from a library (`import java.util.Iterator`) or as a nested class (`import p.Http.Cookie`) is no longer mistaken for a same-named type elsewhere in the project, and a Java type wins over a same-named Scala one. Re-index Java projects after upgrading. Thanks @danusha2345. - In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805) - In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) - In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) diff --git a/__tests__/java-receiver-type.test.ts b/__tests__/java-receiver-type.test.ts new file mode 100644 index 000000000..06a6dadf2 --- /dev/null +++ b/__tests__/java-receiver-type.test.ts @@ -0,0 +1,523 @@ +/** + * A Java call through a field or a local resolves on the receiver's declared + * type, and a receiver of a library type gets no edge. + * + * The field lookup only read the tightest enclosing class and only a plain + * name, so `this.mRepo.save()`, `Outer.this.mRepo.save()`, a field read from + * inside an anonymous class and a field chain `mOwner.repo.save()` were all + * left to name-only guessing. And when the declared type WAS known but was a + * library class (`Parcel`, `Handler`, `List`), the call fell through to the + * same guessing, which bound it to whichever project class declares a method + * of that name (`parcel.readInt()` → a project `VersionedParcel.readInt`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('Java receivers resolve on the declared type', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-recv-')); + const src = path.join(dir, 'src', 'p'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync(path.join(src, 'Repo.java'), `package p; +public class Repo { + public void save() {} + public void close() {} +} +`); + fs.writeFileSync(path.join(src, 'Owner.java'), `package p; +public class Owner { + public Repo repo = new Repo(); + public android.content.Context ctx; +} +`); + fs.writeFileSync(path.join(src, 'Hook.java'), `package p; +public abstract class Hook { + public abstract void fire(); +} +`); + fs.writeFileSync(path.join(src, 'Box.java'), `package p; +public class Box { + public void seal() {} +} +`); + // Same-named methods on unrelated project classes: what a name-only + // guess lands on. The decoy sits in the callers' own file below too. + fs.writeFileSync(path.join(src, 'VersionedParcel.java'), `package p; +public class VersionedParcel { + public int readInt() { return 0; } + public void writeInt(int v) {} +} +`); + // Project types nested in other packages that share a simple name with + // a JDK type the callers use. + fs.mkdirSync(path.join(dir, 'src', 'q')); + fs.writeFileSync(path.join(dir, 'src', 'q', 'Bytes.java'), `package q; +public class Bytes { + public static class Iterator { public boolean hasNext() { return false; } } +} +`); + fs.writeFileSync(path.join(dir, 'src', 'q', 'Table.java'), `package q; +public class Table { + public static class Entry { public Object getKey() { return null; } } +} +`); + // A Scala type of the same simple name as a Java one, reached from a + // Java file through a wildcard import. + fs.writeFileSync(path.join(src, 'Form.java'), `package p; +public class Form { + public T get() { return null; } +} +`); + fs.mkdirSync(path.join(dir, 'src', 'a')); + fs.writeFileSync(path.join(dir, 'src', 'a', 'Form.scala'), `package api +class Form(value: Int) { + def get(): Int = value +} +`); + // A Scala trait in a file not named after it, imported explicitly. + fs.mkdirSync(path.join(dir, 'src', 'api', 'mvc'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src', 'api', 'mvc', 'BodyParsers.scala'), `package api.mvc +trait PlayBodyParsers { + def raw(): Int +} +`); + // Same simple names in another package, indexed first: what an unpinned + // lookup would land on. + fs.mkdirSync(path.join(dir, 'src', 'b')); + fs.writeFileSync(path.join(dir, 'src', 'b', 'Cookie.java'), `package b; +public class Cookie { public String value() { return null; } } +`); + fs.writeFileSync(path.join(dir, 'src', 'b', 'PlayBodyParsers.java'), `package b; +public class PlayBodyParsers { public int raw() { return 0; } } +`); + fs.writeFileSync(path.join(src, 'Http.java'), `package p; +public class Http { + public static class Cookie { public String value() { return null; } } +} +`); + fs.mkdirSync(path.join(dir, 'src', 'r')); + fs.writeFileSync(path.join(dir, 'src', 'r', 'Controller.java'), `package r; +import api.mvc.PlayBodyParsers; +import p.*; +import p.Http.Cookie; +public class Controller { + void submit(Form form) { form.get(); } + void parse(PlayBodyParsers parsers) { parsers.raw(); } + void written(api.mvc.PlayBodyParsers other) { other.raw(); } + void writtenLocal() { + api.mvc.PlayBodyParsers local = null; + local.raw(); + } + void cookie(Cookie crumb) { crumb.value(); } +} +`); + fs.writeFileSync(path.join(src, 'Screen.java'), `package p; + +import android.os.Handler; +import android.os.Parcel; +import android.util.Log; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import q.Bytes; + +class Decoy { + void save() {} + void post(Runnable r) {} + void add(Object o) {} + void put(Object k, Object v) {} + void d(String t, String m) {} + void getResources() {} + void seal() {} +} +// Decoys whose names share words with the receivers, so a receiver-name +// guess prefers them over the declared type. +class StoreCache { void save() {} void close() {} } +class SharedCache { void close() {} } +class OwnerRepoCache { void save() {} } +class BoxCache { void seal() {} } + +public class Screen { + private Repo mStore; + private Handler mHandler; + private Owner mOwner; + private static final Repo sShared = new Repo(); + + void viaField() { mStore.save(); } + void viaThis() { this.mStore.save(); } + void viaConstant() { sShared.close(); } + void viaChain() { mOwner.repo.save(); } + void viaThisChain() { this.mOwner.repo.save(); } + void viaLocalChain(Owner owner) { owner.repo.save(); } + void libraryField() { mHandler.post(null); } + void libraryChain() { mOwner.ctx.getResources(); } + void libraryParam(Parcel parcel) { parcel.readInt(); parcel.writeInt(1); } + void libraryGenericLocal() { + List items = null; + items.add("x"); + Map> byName = null; + byName.put("k", null); + } + void libraryForEach(List parcels) { + for (Parcel p : parcels) { p.readInt(); } + } + void projectGenericLocal() { + Box crate = make(); + crate.seal(); + } + void iterate(List list) { + Iterator it = list.iterator(); + it.hasNext(); + } + void entries(Map map) { + for (Map.Entry e : map.entrySet()) { e.getKey(); } + } + void nested(Bytes.Iterator bytes) { bytes.hasNext(); } + static Box make() { return null; } + void staticLibrary() { Log.d("t", "m"); } + + Runnable anonymous() { + return new Runnable() { + public void run() { mStore.save(); } + }; + } + + class Inner { + private Box mStore; + void viaOuter() { Screen.this.mStore.close(); } + } +} + +class Holder { + T hook; + void go() { hook.fire(); } +} +`); + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Qualified names of what `owner.method` calls (`owner` may name an anonymous class's supertype). */ + function callees(owner: string, method: string): string[] { + const node = cg.searchNodes(method).map((r) => r.node) + .find((n) => n.qualifiedName.endsWith(`${owner}::${method}`) || (n.qualifiedName.includes(`${owner}$anon`) && n.qualifiedName.endsWith(`::${method}`))); + expect(node, `${owner}::${method}`).toBeDefined(); + return cg.getCallees(node!.id) + .filter((c) => c.edge.kind === 'calls') + .map((c) => c.node.qualifiedName) + .sort(); + } + + it('a field, `this.field` and a static final field resolve on the declared type', () => { + expect(callees('Screen', 'viaField')).toEqual(['p::Repo::save']); + expect(callees('Screen', 'viaThis')).toEqual(['p::Repo::save']); + expect(callees('Screen', 'viaConstant')).toEqual(['p::Repo::close']); + }); + + it('an anonymous class reads its outer class field, and `Outer.this.field` names the class', () => { + expect(callees('Runnable', 'run')).toEqual(['p::Repo::save']); + expect(callees('Inner', 'viaOuter')).toEqual(['p::Repo::close']); + }); + + it('a chain of fields resolves through each declared type', () => { + expect(callees('Screen', 'viaChain')).toEqual(['p::Repo::save']); + expect(callees('Screen', 'viaThisChain')).toEqual(['p::Repo::save']); + expect(callees('Screen', 'viaLocalChain')).toEqual(['p::Repo::save']); + }); + + it('a field, a chain hop, a parameter or a local of a library type gets no edge', () => { + expect(callees('Screen', 'libraryField')).toEqual([]); + expect(callees('Screen', 'libraryChain')).toEqual([]); + expect(callees('Screen', 'libraryParam')).toEqual([]); + expect(callees('Screen', 'libraryGenericLocal')).toEqual([]); + expect(callees('Screen', 'libraryForEach')).toEqual([]); + // A static call on an imported library class (`Log.d` beside a project `Decoy.d`). + expect(callees('Screen', 'staticLibrary')).toEqual([]); + }); + + it('a JDK type is not read as a same-named type nested in another package', () => { + // An explicit `import java.util.Iterator`, and a qualified `Map.Entry`. + expect(callees('Screen', 'iterate')).toEqual([]); + expect(callees('Screen', 'entries')).toEqual([]); + }); + + it('a nested project type written with its outer class resolves on it', () => { + expect(callees('Screen', 'nested')).toEqual(['q::Bytes::Iterator::hasNext']); + }); + + it('a generic local of a project type resolves on that type', () => { + expect(callees('Screen', 'projectGenericLocal')).toEqual(['p::Box::seal', 'p::Screen::make']); + }); + + it('a Java receiver type prefers the Java declaration over a same-named Scala one', () => { + expect(callees('Controller', 'submit')).toEqual(['p::Form::get']); + }); + + it('an explicitly imported Scala type is a project type wherever its file is named', () => { + expect(callees('Controller', 'parse')).toEqual(['PlayBodyParsers::raw']); + expect(callees('Controller', 'written')).toEqual(['PlayBodyParsers::raw']); + expect(callees('Controller', 'writtenLocal')).toEqual(['PlayBodyParsers::raw']); + }); + + it('an imported nested type is the one the import names', () => { + expect(callees('Controller', 'cookie')).toEqual(['p::Http::Cookie::value']); + }); + + it('a type-parameter receiver is not treated as a library type', () => { + expect(callees('Holder', 'go')).toEqual(['p::Hook::fire']); + }); +}); + +describe('Java receivers keep the type their declaration names', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-field-written-')); + const write = (rel: string, body: string) => { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + }; + write('src/a/Pause.java', `package a; +public class Pause { + public static class Suite { + public void runDelay(String id) {} + } +} +`); + write('src/a/http/Request.java', `package a.http; +public class Request { + public String getBody() { return ""; } +} +`); + // kestra's AbstractRunnerTest: `protected PauseTest.Suite pauseTest;` under + // `import io.kestra.plugin.core.flow.*;`, and RequestTest's field written + // with the project package beside an import of a library `HttpRequest`. + write('src/b/Runner.java', `package b; + +import a.*; +import io.lib.Request; + +public class Runner { + protected Pause.Suite pause; + private a.http.Request request; + + public void delay() { + pause.runDelay("x"); + } + + public void body() { + request.getBody(); + } + + // spring-ai's \`org.springframework.ai.chat.messages.Message message\` beside + // an import of the Anthropic SDK's \`Message\`: a local or loop variable + // written with its package. + public void localBody(java.util.List all) { + a.http.Request first = all.get(0); + first.getBody(); + } + + public void eachBody(java.util.List all) { + for (a.http.Request each : all) { + each.getBody(); + } + } +} +`); + // SystemUI's QSTileImpl: a field typed by the class's + // type parameter, beside another class's same-named method. + write('src/a/State.java', `package a; +public class State { + public void copyTo(State other) {} +} +`); + write('src/a/Other.java', `package a; +public class Other { + public void copyTo(Other other) {} +} +`); + write('src/b/Tile.java', `package b; + +import a.State; + +public abstract class Tile { + private TState mTmpState; + + public void refresh() { + mTmpState.copyTo(null); + } +} +`); + // SystemUI's HeadsUpManagerPhone: `AlertEntry`, nested in a class two + // levels up its hierarchy, named without an import. + write('src/a/Alerts.java', `package a; +public abstract class Alerts { + public static class Entry { + public void update() {} + } +} +`); + write('src/a/HeadsUp.java', `package a; +public abstract class HeadsUp extends Alerts {} +`); + write('src/c/Store.java', `package c; +public class Store { + public void update() {} +} +`); + write('src/b/Phone.java', `package b; + +import a.HeadsUp; + +public class Phone extends HeadsUp { + public void refresh(java.util.List entries) { + for (Entry entry : entries) { + entry.update(); + } + } +} +`); + // kestra: a `java.net.URI` (short enough to read as a type parameter) and + // a project enum, whose `name()` is Java's, beside project methods the + // receivers' names match. + write('src/a/FlowWithPath.java', `package a; +public class FlowWithPath { + public String getPath() { return ""; } +} +`); + write('src/a/Type.java', `package a; +public class Type { + public String name() { return ""; } +} +`); + write('src/a/ServiceType.java', `package a; +public enum ServiceType { WORKER, EXECUTOR } +`); + write('src/b/Storage.java', `package b; + +import java.net.URI; +import a.ServiceType; + +public class Storage { + private URI base; + private ServiceType kind; + + public void local(URI path, ServiceType type) { + path.getPath(); + type.name(); + } + + public void field() { + base.getPath(); + kind.name(); + } +} +`); + // playframework's javaguide: `import play.mvc.Http.*;` names Http's + // nested `RequestBody`. + write('src/a/Web.java', `package a; +public class Web { + public static class Body { + public String asText() { return ""; } + } +} +`); + write('src/b/Parsers.java', `package b; + +import a.Web.*; + +public class Parsers { + public void read(Body body) { + body.asText(); + } +} +`); + // spring-modulith's ApplicationModule: `Comparable` in + // the class head names the class, not a type parameter, and a nested + // class's field of that type types the call on a local named like it. + write('src/a/AppModule.java', `package a; +public class AppModule implements Comparable { + public java.util.List getNames() { return null; } + public int compareTo(AppModule other) { return 0; } + + public static class Link { + private final AppModule target; + + Link(AppModule target) { this.target = target; } + + static Link of(AppModule source) { + var target = source.find(); + var names = target.getNames(); + return null; + } + } + + public AppModule find() { return this; } +} +`); + write('src/c/Other.java', `package c; +public class Other { + public java.util.List getNames() { return null; } +} +`); + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function callees(owner: string, method: string): string[] { + const node = cg.searchNodes(method).map((r) => r.node).find((n) => n.qualifiedName.endsWith(`${owner}::${method}`)); + expect(node, `${owner}::${method}`).toBeDefined(); + return cg.getCallees(node!.id).filter((c) => c.edge.kind === 'calls').map((c) => c.node.qualifiedName).sort(); + } + + it('a field typed with its outer class, through an on-demand import, resolves on the nested type', () => { + expect(callees('Runner', 'delay')).toEqual(['a::Pause::Suite::runDelay']); + }); + + it('a field written with its package resolves on that type, whatever the file imports by the name', () => { + expect(callees('Runner', 'body')).toEqual(['a.http::Request::getBody']); + }); + + it('a local or loop variable written with its package resolves on that type too', () => { + expect(callees('Runner', 'localBody')).toEqual(['a.http::Request::getBody']); + expect(callees('Runner', 'eachBody')).toEqual(['a.http::Request::getBody']); + }); + + it('a field typed by the class type parameter resolves on its bound', () => { + expect(callees('Tile', 'refresh')).toEqual(['a::State::copyTo']); + }); + + it('a nested type a superclass declares is named without an import', () => { + expect(callees('Phone', 'refresh')).toEqual(['a::Alerts::Entry::update']); + }); + + it('a library type with a short name, or a project enum lacking the method, gets no guess', () => { + expect(callees('Storage', 'local')).toEqual([]); + expect(callees('Storage', 'field')).toEqual([]); + }); + + it('an on-demand import of a class names its nested types', () => { + expect(callees('Parsers', 'read')).toEqual(['a::Web::Body::asText']); + }); + + it('a class the head names as a type argument is no type parameter', () => { + expect(callees('Link', 'of')).toEqual(['a::AppModule::find', 'a::AppModule::getNames']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 75ecfe695..af52fb370 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -13588,10 +13588,12 @@ export function resolveMethodOnType( // intentionally in ANOTHER file (#314) — is unaffected: those blocks return // early whenever an import pins the class. if (ref.referenceKind === 'function_ref' && matches.length !== 1) return null; - // A Ruby receiver is typed only by `x = Widget.new`, so it is an instance: - // `x.build` reaches `def build`, not `def self.build`, wherever each is. + // Java: a same-named type in another JVM language (Scala's `play.api.data.Form` + // beside Java's `play.data.Form`) is the weaker candidate when no import + // pins one — a wildcard `import play.data.*` or the same package names none. + const sameLanguage = ref.language === 'java' ? matches.filter((m) => m.language === 'java') : []; const ordered = rubyMethodsOfKindFirst( - preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(matches, ref, context), ref, context), ref.filePath), + preferCallSiteFile(withinCppTranslationUnit(cppVisibleFirst(sameLanguage.length > 0 ? sameLanguage : matches, ref, context), ref, context), ref.filePath), ref.language === 'ruby' ? 'instance' : undefined); return { original: ref, @@ -18322,6 +18324,7 @@ function inferJavaFieldReceiverType( ): string | null { const inFile = context.getNodesInFile(ref.filePath); if (inFile.length === 0) return null; + if (ref.language === 'java') return javaEnclosingFieldType(receiverName, ref, inFile); // Find the class enclosing the call line (tightest match by latest start). let enclosing: Node | null = null; @@ -18344,16 +18347,21 @@ function inferJavaFieldReceiverType( n.startLine >= enclosing.startLine && (n.endLine ?? n.startLine) <= enclosingEnd, ); - if (!field || !field.signature) return null; + return field ? fieldSignatureType(field) : null; +} - // Signature shape: " " (extractField). Pull the type, - // strip generics + dotted package, drop array/varargs markers. - const beforeName = field.signature.slice( - 0, - field.signature.lastIndexOf(field.name), - ); - const typeRaw = beforeName.trim(); +/** + * The declared type of a Java/Kotlin field node, read from its signature + * (" ", set by tree-sitter.ts extractField): generics, + * a dotted package and array/varargs markers stripped — for Java, in + * javaTypeName's form, which keeps a written package and outer class. Null + * for a primitive or a field without a signature. + */ +function fieldSignatureType(field: Node): string | null { + if (!field.signature) return null; + const typeRaw = field.signature.slice(0, field.signature.lastIndexOf(field.name)).trim(); if (!typeRaw) return null; + if (field.language === 'java') return javaTypeName(typeRaw); const typeNoGenerics = typeRaw.replace(/<[^>]*>/g, '').trim(); const typeNoArray = typeNoGenerics.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').trim(); @@ -18364,6 +18372,301 @@ function inferJavaFieldReceiverType( return lastPart; } +/** + * A Java type as written (`Foo`, `java.util.List`, `Map.Entry`, + * `play.api.mvc.BodyParser`, `Foo[]`), type arguments and array markers + * dropped. The class chain is joined with `::` (`Map::Entry`), so a nested + * type is never taken for a same-named type nested elsewhere, and a package + * written in source stays in front of it, dotted (`play.api.mvc.BodyParser`), + * for javaTypeRef to pin the declaration with. Null for a primitive. + */ +function javaTypeName(raw: string): string | null { + let type = raw; + for (let prev = ''; prev !== type; ) { + prev = type; + type = type.replace(/<[^<>]*>/g, ''); + } + type = type.replace(/\[\s*\]/g, '').replace(/\.\.\.$/, '').replace(/\s+/g, ''); + const segments = type.split('.').filter(Boolean); + let first = segments.length; + while (first > 0 && /^[A-Z][\w$]*$/.test(segments[first - 1]!)) first--; + const chain = segments.slice(first).join('::'); + if (!chain || NON_TYPE_RECEIVER_TOKENS.has(chain)) return null; + const pkg = segments.slice(0, first).join('.'); + return pkg ? `${pkg}.${chain}` : chain; +} + +/** + * Java: the declared type of the field a receiver names — `mHandler`, + * `this.mHandler`, or `Outer.this.mHandler`. The field is looked up in the + * class enclosing the call and then in each class around it, tightest first, + * because an inner or anonymous class reads its outer classes' fields + * (`mHandler.post(...)` inside a `new Runnable() {…}`); `Outer.this.f` names + * the class to read. A `static final` field is indexed as a `constant`. + */ +function javaEnclosingFieldType(receiverName: string, ref: UnresolvedRef, inFile: Node[]): string | null { + const m = receiverName.match(/^(?:(?:(\w+)\.)?this\.)?(\w+)$/); + if (!m) return null; + const [, outer, fieldName] = m; + const enclosing = inFile + .filter( + (n) => + (n.kind === 'class' || n.kind === 'interface' || n.kind === 'enum') && + n.language === 'java' && + n.startLine <= ref.line && + (n.endLine ?? n.startLine) >= ref.line, + ) + .sort((a, b) => b.startLine - a.startLine); + for (const cls of enclosing) { + if (outer && cls.name !== outer) continue; + const field = inFile.find( + (n) => + (n.kind === 'field' || n.kind === 'constant') && + n.name === fieldName && + n.qualifiedName === `${cls.qualifiedName}::${fieldName}`, + ); + if (field) return fieldSignatureType(field); + if (outer) return null; + } + return null; +} + +/** + * Stands for "a type the project does not declare" — a library class, or a + * field reached through one — so the caller can refuse a name-only guess. + */ +const JAVA_EXTERNAL_TYPE = '\u0000external'; + +const JVM_DECLARED_TYPE_KINDS = new Set(['class', 'interface', 'enum', 'struct', 'trait']); + +/** JVM type declarations named `name`. */ +function jvmTypeDecls(name: string, context: ResolutionContext): Node[] { + return context + .getNodesByName(name) + .filter((n) => JVM_DECLARED_TYPE_KINDS.has(n.kind) && sameLanguageFamily(n.language, 'java')); +} + +/** + * Whether `decl` is the type one of `fqns` (an import, or a package written + * in source) names: the Java declaration of that qualified name or file, or + * a Scala / Kotlin one in that package's directory (their qualified names + * carry no package, and their files need not be named after the type). + */ +function declaresJvmFqn(decl: Node, fqns: string[]): boolean { + const dotted = decl.qualifiedName.replace(/::/g, '.'); + const declPath = decl.filePath.replace(/\\/g, '/'); + const declFile = declPath.replace(/\.\w+$/, ''); + const declDir = declPath.slice(0, declPath.lastIndexOf('/') + 1); + return fqns.some((fqn) => { + const fqnPath = fqn.replace(/\./g, '/'); + if (dotted === fqn || declFile === fqnPath || declFile.endsWith('/' + fqnPath)) return true; + const pkgPath = fqnPath.slice(0, fqnPath.lastIndexOf('/') + 1); + return decl.language !== 'java' && pkgPath !== '' && (declDir === pkgPath || declDir.endsWith('/' + pkgPath)); + }); +} + +/** + * Whether Java file `filePath` can name `decl` by its simple name without + * importing it — for a type read from a declaration (a field's, a local's), + * where isJavaTypeVisible judges a name written at a reference. A Java + * qualified name reads `package::Outer::Inner`; a nested type is named that + * way only in its own file, in its own package (as `Outer.Inner`), or where + * its top-level class is imported. A top-level type of another package is + * also reachable through a wildcard import, which the import mappings do not + * list, so it counts as visible, and so does a Scala or Kotlin declaration, + * whose qualified name carries no package to check. A class also names the + * nested types of the classes it extends or implements (SystemUI's + * `AlertEntry` in a subclass of `AlertingNotificationManager`). + */ +function isJavaTypeNameable(decl: Node, filePath: string, context: ResolutionContext): boolean { + if (decl.language !== 'java' || decl.filePath === filePath) return true; + const segments = decl.qualifiedName.split('::'); + const hasPackage = segments.length > 1 && /^[a-z]/.test(segments[0]!); + const classChain = hasPackage ? segments.slice(1) : segments; + if (classChain.length <= 1) return true; + const pkg = hasPackage ? segments[0]! : ''; + if (pkg === javaFileScope(filePath, context).pkg) return true; + const outer = pkg ? `${pkg}.${classChain[0]}` : classChain[0]!; + if (context.getImportMappings(filePath, 'java').some((i) => i.source === outer || i.source.startsWith(outer + '.'))) return true; + // `import play.mvc.Http.*;` names Http's nested types. + if (javaFileScope(filePath, context).demand.has(segments.slice(0, -1).join('.'))) return true; + const ancestors = javaFileAncestors(filePath, context); + return context.getNodesByQualifiedName(segments.slice(0, -1).join('::')).some((o) => ancestors.has(o.id)); +} + +/** Per-context memo: Java file → ids of the classes its classes extend or implement. */ +const JAVA_FILE_ANCESTORS = new WeakMap>>(); + +/** The Java classes and interfaces the classes of `filePath` extend or implement, up to five levels up. */ +function javaFileAncestors(filePath: string, context: ResolutionContext): Set { + let memo = JAVA_FILE_ANCESTORS.get(context); + if (!memo) JAVA_FILE_ANCESTORS.set(context, (memo = new Map())); + const hit = memo.get(filePath); + if (hit) return hit; + const classes = context.getNodesInFile(filePath).filter((n) => n.language === 'java' && isMethodOwnerKind(n)); + const seen = new Set(classes.map((c) => c.id)); + const ancestors = new Set(); + let frontier = classes; + for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { + const next: Node[] = []; + for (const cls of frontier) { + for (const sup of declaredSupertypes(cls, context)) { + for (const decl of context.getNodesByName(sup)) { + if (decl.language !== 'java' || !isMethodOwnerKind(decl) || seen.has(decl.id)) continue; + seen.add(decl.id); + ancestors.add(decl.id); + next.push(decl); + } + } + } + frontier = next; + } + memo.set(filePath, ancestors); + return ancestors; +} + +/** A Java receiver type the project declares. */ +type JavaTypeRef = { + /** The class chain resolveMethodOnType matches (`Http::Cookie`). */ + name: string; + /** The declaration an import or a written package pins, when one does. */ + decl?: Node; +}; + +/** + * What a Java type written in `filePath` (javaTypeName's form) is: a project + * type, or null for a library one (`Parcel`, `Context`, `List`), which none + * of the project's methods belongs to. It is a library type when the project + * declares no JVM type of its outer name; when an import, or a package + * written in source, names a type the project does not declare (`import + * java.util.Iterator` beside a project `UByteArray.Iterator`); when every + * project type of that name is one the file cannot name that way (a nested + * type of another package); or when a project class declares no nested type + * of the written name (`MyLayout.LayoutParams`, inherited from a library + * class). A short all-caps name (`T`, `VH`) is taken for a type parameter, + * whose bound may be a project type, and left alone. + */ +function javaTypeRef(written: string, filePath: string, context: ResolutionContext): JavaTypeRef | null { + const chainStart = written.indexOf('::'); + const head = chainStart < 0 ? written : written.slice(0, chainStart); + const lastDot = head.lastIndexOf('.'); + const pkg = lastDot < 0 ? '' : head.slice(0, lastDot); + const chain = written.slice(lastDot + 1).split('::'); + const outer = chain[0]!; + if (!pkg && chain.length === 1 && /^[A-Z][A-Z0-9]{0,2}$/.test(outer)) return { name: outer }; + const decls = jvmTypeDecls(outer, context); + if (decls.length === 0) return null; + const pins = pkg + ? [`${pkg}.${outer}`] + : context.getImportMappings(filePath, 'java').filter((i) => i.localName === outer).map((i) => i.source); + if (pins.length === 0) { + if (!decls.some((d) => isJavaTypeNameable(d, filePath, context))) return null; + if (chain.length === 1) return { name: outer }; + const name = chain.join('::'); + const nested = jvmTypeDecls(chain[chain.length - 1]!, context).some( + (n) => n.qualifiedName === name || n.qualifiedName.endsWith(`::${name}`), + ); + return nested ? { name } : null; + } + let decl = decls.find((d) => declaresJvmFqn(d, pins)); + for (const segment of chain.slice(1)) { + if (!decl) break; + const owner: Node = decl; + decl = jvmTypeDecls(segment, context).find((n) => n.qualifiedName === `${owner.qualifiedName}::${segment}`); + } + if (!decl) return null; + const segments = decl.qualifiedName.split('::'); + const hasPackage = decl.language === 'java' && segments.length > 1 && /^[a-z]/.test(segments[0]!); + return { name: (hasPackage ? segments.slice(1) : segments).join('::'), decl }; +} + +/** + * Resolve a method on a Java receiver type: the pinned declaration's own + * method first, then resolveMethodOnType on the class chain, then — for a + * nested chain, which the supertype walk there cannot look up — the + * supertypes of its simple name. A Scala or Kotlin declaration pinned by an + * import is not followed further than its own methods: the class chain alone + * matches a same-named Java type as well. + */ +function resolveOnJavaType( + type: JavaTypeRef, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null { + if (type.decl) { + const decl = type.decl; + const own = context + .getNodesByName(methodName) + .find((n) => n.kind === 'method' && n.qualifiedName === `${decl.qualifiedName}::${methodName}`); + if (own) return { original: ref, targetNodeId: own.id, confidence: 0.9, resolvedBy: 'instance-method' }; + if (decl.language !== 'java') return null; + } + const fqn = type.decl ? type.decl.qualifiedName.replace(/::/g, '.') : undefined; + const direct = resolveMethodOnType(type.name, methodName, ref, context, 0.9, 'instance-method', fqn); + if (direct || !type.name.includes('::') || !context.getSupertypes) return direct; + for (const supertype of context.getSupertypes(type.name.split('::').pop()!, 'java')) { + const via = resolveMethodOnType(supertype, methodName, ref, context, 0.9, 'instance-method', undefined, 1); + if (via) return via; + } + return null; +} + +/** + * Java: the declared type of a receiver the field lookup reaches — a field + * (`mRepo`, `this.mRepo`, `Outer.this.mRepo`) or a chain of up to three + * fields after it (`mState.menu`, `this.mOwner.mRepo`), whose head may also + * be a local. Each hop reads the field declared on the previous hop's type, + * and reads that field's type with its own file's imports. + * JAVA_EXTERNAL_TYPE as soon as a hop's type is a library one; null when a + * hop cannot be typed (an inherited field, an untyped local). + */ +function javaReceiverDeclaredType( + receiver: string, + ref: UnresolvedRef, + context: ResolutionContext, +): JavaTypeRef | typeof JAVA_EXTERNAL_TYPE | null { + const m = receiver.match(/^((?:\w+\.)?this\.\w+|[A-Za-z_$][\w$]*)((?:\.[a-zA-Z_$][\w$]*){0,3})$/); + if (!m) return null; + const [, head, rest] = m; + const segments = rest ? rest.slice(1).split('.').filter(Boolean) : []; + const headType = + // A plain receiver's local declaration was already read by the caller. + (segments.length > 0 && !head!.includes('.') + ? inferLocalReceiverType(head!, ref, context) + : null) ?? inferJavaFieldReceiverType(head!, ref, context); + if (!headType) return null; + // A type parameter of the class or method around the call (`TState + // mTmpState` in `QSTileImpl`) is no library type: its + // bound decides, which matchMethodCall reads when this answers null. As + // there, only a name no project class has: typeParameterBound also reads a + // type argument (`Comparable`) as a parameter. + if (/^[A-Z][\w$]*$/.test(headType) && !context.getNodesByName(headType).some(isMethodOwnerKind) && + typeParameterBound(headType, ref, context) !== undefined) return null; + let written = headType; + let filePath = ref.filePath; + for (const segment of segments) { + const type = javaTypeRef(written, filePath, context); + if (!type) return JAVA_EXTERNAL_TYPE; + const owner = type.decl?.qualifiedName; + const field = context + .getNodesByName(segment) + .find( + (n) => + (n.kind === 'field' || n.kind === 'constant') && + n.language === 'java' && + (owner + ? n.qualifiedName === `${owner}::${segment}` + : n.qualifiedName.endsWith(`::${type.name}::${segment}`) || n.qualifiedName === `${type.name}::${segment}`), + ); + const fieldType = field ? fieldSignatureType(field) : null; + if (!fieldType) return null; + written = fieldType; + filePath = field!.filePath; + } + return javaTypeRef(written, filePath, context) ?? JAVA_EXTERNAL_TYPE; +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) @@ -18548,6 +18851,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { CPP_CALLERS_MEMBERS.delete(context); SOLIDITY_SUPERS.delete(context); DECLARED_SUPERS.delete(context); + JAVA_FILE_ANCESTORS.delete(context); INHERITED_METHODS.delete(context); MEMBER_SHADOWS.delete(context); MEMBER_WALKS.delete(context); @@ -18709,8 +19013,10 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] case 'java': return [ new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_][\\w.]*)`), // = new Logger() - new RegExp(`\\b([A-Z][\\w.]*)\\s*${TYPE_ARGS}\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / Pair pair / String[] args - new RegExp(`\\bfor\\s*\\(\\s*(?:final\\s+)?([A-Z][\\w.]*)\\s*${TYPE_ARGS}\\s+${r}\\s*:`), // for (Element el : els) + // A package written before the type is kept (`org.app.Message message`): + // it, not a same-named import, says which type the local is. + new RegExp(`\\b((?:[a-z_$][\\w$]*\\.)*[A-Z][\\w.]*)\\s*${TYPE_ARGS}\\s+${r}\\b\\s*[=;,)]`), // Logger lg; / Pair pair / String[] args + new RegExp(`\\bfor\\s*\\(\\s*(?:final\\s+)?((?:[a-z_$][\\w$]*\\.)*[A-Z][\\w.]*)\\s*${TYPE_ARGS}\\s+${r}\\s*:`), // for (Element el : els) ]; case 'kotlin': return [ @@ -19388,7 +19694,7 @@ export function inferLocalReceiverType( for (const re of patterns) { const m = line.match(re); if (m && m[1]) { - const type = normalizeInferredTypeName(m[1]); + const type = ref.language === 'java' ? javaTypeName(m[1]) : normalizeInferredTypeName(m[1]); if (type) { lastRaw = m[1]; return type; @@ -20279,6 +20585,9 @@ export function matchMethodCall( // dedicated inferrer (header scan + `auto`); every other language uses the // shared source-based inferrer. resolveMethodOnType validates the method // exists on the inferred type, so a mis-inference produces no edge. + let localInferred = false; + // Java: what javaReceiverDeclaredType read for the receiver, once it ran. + let javaDeclared: ReturnType | undefined; if (inferableReceiver) { // A VB.NET receiver's declared type decides the call, or that there is no // project method to call: SCrawler's `ThumbnailFile.Delete(…)` on an @@ -20378,7 +20687,12 @@ export function matchMethodCall( const from = goLocalDecl(objectOrClass!, ref, context)?.from; if (from && isGoOutsidePackage(from, ref.filePath, context)) return null; } - if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch) { + // A Java field the class around the call declares is read below as its + // declaration writes it, with the package or outer class the member + // reader drops (`PauseTest.Suite`, `io.kestra.core.http.HttpRequest`); + // the member reader still types a field the class inherits. + if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch && + (ref.language !== 'java' || (javaDeclared = javaReceiverDeclaredType(objectOrClass!, ref, context)) === null)) { inferredType = nmTimedT('mc-member', ref, () => inferMemberReceiverType(objectOrClass!, ref, context)); // A field of a built-in type (`string _name`, `int count`) has no project method. if (inferredType && /^[a-z]/.test(inferredType)) return null; @@ -20452,7 +20766,23 @@ export function matchMethodCall( if (ref.language === 'rust' && inferredType === 'Pin' && !isRustProjectNamed('Pin', RUST_DECLARED_TYPE_KINDS, context)) { return matchRustPinnedCall(objectOrClass!, methodName!, ref, context); } - if (inferredType) { + if (inferredType && ref.language === 'java') { + localInferred = true; + // Java: a local of a library type (`Parcel parcel`, `List items`) + // has none of the project's methods — neither a same-named nested + // project type's (`Iterator it` beside `UByteArray.Iterator`) nor + // whatever the name-only strategies below would bind the call to. + const type = javaTypeRef(inferredType, ref.filePath, context); + if (!type) return null; + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveOnJavaType(type, methodName!, ref, context)); + if (typedMatch) return typedMatch; + // As for every other language below: a type no project class or + // interface is named — a library `URI` the short-name rule took for a + // type parameter, an enum whose `name()` / `equals()` are Java's — is + // past guessing (kestra's `URI file; file.toString()`). + if (isUndeclaredTypeName(inferredType.split(/[.:]+/).pop()!, ref, context)) return null; + } else if (inferredType) { + localInferred = true; // A Go local bound from a type assertion calls a method of the type Go // finds for it, or nothing the project declares: `flusher.Flush()` after // `flusher, ok := w.(http.Flusher)` went to a project type named after it. @@ -20669,12 +20999,28 @@ export function matchMethodCall( return guess && read.size > 0 ? { ...guess, metadata: { ...guess.metadata, typeFrom: [...read].sort() } } : guess; } - // Java/Kotlin: receiver may be a field whose name doesn't match the type by - // Java naming convention (`userbo` → class `UserBO`, abbreviated). Look up + // Java: a field receiver — `mRepo`, `this.mRepo`, `Outer.this.mRepo`, or a + // chain of fields `mState.menu` — resolves on its declared type, and a + // library type (`Handler`, `Context`) gets NO edge rather than the + // name-only guesses below. + if (ref.language === 'java' && dotMatch && !localInferred) { + const declared = javaDeclared !== undefined ? javaDeclared : nmTimedT('mc-java-declared', ref, () => + javaReceiverDeclaredType(objectOrClass!, ref, context)); + if (declared === JAVA_EXTERNAL_TYPE) return null; + if (declared) { + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveOnJavaType(declared, methodName!, ref, context)); + if (typedMatch) return typedMatch; + if (isUndeclaredTypeName(declared.name.split('::').pop()!, ref, context)) return null; + } + } + + // Kotlin (Java's fields are read in the branch above): receiver may be a + // field whose name doesn't match the type by Java naming convention + // (`userbo` → class `UserBO`, abbreviated). Look up // the field in the enclosing class to get its declared type, then resolve // the method on that type. Covers Spring `@Resource`/`@Autowired` field // injection where the field type is the concrete bean class. - if ((ref.language === 'java' || ref.language === 'kotlin') && dotMatch) { + if (ref.language === 'kotlin' && dotMatch) { const inferredType = inferJavaFieldReceiverType(objectOrClass!, ref, context); if (inferredType) { // When two classes share the same simple name, the caller file's