diff --git a/CHANGELOG.md b/CHANGELOG.md index 842a67fae..534995aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -266,6 +266,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 Java, a call on a value a type holds in a static field, like `Consts.HELPER.go()`, `Holder.SHARED.run()`, `Outer.Inner.NESTED.run()`, an enum constant's `Color.RED.hex()` or a `CREATOR`'s `createFromParcel(…)`, now links to the method of the field's type, as the field's declaration reads with its own file's imports: the class a `new FastRunner()` initializer builds, or the anonymous class the field holds. A field the type inherits counts, an enum constant's own override comes first, and a Kotlin object's `INSTANCE` reaches the object's member. An interface's constants are now indexed. Before, such a call was guessed by the method's name, often onto a same-named class in another package, and a field of a library class, like `Intent.ACTION_VIEW.equals(…)`, could link to a project method of that name; it now links nothing. Re-index Java projects after upgrading. Thanks @mixxer. - 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 Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345. diff --git a/__tests__/fixtures/kernel-parity/Torture.java b/__tests__/fixtures/kernel-parity/Torture.java index 221f02b7a..84ab79b1f 100644 --- a/__tests__/fixtures/kernel-parity/Torture.java +++ b/__tests__/fixtures/kernel-parity/Torture.java @@ -140,3 +140,9 @@ public void on() {} public Integer call() { return 1; } }; } +interface TortureConsts { + int MAX = 10; + Helper HELPER = new Helper(); + String NAME = "x", OTHER = "y"; + Runnable TASK = new Runnable() { public void run() { go(); } }; +} diff --git a/__tests__/java-static-field-receivers.test.ts b/__tests__/java-static-field-receivers.test.ts new file mode 100644 index 000000000..c8f09bbec --- /dev/null +++ b/__tests__/java-static-field-receivers.test.ts @@ -0,0 +1,196 @@ +/** + * Java's `Type.FIELD.m()` calls a method on a value a type holds in a static + * field: an interface's constant, a class's `static final`, an enum constant. + * The value is typed by the field's own declaration, with its own file's + * imports: the method an anonymous class the field is initialized with + * declares, else the class a `new X(…)` builds, else the declared type's. + * Before, nothing read the field: an interface's constants weren't indexed + * at all, and the call was guessed by the method's name alone, often onto a + * same-named class in another package, or onto a project method for a field + * of a library class (`Intent.ACTION_VIEW.equals(…)`). + * + * Cases from @mixxer's #1949 and the T5 triage fixtures. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-java-static-field-')); + const files: Record = { + 'r/Consts.java': `package r; +public interface Consts { + int MAX = 10; + Helper HELPER = new Helper(); + String NAME = "x"; +} +`, + 'r/Helper.java': `package r; +public class Helper { public void go() {} } +`, + 'r/Holder.java': `package r; +public class Holder { + public static final Helper SHARED = new Helper(); +} +`, + 'r/User.java': `package r; +public class User { + void viaInterface() { Consts.HELPER.go(); } + void viaClass() { Holder.SHARED.go(); } +} +`, + 'com/lib/ProjectInterpolator.java': `package com.lib; +public class ProjectInterpolator { public float getInterpolation(float t) { return t; } } +`, + 'p/ProjectInterpolator.java': `package p; +public class ProjectInterpolator { public float getInterpolation(float t) { return t; } } +`, + 'r/ProjectInterpolators.java': `package r; +import p.ProjectInterpolator; +class ProjectInterpolators { static final ProjectInterpolator LOCAL = new ProjectInterpolator(); } +`, + 'r/Consumer.java': `package r; +public class Consumer { + float projectCall() { return ProjectInterpolators.LOCAL.getInterpolation(1); } +} +`, + 's/Shapes.java': `package s; + +import android.os.Parcel; +import android.os.Parcelable; +import android.content.Intent; +import android.view.animation.Interpolators; + +class Parent { + public static final Runner SHARED = new Runner(); +} + +class Child extends Parent {} + +class Runner { public void run() {} public String getName() { return "r"; } } + +class FastRunner extends Runner { @Override public void run() {} } + +interface Defaults { + Runner DEFAULT = new FastRunner(); +} + +class Impl implements Defaults {} + +class Outer { + static class Inner { + static final Runner NESTED = new Runner(); + } +} + +enum Color { + RED, + GREEN { + @Override String hex() { return "0f0"; } + }; + String hex() { return "f00"; } +} + +class Foo implements Parcelable { + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + public Foo createFromParcel(Parcel in) { return new Foo(); } + public Foo[] newArray(int size) { return new Foo[size]; } + }; +} + +class Calls { + void inherited() { Child.SHARED.run(); } + void interfaceConstant() { Impl.DEFAULT.run(); } + void nested() { Outer.Inner.NESTED.run(); } + void enumConstant() { Color.RED.hex(); } + void enumBody() { Color.GREEN.hex(); } + void anonymous(Parcel p) { Foo.CREATOR.createFromParcel(p); } + boolean library(String a) { return Intent.ACTION_VIEW.equals(a); } + float libraryField(float t) { return Interpolators.FAST_OUT_SLOW_IN.getInterpolation(t); } + String javaLang() { return Integer.TYPE.getName(); } + void kotlinObject() { KObj.INSTANCE.go(); } +} +`, + 's/KObj.kt': `package s + +object KObj { + fun go() {} +} + +class Other { + fun equals(x: String): Boolean = true +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** Qualified names of what the method `name` in `file` calls. */ +function callees(file: string, name: string): string[] { + const from = cg.getNodesInFile(file).find((n) => n.kind === 'method' && n.name === name)!; + return cg + .getOutgoingEdgesFrom([from.id]) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('Java: an interface constant', () => { + it('is indexed, as a static final field is', () => { + const constants = cg.getNodesInFile('r/Consts.java').filter((n) => n.kind === 'constant').map((n) => n.qualifiedName); + expect(constants.sort()).toEqual(['r::Consts::HELPER', 'r::Consts::MAX', 'r::Consts::NAME']); + }); +}); + +describe('Java: Type.FIELD.m()', () => { + it('reaches the method of the field\'s type', () => { + expect(callees('r/User.java', 'viaInterface')).toEqual(['r::Helper::go']); + expect(callees('r/User.java', 'viaClass')).toEqual(['r::Helper::go']); + }); + + it('reads the field\'s type with the field\'s own file\'s imports', () => { + expect(callees('r/Consumer.java', 'projectCall')).toEqual(['p::ProjectInterpolator::getInterpolation']); + }); + + it('finds a field the type inherits, from a class or an interface', () => { + expect(callees('s/Shapes.java', 'inherited')).toEqual(['s::Runner::run']); + expect(callees('s/Shapes.java', 'interfaceConstant')).toEqual(['s::FastRunner::run']); + }); + + it('walks a nested type', () => { + expect(callees('s/Shapes.java', 'nested')).toEqual(['s::Runner::run']); + }); + + it('reaches an enum constant\'s own override, else the enum\'s method', () => { + expect(callees('s/Shapes.java', 'enumConstant')).toEqual(['s::Color::hex']); + expect(callees('s/Shapes.java', 'enumBody')).toEqual(['s::Color::GREEN::hex']); + }); + + it('reaches the method of an anonymous class the field holds', () => { + expect(callees('s/Shapes.java', 'anonymous')).toEqual(['s::Foo::CREATOR::::createFromParcel']); + }); + + it('links nothing for a library type\'s field', () => { + expect(callees('s/Shapes.java', 'library')).toEqual([]); + expect(callees('s/Shapes.java', 'libraryField')).toEqual([]); + expect(callees('s/Shapes.java', 'javaLang')).toEqual([]); + }); + + it('reaches a Kotlin object\'s member through INSTANCE', () => { + expect(callees('s/Shapes.java', 'kotlinObject')).toEqual(['s::KObj::go']); + }); +}); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index eb8c7b70e..fed6776b0 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -429,6 +429,10 @@ impl<'t> Walker<'t> { } fn is_static(&self, node: Node) -> bool { + // An interface constant is implicitly `public static final`. + if node.kind() == "constant_declaration" { + return true; + } for i in 0..node.child_count() { if let Some(child) = node.child(i) { if child.kind() == "modifiers" && self.text(child).contains("static") { @@ -441,6 +445,9 @@ impl<'t> Walker<'t> { /// javaExtractor.isConst: `static final` field → constant. fn is_const(&self, node: Node) -> bool { + if node.kind() == "constant_declaration" { + return true; + } for i in 0..node.child_count() { if let Some(child) = node.child(i) { if child.kind() == "modifiers" { @@ -510,7 +517,7 @@ impl<'t> Walker<'t> { } else if kind == "enum_declaration" { self.extract_enum(node); skip_children = true; - } else if kind == "field_declaration" && self.inside_class_like() { + } else if (kind == "field_declaration" || kind == "constant_declaration") && self.inside_class_like() { // The initializers extract_field walked under their fields took // their own candidates; the scan covers the rest. let walked = self.extract_field(node); diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index a54db1550..a7830a2d8 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -331,7 +331,8 @@ export const javaExtractor: LanguageExtractor = { importTypes: ['import_declaration'], callTypes: ['method_invocation'], variableTypes: ['local_variable_declaration'], - fieldTypes: ['field_declaration'], + // An interface's constants (`Helper HELPER = new Helper();`) are fields too. + fieldTypes: ['field_declaration', 'constant_declaration'], nameField: 'name', bodyField: 'body', paramsField: 'parameters', @@ -358,6 +359,8 @@ export const javaExtractor: LanguageExtractor = { return undefined; }, isStatic: (node) => { + // An interface constant is implicitly `public static final`. + if (node.type === 'constant_declaration') return true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child?.type === 'modifiers' && child.text.includes('static')) { @@ -370,6 +373,7 @@ export const javaExtractor: LanguageExtractor = { // shared config). Drives `constant` kind so value-reference edges target it; // instance / `final`-only / `static`-only fields stay mutable `field`s. isConst: (node) => { + if (node.type === 'constant_declaration') return true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child?.type === 'modifiers') { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7a5d027b8..041ee2d63 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -19811,6 +19811,123 @@ function resolveOnJavaType( return null; } +/** `Type.FIELD`, `Outer.Inner.FIELD`, `pkg.Type.FIELD`: a value a type holds, as a call's receiver. */ +const JAVA_STATIC_FIELD_RECEIVER = /^((?:[a-z_$][\w$]*\.)*(?:[A-Z][\w$]*\.)*[A-Z][\w$]*)\.([A-Za-z_$][\w$]*)$/; + +/** + * The static field `name` of Java type `owner`, its own or one it inherits + * from the types it extends or implements (an interface's constant), a few + * levels up. An enum's constant counts. + */ +function javaStaticField(owner: Node, name: string, context: ResolutionContext): Node | undefined { + const seen = new Set([owner.id]); + let frontier = [owner]; + for (let depth = 0; depth < 5 && frontier.length > 0; depth++) { + const next: Node[] = []; + for (const type of frontier) { + const own = context + .getNodesByQualifiedName(`${type.qualifiedName}::${name}`) + .find((n) => n.filePath === type.filePath && (n.kind === 'field' || n.kind === 'constant' || n.kind === 'enum_member')); + if (own) return own; + for (const sup of declaredSupertypes(type, context)) { + const written = javaTypeName(sup); + const ref = written ? javaTypeRef(written, type.filePath, context) : null; + const tail = written?.split('.').pop() ?? ''; + const decls = ref?.decl ? [ref.decl] + : ref ? jvmTypeDecls(tail.split('::').pop()!, context).filter((d) => d.qualifiedName === ref.name || d.qualifiedName.endsWith(`::${ref.name}`)) + : []; + for (const decl of decls) { + if (seen.has(decl.id)) continue; + seen.add(decl.id); + next.push(decl); + } + } + } + frontier = next; + } + return undefined; +} + +/** + * Java's `Type.FIELD.m()` — `Consts.HELPER.go()`, `Outer.Inner.SHARED.run()`, + * `ProjectInterpolators.LOCAL.getInterpolation(t)`, `Color.RED.hex()`: a call + * on a value a type holds in a static field, an interface's constant or an + * enum's constant. The field is the type's own or one it inherits, and its + * value is typed by its own declaration, read with its own file's imports: the + * method an anonymous class it is initialized with declares (a `CREATOR`'s + * `createFromParcel`), else the class a `new X(…)` initializer builds, else + * the declared type's. A Kotlin object's `INSTANCE` and a class's + * `Companion`, as Java writes them, are the type itself. Null — no edge — for + * a field of a type from outside the project (`Intent.ACTION_VIEW.equals(…)`) + * or a value of one; undefined when the receiver is no type's field (a local, + * a nested type), for the strategies below. + */ +function matchJavaStaticFieldCall( + receiver: string, + methodName: string, + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | undefined { + const m = JAVA_STATIC_FIELD_RECEIVER.exec(receiver); + if (!m) return undefined; + const [, ownerWritten, fieldName] = m; + const head = ownerWritten!.split('.')[0]!; + // A local or a field of that name holds a value, not a type. + if (/^[A-Z]/.test(head) && (inferLocalReceiverType(head, ref, context) || inferJavaFieldReceiverType(head, ref, context))) return undefined; + const written = javaTypeName(ownerWritten!); + if (!written) return undefined; + const ownerType = javaTypeRef(written, ref.filePath, context); + if (!ownerType) return null; + const owners = ownerType.decl ? [ownerType.decl] + : jvmTypeDecls(ownerType.name.split('::').pop()!, context).filter((d) => + (d.qualifiedName === ownerType.name || d.qualifiedName.endsWith(`::${ownerType.name}`)) && isJavaTypeNameable(d, ref.filePath, context)); + const owner = owners.length === 1 ? owners[0]! : owners.find((d) => d.filePath === ref.filePath); + if (!owner) return undefined; + const found = (n: Node | undefined): ResolvedRef | null => + n ? { original: ref, targetNodeId: n.id, confidence: 0.9, resolvedBy: 'instance-method' } : null; + const methodOf = (typeQn: string, file: string): Node | undefined => + context.getNodesByQualifiedName(`${typeQn}::${methodName}`).find((n) => n.kind === 'method' && n.filePath === file); + if (owner.language === 'kotlin' && (fieldName === 'INSTANCE' || fieldName === 'Companion')) { + return found(methodOf(owner.qualifiedName, owner.filePath)); + } + // `Outer.Inner.m()` is a static call on a nested type, not on a field. + if (context.getNodesByQualifiedName(`${owner.qualifiedName}::${fieldName}`).some((n) => JVM_DECLARED_TYPE_KINDS.has(n.kind))) return undefined; + const field = javaStaticField(owner, fieldName!, context); + if (!field || field.language !== 'java') return undefined; + if (field.kind === 'enum_member') { + // A constant's own body overrides first (`PLUS { int apply(…) }`), then the enum's method. + const enumQn = field.qualifiedName.slice(0, field.qualifiedName.lastIndexOf('::')); + const enumDecl = context.getNodesByQualifiedName(enumQn).find((n) => n.filePath === field.filePath && JVM_DECLARED_TYPE_KINDS.has(n.kind)); + const segments = enumQn.split('::'); + const chain = segments.length > 1 && /^[a-z]/.test(segments[0]!) ? segments.slice(1).join('::') : enumQn; + return found(methodOf(field.qualifiedName, field.filePath) ?? methodOf(enumQn, field.filePath)) ?? + (enumDecl ? resolveOnJavaType({ name: chain, decl: enumDecl }, methodName, ref, context) : null); + } + // An anonymous class the field is initialized with: its own method. + const anonMethod = context + .getNodesInFile(field.filePath) + .filter((n) => n.kind === 'class' && n.qualifiedName.startsWith(`${field.qualifiedName}::<`) && n.name.includes('$anon@')) + .map((anon) => methodOf(anon.qualifiedName, field.filePath)) + .find((n) => n !== undefined); + if (anonMethod) return found(anonMethod); + // A `new X(…)` initializer builds X, the declared type's subtype at most. + const lines = context.getFileLines?.(field.filePath) ?? context.readFile(field.filePath)?.split(/\r?\n/) ?? []; + const declText = lines.slice(field.startLine - 1, Math.min(lines.length, (field.endLine ?? field.startLine))).join('\n'); + const built = new RegExp(`\\b${field.name.replace(/\$/g, '\\$')}\\s*=\\s*new\\s+((?:[\\w$]+\\s*\\.\\s*)*[\\w$]+)\\s*(?:<[^()]*>)?\\s*\\(`).exec(declText)?.[1]?.replace(/\s+/g, ''); + if (built) { + const builtName = javaTypeName(built); + const builtType = builtName ? javaTypeRef(builtName, field.filePath, context) : null; + if (builtType) { + const viaBuilt = resolveOnJavaType(builtType, methodName, ref, context); + if (viaBuilt) return viaBuilt; + } + } + const declared = fieldSignatureType(field); + if (!declared) return null; + const declaredType = javaTypeRef(declared, field.filePath, context); + return declaredType ? resolveOnJavaType(declaredType, methodName, ref, context) : 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 @@ -22335,6 +22452,13 @@ export function matchMethodCall( if (typed !== undefined) rustBindingLine = typed.unread; } + // A Java call on a value a type holds in a static field, `Consts.HELPER.go()`: + // typed by the field's declaration, or no edge for a library type's field. + if (ref.language === 'java' && dotMatch) { + const onField = nmTimedT('mc-javastaticfield', ref, () => matchJavaStaticFieldCall(objectOrClass!, methodName!, ref, context)); + if (onField !== undefined) return onField; + } + // Infer the receiver's type from its local declaration/initializer in the // enclosing scope, then resolve the method on that type (#1108). C++ keeps its // dedicated inferrer (header scan + `auto`); every other language uses the