mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-25 03:49:59 +08:00
fix(java): a record is indexed as a class, with its components' fields and accessors
Neither engine treated record_declaration as a class type, so a Java record
left no node of its own. Its methods and explicit constructor became
package-level functions (p::norm, p::Point), a nested record's methods were
filed under the class around it, compact constructors, static fields and
records nested in a record were dropped, and calls on a record-typed value
resolved to nothing or to a same-named member of another type.
The Java extractor and the kernel now treat record_declaration as a class
and a compact constructor as a method. Each component becomes a private
field ("<Type> <name>") and a public accessor name() with the component's
type as its return type, unless the body declares it; they are synthesized
before Lombok's members, the same way.
An implicit accessor is reached only through what types its receiver: a
typed receiver, a method reference, a qualified call, or a call from inside
the record. The name-only strategies (the receiver-word guesses, exact-name
and fuzzy matching) no longer settle on one, so result.content() on a
library type stops linking to whichever record has a content component.
Records usually sit in sealed hierarchies, and that exposed a resolver gap:
the member-type reader took the head "sealed interface Blocks permits Block"
for a member named Block of type "permits", typed the receiver of
Block.fromKey(...) inside the record as a built-in and dropped the call.
"permits" and "throws" are now among the words that are not a type there.
Validation:
- java-records.test.ts, 5 cases on both engines (10 tests), all red on
main; the sealed-permits and name-only-guess cases are each red without
their resolver line.
- Kernel parity: TortureRecord.java in the torture and CRLF runs; real-repo
sweeps at full parity with deferrals unchanged (jextract 218/218,
spring-modulith 682/682, playframework 801/801, kestra 2997/2997,
spring-ai 2491/2492, Minestom 1859/1894).
- A/B against main d02a88d0, every bucket triaged by sampling (edge sites
added/removed): jextract +207/-1, spring-modulith +375/-1, playframework
+80/-2, Minestom +17,963/-287, spring-ai +8,806/-471, kestra
+10,077/-497; most retargets are a constructor function becoming its
record. The accessor rule drops only name-only results (spring-ai 1,063,
kestra 765, Minestom 408 sites at 0.65-0.8 confidence); no typed call,
method reference or qualified call moves. click and gin byte-identical.
- Cost (process CPU, median of 3 interleaved indexes): Minestom
47.2s -> 50.3s (+6.6%, for 17% more nodes and 23% more edges),
spring-ai 93.5s -> 94.8s (+1.4%).
Original PR #1946 by @danusha2345.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Colby Mchenry
co-authored by
Claude Opus 5.5
parent
a565f215f1
commit
3a93f57ae1
@@ -222,6 +222,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List<String>, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663)
|
||||
- In Java and TypeScript, a class that implements or extends a parameterized type, like `implements Store<String>`, `extends Base<Integer>`, an interface's `extends Store<T>` or TypeScript's `implements Store<string>`, 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 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)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.records;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
|
||||
/** A 2-D point. */
|
||||
public record Point(int x, int y) implements Comparable<Point>, Shape {
|
||||
public static final Point ORIGIN = new Point(0, 0);
|
||||
private static int created;
|
||||
|
||||
public Point {
|
||||
if (x < 0) throw new IllegalArgumentException("x");
|
||||
created++;
|
||||
}
|
||||
|
||||
public Point(int v) { this(v, v); }
|
||||
|
||||
/** Explicit accessor — no implicit one is minted. */
|
||||
public int x() { return x; }
|
||||
|
||||
public double norm() { return Math.sqrt(x * x + y * y); }
|
||||
|
||||
@Override
|
||||
public int compareTo(Point o) { return Double.compare(norm(), o.norm()); }
|
||||
|
||||
record Inner(String name, List<String> tags) {
|
||||
String first() { return tags.get(0); }
|
||||
}
|
||||
}
|
||||
|
||||
record Pair<A, B>(A first, B second) {
|
||||
A left() { return first; }
|
||||
}
|
||||
|
||||
@Builder
|
||||
record Config(@Deprecated String host, int[] ports, java.util.Map<String, Integer> limits, String... rest) {}
|
||||
|
||||
interface Shape { double norm(); }
|
||||
|
||||
class Client {
|
||||
private Point.Inner inner;
|
||||
|
||||
String use(Point p, Pair<String, Integer> pr) {
|
||||
record Local(int n) { int twice() { return n * 2; } }
|
||||
Local l = new Local(3);
|
||||
Config c = Config.builder().build();
|
||||
return inner.first() + p.y() + pr.left() + l.twice() + c.host() + Point.ORIGIN.norm();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Java `record` declarations are first-class types.
|
||||
*
|
||||
* A record is a class: its body holds methods, constructors (including the
|
||||
* compact `Point { … }` form), static fields and nested types, and its header
|
||||
* declares components — each an implicit private field plus a public accessor
|
||||
* `x()`. Before, the grammar's `record_declaration` was not a class type, so a
|
||||
* record produced no node, its methods were orphaned onto the package, its
|
||||
* `implements` were lost, and `p.norm()` / `info.remoteAddress()` on a
|
||||
* record-typed receiver resolved to nothing (or to an interface's method).
|
||||
*
|
||||
* Runs through both extraction paths: the wasm extractor and the native
|
||||
* kernel (default routing when a kernel is staged).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
type NodeRow = { kind: string; name: string; qualified_name: string; signature: string | null; docstring: string | null };
|
||||
type EdgeRow = { kind: string; src: string; tgt: string; tgtKind: string };
|
||||
|
||||
describe.each([
|
||||
['wasm', '0'],
|
||||
['default routing', undefined],
|
||||
] as const)('Java records (%s)', (_label, kernelEnv) => {
|
||||
let dir: string;
|
||||
let savedKernel: string | undefined;
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'java-records-'));
|
||||
savedKernel = process.env.CODEGRAPH_KERNEL;
|
||||
if (kernelEnv === undefined) delete process.env.CODEGRAPH_KERNEL;
|
||||
else process.env.CODEGRAPH_KERNEL = kernelEnv;
|
||||
});
|
||||
afterEach(() => {
|
||||
if (savedKernel === undefined) delete process.env.CODEGRAPH_KERNEL;
|
||||
else process.env.CODEGRAPH_KERNEL = savedKernel;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const write = (rel: string, body: string) => {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, body);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
const nodes: NodeRow[] = db
|
||||
.prepare(`SELECT kind, name, qualified_name, signature, docstring FROM nodes`)
|
||||
.all();
|
||||
const edges: EdgeRow[] = db
|
||||
.prepare(
|
||||
`SELECT e.kind kind, s.qualified_name src, t.qualified_name tgt, t.kind tgtKind FROM edges e
|
||||
JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target`
|
||||
)
|
||||
.all();
|
||||
cg.close?.();
|
||||
const has = (kind: string, src: string, tgt: string) =>
|
||||
edges.some((e) => e.kind === kind && e.src === src && e.tgt === tgt);
|
||||
const node = (qn: string, kind: string) => nodes.filter((n) => n.qualified_name === qn && n.kind === kind);
|
||||
return { nodes, edges, has, node };
|
||||
};
|
||||
|
||||
const fixture = () => {
|
||||
write('p/Shape.java', `package p;
|
||||
public interface Shape { double norm(); }
|
||||
`);
|
||||
write('p/Point.java', `package p;
|
||||
|
||||
public record Point(int x, int y) implements Shape {
|
||||
public static final Point ORIGIN = new Point(0, 0);
|
||||
public Point {
|
||||
if (x < 0) throw new IllegalArgumentException();
|
||||
}
|
||||
public Point(int v) { this(v, v); }
|
||||
public double norm() { return Math.sqrt(x * x + y * y); }
|
||||
record Inner(String name) {}
|
||||
}
|
||||
`);
|
||||
write('p/Pair.java', `package p;
|
||||
public record Pair<A, B>(A first, B second) {
|
||||
public A left() { return first; }
|
||||
}
|
||||
`);
|
||||
write('p/Http.java', `package p;
|
||||
public class Http {
|
||||
public record RemoteInfo(String remoteAddress, int port) {
|
||||
public String describe() { return remoteAddress + ":" + port; }
|
||||
public String remoteAddress() { return remoteAddress.trim(); }
|
||||
}
|
||||
private RemoteInfo info;
|
||||
public String use(Point p, RemoteInfo param) {
|
||||
Point local = new Point(1, 2);
|
||||
double a = local.norm();
|
||||
double b = p.norm();
|
||||
String c = info.remoteAddress();
|
||||
String d = param.describe();
|
||||
int e = p.x();
|
||||
Pair<String, Integer> pr = new Pair<>("a", 1);
|
||||
String f = pr.left();
|
||||
return c + d + a + b + e + f;
|
||||
}
|
||||
}
|
||||
`);
|
||||
};
|
||||
|
||||
it('indexes a record as a class that contains its members and implements its interfaces', async () => {
|
||||
fixture();
|
||||
const { node, has } = await load();
|
||||
expect(node('p::Point', 'class')).toHaveLength(1);
|
||||
expect(node('p::Pair', 'class')).toHaveLength(1);
|
||||
// Nested records qualify under their outer type.
|
||||
expect(node('p::Http::RemoteInfo', 'class')).toHaveLength(1);
|
||||
expect(node('p::Point::Inner', 'class')).toHaveLength(1);
|
||||
expect(has('contains', 'p::Http', 'p::Http::RemoteInfo')).toBe(true);
|
||||
expect(has('contains', 'p::Point', 'p::Point::Inner')).toBe(true);
|
||||
|
||||
// Body members hang off the record, not the package.
|
||||
expect(has('contains', 'p::Point', 'p::Point::norm')).toBe(true);
|
||||
expect(node('p::norm', 'function')).toHaveLength(0);
|
||||
expect(node('p::Point::ORIGIN', 'constant')).toHaveLength(1);
|
||||
// Both the compact and the explicit constructor.
|
||||
expect(node('p::Point::Point', 'method')).toHaveLength(2);
|
||||
|
||||
expect(has('implements', 'p::Point', 'p::Shape')).toBe(true);
|
||||
});
|
||||
|
||||
it('synthesizes a field and an accessor per component, never shadowing a declared accessor', async () => {
|
||||
fixture();
|
||||
const { node } = await load();
|
||||
expect(node('p::Point::x', 'field').map((n) => n.signature)).toEqual(['int x']);
|
||||
expect(node('p::Point::x', 'method').map((n) => n.signature)).toEqual(['int x()']);
|
||||
expect(node('p::Pair::first', 'field').map((n) => n.signature)).toEqual(['A first']);
|
||||
expect(node('p::Point::Inner::name', 'method')).toHaveLength(1);
|
||||
|
||||
// RemoteInfo declares remoteAddress() itself — exactly one, the hand-written one.
|
||||
const accessor = node('p::Http::RemoteInfo::remoteAddress', 'method');
|
||||
expect(accessor).toHaveLength(1);
|
||||
expect(accessor[0]!.docstring).toBeNull();
|
||||
expect(node('p::Http::RemoteInfo::port', 'method')[0]!.docstring).toBe('Implicit record component accessor');
|
||||
});
|
||||
|
||||
it('resolves calls on record-typed locals, fields and parameters to the record', async () => {
|
||||
fixture();
|
||||
const { has } = await load();
|
||||
expect(has('calls', 'p::Http::use', 'p::Point::norm')).toBe(true); // local + param
|
||||
expect(has('calls', 'p::Http::use', 'p::Shape::norm')).toBe(false);
|
||||
expect(has('calls', 'p::Http::use', 'p::Http::RemoteInfo::remoteAddress')).toBe(true); // field
|
||||
expect(has('calls', 'p::Http::use', 'p::Http::RemoteInfo::describe')).toBe(true); // param
|
||||
expect(has('calls', 'p::Http::use', 'p::Point::x')).toBe(true); // implicit accessor
|
||||
expect(has('calls', 'p::Http::use', 'p::Pair::left')).toBe(true); // generic record
|
||||
expect(has('instantiates', 'p::Http::use', 'p::Point')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a call on the record type itself when a sealed interface permits it first', async () => {
|
||||
// `permits Circle,` in the interface's head is not a member named `Circle`
|
||||
// of type `permits`: read as one, it typed the receiver of `Circle.unit()`
|
||||
// as a built-in and dropped the call, now that the record encloses it.
|
||||
write('p/Shape.java', `package p;
|
||||
public sealed interface Shape permits Circle, Square {
|
||||
double area();
|
||||
}
|
||||
`);
|
||||
write('p/Circle.java', `package p;
|
||||
public record Circle(double r) implements Shape {
|
||||
static Circle unit() { return new Circle(1); }
|
||||
public double area() { return r * r; }
|
||||
Circle twice() { return Circle.unit(); }
|
||||
}
|
||||
`);
|
||||
write('p/Square.java', `package p;
|
||||
public record Square(double side) implements Shape {
|
||||
public double area() { return side * side; }
|
||||
}
|
||||
`);
|
||||
const { has } = await load();
|
||||
expect(has('calls', 'p::Circle::twice', 'p::Circle::unit')).toBe(true);
|
||||
expect(has('implements', 'p::Circle', 'p::Shape')).toBe(true);
|
||||
});
|
||||
|
||||
it('reaches an implicit accessor through a typed receiver, never by a name-only guess', async () => {
|
||||
// `point` in the lambda has no type the resolver can read; its name
|
||||
// matching the record's is no evidence that it is one.
|
||||
write('p/Point.java', `package p;
|
||||
public record Point(int x, int y) {
|
||||
int sum() { return x() + y(); }
|
||||
}
|
||||
`);
|
||||
write('p/Use.java', `package p;
|
||||
import java.util.List;
|
||||
public class Use {
|
||||
int typed(Point p) { return p.x(); }
|
||||
void guessed(List<Object> points) { points.forEach(point -> point.x()); }
|
||||
int ref(List<Point> ps) { return ps.stream().mapToInt(Point::y).sum(); }
|
||||
}
|
||||
`);
|
||||
const { has } = await load();
|
||||
expect(has('calls', 'p::Use::typed', 'p::Point::x')).toBe(true);
|
||||
expect(has('references', 'p::Use::ref', 'p::Point::y')).toBe(true);
|
||||
expect(has('calls', 'p::Point::sum', 'p::Point::x')).toBe(true);
|
||||
expect(has('calls', 'p::Use::guessed', 'p::Point::x')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -409,6 +409,11 @@ export const Box = () => <div data-size={THEME_TOKENS.size} />;
|
||||
assertParity('fixtures/Torture.java', fs.readFileSync(file, 'utf8'), 'java');
|
||||
});
|
||||
|
||||
it('torture fixture (java records): components, compact ctors, nested/local/generic records', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'TortureRecord.java');
|
||||
assertParity('fixtures/TortureRecord.java', fs.readFileSync(file, 'utf8'), 'java');
|
||||
});
|
||||
|
||||
it('torture fixture (python): decorators, self fn-refs, imports, shadowing', () => {
|
||||
const file = path.join(FIXTURE_DIR, 'torture.py');
|
||||
assertParity('fixtures/torture.py', fs.readFileSync(file, 'utf8'), 'python');
|
||||
@@ -503,6 +508,7 @@ func wire(s *Store) {
|
||||
['torture.tsx', 'tsx'],
|
||||
['torture.js', 'javascript'],
|
||||
['Torture.java', 'java'],
|
||||
['TortureRecord.java', 'java'],
|
||||
['torture.py', 'python'],
|
||||
['torture.go', 'go'],
|
||||
] as const)('torture fixture CRLF parity: %s', (name, lang) => {
|
||||
|
||||
@@ -23,7 +23,11 @@ use tree_sitter::{Node, Parser};
|
||||
const MAX_VALUE_REF_NODES: usize = 20_000;
|
||||
|
||||
fn is_method_type(kind: &str) -> bool {
|
||||
matches!(kind, "method_declaration" | "constructor_declaration")
|
||||
matches!(kind, "method_declaration" | "constructor_declaration" | "compact_constructor_declaration")
|
||||
}
|
||||
/// classTypes (languages/java.ts): a `record` is a class.
|
||||
fn is_class_type(kind: &str) -> bool {
|
||||
matches!(kind, "class_declaration" | "record_declaration")
|
||||
}
|
||||
fn is_interface_type(kind: &str) -> bool {
|
||||
matches!(kind, "interface_declaration" | "annotation_type_declaration")
|
||||
@@ -494,7 +498,7 @@ impl<'t> Walker<'t> {
|
||||
|
||||
self.maybe_capture_fn_refs(node);
|
||||
|
||||
if kind == "class_declaration" {
|
||||
if is_class_type(kind) {
|
||||
self.extract_class(node);
|
||||
skip_children = true;
|
||||
} else if is_method_type(kind) {
|
||||
@@ -562,7 +566,7 @@ impl<'t> Walker<'t> {
|
||||
// Static-member / value-read (`Type.CONST`) — self-gates on field_access.
|
||||
self.extract_static_member_ref(node);
|
||||
|
||||
if kind == "class_declaration" {
|
||||
if is_class_type(kind) {
|
||||
self.extract_class(node);
|
||||
return;
|
||||
}
|
||||
@@ -603,7 +607,11 @@ impl<'t> Walker<'t> {
|
||||
self.visit_node(c);
|
||||
}
|
||||
}
|
||||
// Lombok member synthesis (#912) — class still on the stack.
|
||||
// synthesizeJavaMembers — class still on the stack: record
|
||||
// components first, then Lombok (#912).
|
||||
if node.kind() == "record_declaration" {
|
||||
self.synthesize_record_components(node, row);
|
||||
}
|
||||
self.synthesize_lombok_members(node, row);
|
||||
self.stack.pop();
|
||||
}
|
||||
@@ -1418,6 +1426,82 @@ impl<'t> Walker<'t> {
|
||||
names
|
||||
}
|
||||
|
||||
/// Members already declared directly in a class (exact `classQN::name`
|
||||
/// matches), split into the method and field namespaces.
|
||||
fn declared_member_names(&self, class_row: u32) -> (HashSet<String>, HashSet<String>) {
|
||||
let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone();
|
||||
let mut taken_methods: HashSet<String> = HashSet::new();
|
||||
let mut taken_fields: HashSet<String> = HashSet::new();
|
||||
for m in &self.nodes_meta {
|
||||
if m.qualified_name == format!("{class_qn}::{}", m.name) {
|
||||
match m.kind {
|
||||
"method" | "function" => {
|
||||
taken_methods.insert(m.name.clone());
|
||||
}
|
||||
"field" | "variable" | "constant" | "property" => {
|
||||
taken_fields.insert(m.name.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(taken_methods, taken_fields)
|
||||
}
|
||||
|
||||
/// synthesizeRecordComponents (languages/java.ts): per record component a
|
||||
/// private field and a public accessor, anchored on the component's name.
|
||||
fn synthesize_record_components(&mut self, record_node: Node<'t>, record_row: u32) {
|
||||
let Some(params) = record_node.child_by_field_name("parameters") else { return };
|
||||
let (mut taken_methods, mut taken_fields) = self.declared_member_names(record_row);
|
||||
for i in 0..params.named_child_count() {
|
||||
let Some(param) = params.named_child(i) else { continue };
|
||||
if param.kind() != "formal_parameter" {
|
||||
continue;
|
||||
}
|
||||
let (Some(name_node), Some(type_node)) =
|
||||
(param.child_by_field_name("name"), param.child_by_field_name("type"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = self.text(name_node).trim().to_string();
|
||||
let type_text = self.text(type_node).trim().to_string();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !taken_fields.contains(&name) {
|
||||
taken_fields.insert(name.clone());
|
||||
self.create_node(
|
||||
"field",
|
||||
&name,
|
||||
name_node,
|
||||
Extra {
|
||||
visibility: Some(2),
|
||||
is_static: Some(false),
|
||||
signature: Some(format!("{type_text} {name}")),
|
||||
..Extra::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
if !taken_methods.contains(&name) {
|
||||
taken_methods.insert(name.clone());
|
||||
let return_type = self.normalize_java_type(Some(type_node));
|
||||
self.create_node(
|
||||
"method",
|
||||
&name,
|
||||
name_node,
|
||||
Extra {
|
||||
visibility: Some(1),
|
||||
is_static: Some(false),
|
||||
signature: Some(format!("{type_text} {name}()")),
|
||||
docstring: Some("Implicit record component accessor".to_string()),
|
||||
return_type,
|
||||
..Extra::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn synthesize_lombok_members(&mut self, class_node: Node<'t>, class_row: u32) {
|
||||
let class_anns = self.lombok_annotation_names(class_node);
|
||||
let class_getter = class_anns.contains("Getter");
|
||||
@@ -1448,23 +1532,8 @@ impl<'t> Walker<'t> {
|
||||
}
|
||||
|
||||
// Members the source already declares (exact `classQN::name` matches).
|
||||
let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone();
|
||||
let class_name = self.nodes_meta[class_row as usize].name.clone();
|
||||
let mut taken_methods: HashSet<String> = HashSet::new();
|
||||
let mut taken_fields: HashSet<String> = HashSet::new();
|
||||
for m in &self.nodes_meta {
|
||||
if m.qualified_name == format!("{class_qn}::{}", m.name) {
|
||||
match m.kind {
|
||||
"method" | "function" => {
|
||||
taken_methods.insert(m.name.clone());
|
||||
}
|
||||
"field" | "variable" | "constant" | "property" => {
|
||||
taken_fields.insert(m.name.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut taken_methods, taken_fields) = self.declared_member_names(class_row);
|
||||
|
||||
let class_name_node = class_node.child_by_field_name("name").unwrap_or(class_node);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Node as SyntaxNode } from 'web-tree-sitter';
|
||||
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
|
||||
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
|
||||
import { IMPLICIT_RECORD_ACCESSOR_DOC } from '../../types';
|
||||
|
||||
/**
|
||||
* Tree-sitter-java node types for a method's `type` (return) field that can
|
||||
@@ -250,10 +251,74 @@ function synthesizeLombokMembers(classNode: SyntaxNode, ctx: ExtractorContext):
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record component members
|
||||
// ---------------------------------------------------------------------------
|
||||
// A record header `record Point(int x, int y)` declares, per component, a
|
||||
// private final field and a public accessor `x()` — neither appears in the
|
||||
// body, so without them `p.x()` resolves to nothing and a component's type
|
||||
// can't type a receiver. Each is anchored on the component's name token (a
|
||||
// leaf, like the Lombok members). The canonical constructor is not
|
||||
// synthesized: `new Point(…)` already links to the record via `instantiates`.
|
||||
// Varargs components (`String... rest`) are skipped.
|
||||
function synthesizeRecordComponents(recordNode: SyntaxNode, ctx: ExtractorContext): void {
|
||||
const params = getChildByField(recordNode, 'parameters');
|
||||
if (!params) return;
|
||||
const recordId = ctx.nodeStack[ctx.nodeStack.length - 1];
|
||||
const recordQN = ctx.nodes.find((n) => n.id === recordId)?.qualifiedName;
|
||||
const takenMethods = new Set<string>();
|
||||
const takenFields = new Set<string>();
|
||||
if (recordQN) {
|
||||
for (const n of ctx.nodes) {
|
||||
if (n.filePath === ctx.filePath && n.qualifiedName === `${recordQN}::${n.name}`) {
|
||||
if (n.kind === 'method' || n.kind === 'function') takenMethods.add(n.name);
|
||||
else if (n.kind === 'field' || n.kind === 'variable' || n.kind === 'constant' || n.kind === 'property') {
|
||||
takenFields.add(n.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const param of params.namedChildren) {
|
||||
if (param.type !== 'formal_parameter') continue;
|
||||
const nameNode = getChildByField(param, 'name');
|
||||
const typeNode = getChildByField(param, 'type');
|
||||
if (!nameNode || !typeNode) continue;
|
||||
const name = getNodeText(nameNode, ctx.source).trim();
|
||||
const typeText = getNodeText(typeNode, ctx.source).trim();
|
||||
if (!name) continue;
|
||||
if (!takenFields.has(name)) {
|
||||
takenFields.add(name);
|
||||
ctx.createNode('field', name, nameNode, {
|
||||
visibility: 'private',
|
||||
isStatic: false,
|
||||
signature: `${typeText} ${name}`,
|
||||
});
|
||||
}
|
||||
if (!takenMethods.has(name)) {
|
||||
takenMethods.add(name);
|
||||
ctx.createNode('method', name, nameNode, {
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
signature: `${typeText} ${name}()`,
|
||||
docstring: IMPLICIT_RECORD_ACCESSOR_DOC,
|
||||
returnType: normalizeJavaType(typeNode, ctx.source),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeJavaMembers(classNode: SyntaxNode, ctx: ExtractorContext): void {
|
||||
if (classNode.type === 'record_declaration') synthesizeRecordComponents(classNode, ctx);
|
||||
synthesizeLombokMembers(classNode, ctx);
|
||||
}
|
||||
|
||||
export const javaExtractor: LanguageExtractor = {
|
||||
functionTypes: [],
|
||||
classTypes: ['class_declaration'],
|
||||
methodTypes: ['method_declaration', 'constructor_declaration'],
|
||||
// `record_declaration` (Java 16+) is a class: its body holds methods,
|
||||
// constructors, static fields and nested types like any class body.
|
||||
classTypes: ['class_declaration', 'record_declaration'],
|
||||
// `compact_constructor_declaration` is a record's `Point { … }` constructor.
|
||||
methodTypes: ['method_declaration', 'constructor_declaration', 'compact_constructor_declaration'],
|
||||
// `annotation_type_declaration` is `@interface Foo { … }` — an annotation
|
||||
// definition. Without it, annotation types (`@SerializedName`, `@GetMapping`,
|
||||
// JPA/Spring annotations) aren't nodes, so the `@Foo` usages that DO get
|
||||
@@ -272,7 +337,7 @@ export const javaExtractor: LanguageExtractor = {
|
||||
paramsField: 'parameters',
|
||||
returnField: 'type',
|
||||
getReturnType: extractJavaReturnType,
|
||||
synthesizeMembers: synthesizeLombokMembers,
|
||||
synthesizeMembers: synthesizeJavaMembers,
|
||||
getSignature: (node, source) => {
|
||||
const params = getChildByField(node, 'parameters');
|
||||
const returnType = getChildByField(node, 'type');
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
isImportableKind,
|
||||
CPP_DEFINE_SIGNATURE,
|
||||
} from './types';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE } from './name-matcher';
|
||||
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor } from './name-matcher';
|
||||
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
|
||||
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
|
||||
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
|
||||
@@ -1465,6 +1465,8 @@ export class ReferenceResolver {
|
||||
const module = this.nodeById(resolved.targetNodeId);
|
||||
if (module && module.kind !== 'file' && module.kind !== 'module') return null;
|
||||
}
|
||||
if (resolved && (resolved.byName || resolved.resolvedBy === 'exact-match' || resolved.resolvedBy === 'fuzzy') &&
|
||||
isGuessedRecordAccessor(resolved, ref, this.context)) return null;
|
||||
// A name the calling JS/TS function binds itself (a parameter, a local) in
|
||||
// scope at the reference is that binding even when the file imports the
|
||||
// name, which the import resolver answers before the matcher's own check.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Language, Node } from '../types';
|
||||
import { Language, Node, IMPLICIT_RECORD_ACCESSOR_DOC } from '../types';
|
||||
import { escapeRegExp } from '../utils';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
@@ -2155,6 +2155,21 @@ function luaModuleFile(name: string, decl: Node, context: ResolutionContext): st
|
||||
*/
|
||||
const PRIVATE_IS_FILE_LOCAL = new Set<string>(['kotlin', 'java', 'csharp', 'swift', 'scala', 'php']);
|
||||
|
||||
/**
|
||||
* Whether a resolution lands on a Java record's implicit accessor from outside
|
||||
* the record. One is reached through what types its receiver (a typed
|
||||
* receiver, `Record::x`, `Record.x`) or from inside the record; a name-only
|
||||
* guess bound `result.content()` on a library type to any record with a
|
||||
* `content` component. Applied by ReferenceResolver to name-only results.
|
||||
*/
|
||||
export function isGuessedRecordAccessor(resolved: ResolvedRef, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const target = context.getNodeById?.(resolved.targetNodeId);
|
||||
if (!target || target.kind !== 'method' || target.docstring !== IMPLICIT_RECORD_ACCESSOR_DOC) return false;
|
||||
const record = target.qualifiedName.slice(0, target.qualifiedName.lastIndexOf('::'));
|
||||
return ref.filePath !== target.filePath || !context.getNodesInFile(target.filePath).some((n) =>
|
||||
n.kind === 'class' && n.qualifiedName === record && n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line);
|
||||
}
|
||||
|
||||
/** Per-context memo: node id → "this C/C++ function is declared `static`". */
|
||||
const C_STATIC_MEMO = new WeakMap<ResolutionContext, Map<string, boolean>>();
|
||||
|
||||
@@ -18454,6 +18469,8 @@ const MEMBER_TYPE_NON_TYPES: ReadonlySet<string> = new Set([
|
||||
'var', 'val', 'goto', 'nameof', 'typeof', 'sizeof', 'default', 'when', 'where', 'get', 'set', 'init',
|
||||
'class', 'interface', 'enum', 'struct', 'record', 'object', 'namespace', 'package', 'import', 'extends',
|
||||
'implements', 'fun', 'static', 'final', 'abstract', 'sealed', 'override', 'virtual', 'delegate', 'event',
|
||||
// Java's `sealed interface Shape permits Circle, Square` and `void run() throws Failure {`.
|
||||
'permits', 'throws',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -20434,6 +20451,7 @@ export function matchMethodCall(
|
||||
targetNodeId: methodNode.id,
|
||||
confidence: 0.8,
|
||||
resolvedBy: 'instance-method',
|
||||
byName: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20556,6 +20574,7 @@ export function matchMethodCall(
|
||||
targetNodeId: targetMethods[0]!.id,
|
||||
confidence: 0.7,
|
||||
resolvedBy: 'instance-method',
|
||||
byName: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20640,6 +20659,7 @@ export function matchMethodCall(
|
||||
targetNodeId: bestMatch.id,
|
||||
confidence: 0.65,
|
||||
resolvedBy: 'instance-method',
|
||||
byName: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface ResolvedRef {
|
||||
edgeKind?: EdgeKind;
|
||||
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
|
||||
metadata?: Record<string, unknown>;
|
||||
/** Picked by a name-only strategy (the receiver's word, not its type). Not persisted. */
|
||||
byName?: boolean;
|
||||
/**
|
||||
* The OTHER targets, when one reference names several.
|
||||
*
|
||||
|
||||
@@ -47,6 +47,13 @@ export const NODE_KINDS = [
|
||||
|
||||
export type NodeKind = (typeof NODE_KINDS)[number];
|
||||
|
||||
/**
|
||||
* The docstring both engines give a Java record's implicit component accessor
|
||||
* (languages/java.ts, codegraph-kernel/src/java.rs). The resolver reaches one
|
||||
* only through what types its receiver, never by a name-only guess.
|
||||
*/
|
||||
export const IMPLICIT_RECORD_ACCESSOR_DOC = 'Implicit record component accessor';
|
||||
|
||||
/**
|
||||
* Types of edges (relationships) between nodes.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user