Skip to content
Closed
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 @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- Java `record` declarations are now indexed as classes, with their methods, constructors, components and implicit accessors, so calls on a record-typed value like `info.remoteAddress()` resolve and records show up in callers, impact and implementations; re-index Java projects after upgrading.
- In TypeScript, an awaited call with type arguments like `await fetchApi<User>("/me")` or `await api.get<T>(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586)
- In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
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();
}
}
158 changes: 158 additions & 0 deletions __tests__/java-records.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/**
* 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);
});
});
6 changes: 6 additions & 0 deletions __tests__/kernel-tsjs-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,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 @@ -492,6 +497,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
109 changes: 89 additions & 20 deletions codegraph-kernel/src/java.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1404,6 +1412,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");
Expand Down Expand Up @@ -1434,23 +1518,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);

Expand Down
Loading