diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779..9565bfef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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("/me")` or `await api.get(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()` or `render()`, 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) diff --git a/__tests__/fixtures/kernel-parity/TortureRecord.java b/__tests__/fixtures/kernel-parity/TortureRecord.java new file mode 100644 index 000000000..5107fa57c --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureRecord.java @@ -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, 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 tags) { + String first() { return tags.get(0); } + } +} + +record Pair(A first, B second) { + A left() { return first; } +} + +@Builder +record Config(@Deprecated String host, int[] ports, java.util.Map limits, String... rest) {} + +interface Shape { double norm(); } + +class Client { + private Point.Inner inner; + + String use(Point p, Pair 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(); + } +} diff --git a/__tests__/java-records.test.ts b/__tests__/java-records.test.ts new file mode 100644 index 000000000..a4960681e --- /dev/null +++ b/__tests__/java-records.test.ts @@ -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 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 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); + }); +}); diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index f408f822a..a6911eef8 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -398,6 +398,11 @@ export const Box = () =>
; 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'); @@ -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) => { diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 42c30f76e..454901aab 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -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(); } @@ -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, HashSet) { + let class_qn = self.nodes_meta[class_row as usize].qualified_name.clone(); + let mut taken_methods: HashSet = HashSet::new(); + let mut taken_fields: HashSet = 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"); @@ -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 = HashSet::new(); - let mut taken_fields: HashSet = 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); diff --git a/src/extraction/languages/java.ts b/src/extraction/languages/java.ts index 5db825890..62e26b0ef 100644 --- a/src/extraction/languages/java.ts +++ b/src/extraction/languages/java.ts @@ -250,10 +250,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(); + const takenFields = new Set(); + 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 component accessor', + 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 +336,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');