Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions __tests__/fixtures/kernel-parity/TortureRecord.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
207 changes: 207 additions & 0 deletions __tests__/java-records.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
6 changes: 6 additions & 0 deletions __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) => {
Expand Down
Loading