feat(java): synthesize Lombok @Data/@Getter/@Setter accessor methods

This commit is contained in:
ChunxueLi 2026-08-08 06:55:00 +08:00 committed by ChunxueLi
parent ac68f5254c
commit e30a4f22d1
3 changed files with 757 additions and 0 deletions

View file

@ -0,0 +1,439 @@
/**
* Lombok method synthesizer for Java.
*
* Lombok generates accessor methods (getters/setters) at compile time via
* annotation processors. These methods are absent from the AST every call
* to `obj.getOrderId()` on a `@Data` class is an unresolved call edge in the
* static graph.
*
* This module walks the tree-sitter Java AST and, for each class annotated
* with `@Getter`, `@Setter`, or `@Data`, synthesizes virtual Method graph
* nodes for the accessor methods Lombok would generate. The output mirrors
* the shape of real Method symbols/nodes/relationships so the rest of the
* ingestion pipeline treats them identically.
*
* Synthesized methods are:
* - **Public** (`visibility: 'public'`): Lombok's default access level.
* - **Non-static**: only instance fields get accessors.
* - **Skipped when a hand-written method of the same name already exists.**
* - **Skipped for fields explicitly suppressed** via `@Getter(AccessLevel.NONE)`.
*
* Naming follows the JavaBeans convention Lombok uses:
* - `String name` `getName()` / `setName(String)`
* - `boolean active` `isActive()` / `setActive(boolean)`
* - `Boolean active` `getActive()` (boxed getXxx, per Lombok)
*/
import type Parser from 'tree-sitter';
// ── Types ─────────────────────────────────────────────────────────────────
/** A field extracted from the AST for Lombok synthesis. */
interface LombokField {
name: string;
type: string;
isStatic: boolean;
/** True when @Getter(AccessLevel.NONE) suppresses the getter for this field. */
suppressGetter: boolean;
}
/** A class eligible for Lombok accessor synthesis. */
interface LombokClass {
/** Tree-sitter node of the class_declaration. */
node: Parser.SyntaxNode;
/** Class simple name. */
name: string;
/** 'getter' and/or 'setter' depending on which annotations are present. */
generateGetters: boolean;
generateSetters: boolean;
fields: LombokField[];
/** Names of methods already declared in this class body (collision guard). */
existingMethods: Set<string>;
}
/** Synthetic symbol entry — mirrors the shape pushed to `result.symbols`. */
export interface SyntheticSymbol {
filePath: string;
name: string;
nodeId: string;
type: 'Method';
ownerId: string;
parameterCount: number;
requiredParameterCount: number;
parameterTypes: string[];
returnType: string;
visibility: string;
isStatic: boolean;
isAbstract: boolean;
isFinal: boolean;
isLombok: true;
}
/** Synthetic node entry — mirrors the shape pushed to `result.nodes`. */
export interface SyntheticNode {
id: string;
label: 'Method';
properties: Record<string, unknown> & {
name: string;
filePath: string;
startLine: number;
endLine: number;
language: string;
isExported: boolean;
synthetic: 'lombok';
visibility: string;
isStatic: boolean;
returnType: string;
parameterTypes: string[];
parameterCount: number;
};
}
/** Synthetic relationship entry — mirrors the shape pushed to `result.relationships`. */
export interface SyntheticRelationship {
id: string;
sourceId: string;
targetId: string;
type: 'HAS_METHOD';
confidence: number;
reason: string;
}
export interface LombokSynthesisResult {
symbols: SyntheticSymbol[];
nodes: SyntheticNode[];
relationships: SyntheticRelationship[];
}
// ── Helpers ───────────────────────────────────────────────────────────────
/** Capitalize the first letter of a string. */
function capitalize(s: string): string {
return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
/** Generate the Lombok getter method name for a field. */
function getterName(fieldName: string, fieldType: string): string {
// Primitive boolean → isXxx(); everything else → getXxx()
if (fieldType === 'boolean') {
return `is${capitalize(fieldName)}`;
}
return `get${capitalize(fieldName)}`;
}
/** Generate the Lombok setter method name for a field. */
function setterName(fieldName: string): string {
return `set${capitalize(fieldName)}`;
}
/**
* Extract annotation simple names from a tree-sitter `modifiers` node.
* Handles both `marker_annotation` (`@Data`) and `annotation` (`@Getter(...)`)
* and their fully-qualified forms (`@lombok.Data`).
*/
function extractAnnotationNames(modifiersNode: Parser.SyntaxNode | null): Set<string> {
const names = new Set<string>();
if (!modifiersNode) return names;
for (const child of modifiersNode.children) {
if (child.type !== 'marker_annotation' && child.type !== 'annotation') continue;
// The name child is a named field 'name' within marker_annotation/annotation
const nameNode = child.childForFieldName('name');
const text = nameNode?.text ?? '';
// Normalize to simple name: `lombok.Data` → `Data`
const simpleName = text.split('.').pop() ?? text;
if (simpleName) names.add(simpleName);
}
return names;
}
/**
* Determine if a field's Lombok accessor is suppressed.
*
* `@Getter(AccessLevel.NONE)` or `@Setter(AccessLevel.NONE)` on a field
* disables that specific accessor. We check for the string `NONE` in the
* annotation text as a lightweight heuristic the annotation argument is
* always an enum constant, so `NONE` uniquely identifies suppression.
*/
function isAccessorSuppressed(
fieldNode: Parser.SyntaxNode,
accessorType: 'Getter' | 'Setter',
): boolean {
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers');
if (!modifiers) return false;
for (const child of modifiers.children) {
if (child.type !== 'annotation') continue;
const nameNode = child.childForFieldName('name');
const simpleName = nameNode?.text.split('.').pop();
if (simpleName === accessorType && child.text.includes('NONE')) {
return true;
}
}
return false;
}
/**
* Parse a field declaration node to extract field name(s) and type.
*
* `private String name;` [{ name: 'name', type: 'String', isStatic: false }]
* `private final Long id = 0L;` [{ name: 'id', type: 'Long', isStatic: false }]
* `private int x, y;` [{ name: 'x', ... }, { name: 'y', ... }]
*/
function parseFieldDeclaration(
fieldNode: Parser.SyntaxNode,
): { name: string; type: string; isStatic: boolean; suppressGetter: boolean }[] {
const results: { name: string; type: string; isStatic: boolean; suppressGetter: boolean }[] = [];
// Type is in the `type` field
const typeNode = fieldNode.childForFieldName('type');
const fieldType = typeNode?.text ?? 'Object';
// Check static — tree-sitter-java uses the keyword itself as the node type
// (e.g. `static`, `final`), not a wrapper `modifier` node.
const modifiers = fieldNode.children.find((c) => c.type === 'modifiers');
let isStatic = false;
if (modifiers) {
for (const mod of modifiers.children) {
if (mod.text === 'static') {
isStatic = true;
break;
}
}
}
// Collect all variable declarators — handles both single (`int x;`) and
// multi-variable (`int x, y;`) declarations. The `declarator` field name
// returns only the first one; the rest are unnamed children.
const declarators: Parser.SyntaxNode[] = [];
const declaratorField = fieldNode.childForFieldName('declarator');
if (declaratorField) {
declarators.push(declaratorField);
}
// Also collect unnamed variable_declarator children (multi-variable case)
for (const child of fieldNode.children) {
if (child.type === 'variable_declarator' && child !== declaratorField) {
declarators.push(child);
}
}
for (const declaratorNode of declarators) {
const nameNode = declaratorNode.childForFieldName('name');
if (nameNode) {
results.push({ name: nameNode.text, type: fieldType, isStatic, suppressGetter: false });
}
}
return results;
}
/**
* Collect method names from a class body (for collision detection).
* Walks direct children of the class body for `method_declaration` nodes.
*/
function collectExistingMethodNames(classBody: Parser.SyntaxNode | null): Set<string> {
const names = new Set<string>();
if (!classBody) return names;
for (const child of classBody.children) {
if (child.type === 'method_declaration') {
const nameNode = child.childForFieldName('name');
if (nameNode) names.add(nameNode.text);
}
}
return names;
}
/** Walk the tree for class_declaration nodes eligible for Lombok synthesis. */
function findLombokClasses(root: Parser.SyntaxNode): LombokClass[] {
const classes: LombokClass[] = [];
function walk(node: Parser.SyntaxNode): void {
if (node.type === 'class_declaration') {
const modifiers = node.children.find((c) => c.type === 'modifiers');
const annotations = extractAnnotationNames(modifiers);
const hasGetter = annotations.has('Getter') || annotations.has('Data');
const hasSetter = annotations.has('Setter') || annotations.has('Data');
if (hasGetter || hasSetter) {
const nameNode = node.childForFieldName('name');
const className = nameNode?.text ?? '';
if (className) {
// Find class body
const body = node.children.find((c) => c.type === 'class_body');
const existingMethods = collectExistingMethodNames(body ?? null);
// Collect fields
const fields: LombokField[] = [];
if (body) {
for (const child of body.children) {
if (child.type !== 'field_declaration') continue;
for (const f of parseFieldDeclaration(child)) {
// Skip static fields (Lombok doesn't generate instance accessors for static fields)
if (f.isStatic) continue;
// Mark getter as suppressed if @Getter(AccessLevel.NONE) is on the field
if (hasGetter && isAccessorSuppressed(child, 'Getter')) {
f.suppressGetter = true;
}
fields.push(f);
}
}
}
classes.push({
node,
name: className,
generateGetters: hasGetter,
generateSetters: hasSetter,
fields,
existingMethods,
});
}
}
}
// Recurse into children for nested classes
for (const child of node.children) walk(child);
}
walk(root);
return classes;
}
// ── Main API ──────────────────────────────────────────────────────────────
/**
* Synthesize Lombok accessor methods for a Java file.
*
* Call this after the normal AST capture loop in parse-worker, for Java files
* only. The returned symbols/nodes/relationships should be pushed into the
* worker result so they flow through the rest of the pipeline unchanged.
*
* @param tree The parsed tree-sitter Java AST.
* @param filePath Absolute file path.
* @param classNodeIds Map from class simple name graph node ID, for the
* classes that already exist in `result.nodes`. Only
* classes present in this map get synthesized methods.
* @returns Synthesis result, or empty if no Lombok classes found.
*/
export function synthesizeLombokAccessors(
tree: Parser.Tree,
filePath: string,
classNodeIds: Map<string, string>,
): LombokSynthesisResult {
const result: LombokSynthesisResult = {
symbols: [],
nodes: [],
relationships: [],
};
const lombokClasses = findLombokClasses(tree.rootNode);
for (const cls of lombokClasses) {
const ownerId = classNodeIds.get(cls.name);
if (!ownerId) continue; // Class not in the graph — skip
for (const field of cls.fields) {
// Getter (skip if suppressed by @Getter(AccessLevel.NONE))
if (cls.generateGetters && !field.suppressGetter) {
const gName = getterName(field.name, field.type);
if (!cls.existingMethods.has(gName)) {
const nodeId = `Method:${filePath}:${cls.name}.${gName}#0`;
result.nodes.push({
id: nodeId,
label: 'Method',
properties: {
name: gName,
filePath,
startLine: 0,
endLine: 0,
language: 'java',
isExported: false,
synthetic: 'lombok',
visibility: 'public',
isStatic: false,
returnType: field.type,
parameterTypes: [],
parameterCount: 0,
},
});
result.symbols.push({
filePath,
name: gName,
nodeId,
type: 'Method',
ownerId,
parameterCount: 0,
requiredParameterCount: 0,
parameterTypes: [],
returnType: field.type,
visibility: 'public',
isStatic: false,
isAbstract: false,
isFinal: false,
isLombok: true,
});
result.relationships.push({
id: `HAS_METHOD:${ownerId}->${nodeId}`,
sourceId: ownerId,
targetId: nodeId,
type: 'HAS_METHOD',
confidence: 1.0,
reason: 'lombok-getter',
});
}
}
// Setter
if (cls.generateSetters) {
const sName = setterName(field.name);
if (!cls.existingMethods.has(sName)) {
const nodeId = `Method:${filePath}:${cls.name}.${sName}#1`;
result.nodes.push({
id: nodeId,
label: 'Method',
properties: {
name: sName,
filePath,
startLine: 0,
endLine: 0,
language: 'java',
isExported: false,
synthetic: 'lombok',
visibility: 'public',
isStatic: false,
returnType: 'void',
parameterTypes: [field.type],
parameterCount: 1,
},
});
result.symbols.push({
filePath,
name: sName,
nodeId,
type: 'Method',
ownerId,
parameterCount: 1,
requiredParameterCount: 1,
parameterTypes: [field.type],
returnType: 'void',
visibility: 'public',
isStatic: false,
isAbstract: false,
isFinal: false,
isLombok: true,
});
result.relationships.push({
id: `HAS_METHOD:${ownerId}->${nodeId}`,
sourceId: ownerId,
targetId: nodeId,
type: 'HAS_METHOD',
confidence: 1.0,
reason: 'lombok-setter',
});
}
}
}
}
return result;
}

View file

@ -119,6 +119,7 @@ import type { ConstructorBinding } from '../type-env.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
import { defaultExportNameCollides } from '../languages/typescript/cjs-export-assignment.js';
import { synthesizeLombokAccessors } from '../languages/java/lombok-synthesizer.js';
import {
extractVueScript,
extractTemplateComponents,
@ -3081,6 +3082,25 @@ const processFileGroup = (
if (springTypes.length > 0) (result.springTypes ??= []).push(...springTypes);
}
// Lombok accessor synthesis: generate virtual Method nodes for getter/setter
// methods that Lombok generates at compile time (@Data/@Getter/@Setter).
// Java only — the synthesizer walks the AST for annotated classes.
if (language === SupportedLanguages.Java) {
// Build class-name → node-ID map from symbols created in this file
const classNodeIds = new Map<string, string>();
for (const sym of result.symbols) {
if (sym.type === 'Class' && sym.name) {
classNodeIds.set(sym.name, sym.nodeId);
}
}
const lombok = synthesizeLombokAccessors(tree, file.path, classNodeIds);
if (lombok.symbols.length > 0) {
for (const node of lombok.nodes) result.nodes.push(node as never);
for (const sym of lombok.symbols) result.symbols.push(sym as never);
for (const rel of lombok.relationships) result.relationships.push(rel as never);
}
}
// Vue: emit CALLS edges for components used in <template>
if (language === SupportedLanguages.Vue) {
const templateComponents = extractTemplateComponents(file.content);

View file

@ -0,0 +1,298 @@
/**
* Unit test: Lombok accessor method synthesis.
*
* Tests the lombok-synthesizer module directly (no worker pool needed).
* Verifies that @Data/@Getter/@Setter annotated classes produce the correct
* synthetic getter/setter methods, with proper naming conventions, collision
* guards, and AccessLevel.NONE suppression.
*/
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import Java from 'tree-sitter-java';
import { synthesizeLombokAccessors } from '../../src/core/ingestion/languages/java/lombok-synthesizer.js';
function parse(code: string): Parser.Tree {
const parser = new Parser();
parser.setLanguage(Java);
return parser.parse(code);
}
const FILE_PATH = '/test/Order.java';
describe('synthesizeLombokAccessors', () => {
describe('@Data annotation', () => {
it('generates both getter and setter for each field', () => {
const tree = parse(`
@Data
public class Order {
private String orderId;
private Long amount;
}
`);
const classNodeIds = new Map([['Order', 'Class:/test/Order.java:Order']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
// 2 fields × 2 (getter + setter) = 4 synthetic methods
expect(result.symbols).toHaveLength(4);
const names = result.symbols.map((s) => s.name).sort();
expect(names).toEqual(['getAmount', 'getOrderId', 'setAmount', 'setOrderId']);
});
it('sets correct return types and parameter types', () => {
const tree = parse(`
@Data
public class Order {
private String orderId;
}
`);
const classNodeIds = new Map([['Order', 'Class:/test/Order.java:Order']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
const getter = result.symbols.find((s) => s.name === 'getOrderId')!;
expect(getter.returnType).toBe('String');
expect(getter.parameterTypes).toEqual([]);
expect(getter.parameterCount).toBe(0);
const setter = result.symbols.find((s) => s.name === 'setOrderId')!;
expect(setter.returnType).toBe('void');
expect(setter.parameterTypes).toEqual(['String']);
expect(setter.parameterCount).toBe(1);
});
it('creates HAS_METHOD relationships linking to the class', () => {
const tree = parse(`
@Data
public class Order {
private String orderId;
}
`);
const classId = 'Class:/test/Order.java:Order';
const classNodeIds = new Map([['Order', classId]]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
for (const rel of result.relationships) {
expect(rel.type).toBe('HAS_METHOD');
expect(rel.sourceId).toBe(classId);
expect(rel.confidence).toBe(1.0);
}
expect(result.relationships.every((r) => r.reason.startsWith('lombok-'))).toBe(true);
});
});
describe('boolean field naming convention', () => {
it('uses isXxx() for primitive boolean fields', () => {
const tree = parse(`
@Data
public class Config {
private boolean active;
}
`);
const classNodeIds = new Map([['Config', 'Class:/test/Config.java:Config']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
const names = result.symbols.map((s) => s.name).sort();
expect(names).toContain('isActive');
expect(names).not.toContain('getActive');
// Setter is always setXxx regardless of type
expect(names).toContain('setActive');
});
it('uses getXxx() for Boolean (boxed) fields', () => {
const tree = parse(`
@Data
public class Config {
private Boolean enabled;
}
`);
const classNodeIds = new Map([['Config', 'Class:/test/Config.java:Config']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
const names = result.symbols.map((s) => s.name).sort();
expect(names).toContain('getEnabled');
expect(names).not.toContain('isEnabled');
});
});
describe('individual @Getter and @Setter', () => {
it('only generates getters with @Getter', () => {
const tree = parse(`
@Getter
public class ReadOnly {
private String name;
}
`);
const classNodeIds = new Map([['ReadOnly', 'Class:/test/ReadOnly.java:ReadOnly']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
expect(result.symbols).toHaveLength(1);
expect(result.symbols[0].name).toBe('getName');
});
it('only generates setters with @Setter', () => {
const tree = parse(`
@Setter
public class WriteOnly {
private String name;
}
`);
const classNodeIds = new Map([['WriteOnly', 'Class:/test/WriteOnly.java:WriteOnly']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
expect(result.symbols).toHaveLength(1);
expect(result.symbols[0].name).toBe('setName');
});
});
describe('collision guard', () => {
it('skips getter when a hand-written method of the same name exists', () => {
const tree = parse(`
@Data
public class Order {
private String orderId;
public String getOrderId() {
return customLogic();
}
}
`);
const classNodeIds = new Map([['Order', 'Class:/test/Order.java:Order']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
// Getter is skipped (hand-written exists), setter is still generated
expect(result.symbols).toHaveLength(1);
expect(result.symbols[0].name).toBe('setOrderId');
});
});
describe('static field exclusion', () => {
it('does not generate accessors for static fields', () => {
const tree = parse(`
@Data
public class Constants {
private static String GLOBAL = "default";
private String instance;
}
`);
const classNodeIds = new Map([['Constants', 'Class:/test/Constants.java:Constants']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
// Only the instance field gets accessors
const names = result.symbols.map((s) => s.name).sort();
expect(names).toEqual(['getInstance', 'setInstance']);
});
});
describe('AccessLevel.NONE suppression', () => {
it('skips getter when @Getter(AccessLevel.NONE) is on a field', () => {
const tree = parse(`
@Data
public class Order {
@Getter(AccessLevel.NONE)
private String secret;
private String name;
}
`);
const classNodeIds = new Map([['Order', 'Class:/test/Order.java:Order']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
const names = result.symbols.map((s) => s.name).sort();
// secret getter is suppressed, but setter is still generated (@Data includes @Setter)
expect(names).toEqual(['getName', 'setName', 'setSecret']);
expect(names).not.toContain('getSecret');
});
});
describe('no Lombok annotations', () => {
it('returns empty result for classes without Lombok annotations', () => {
const tree = parse(`
public class PlainClass {
private String name;
public String getName() { return name; }
}
`);
const classNodeIds = new Map([['PlainClass', 'Class:/test/PlainClass.java:PlainClass']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
expect(result.symbols).toHaveLength(0);
expect(result.nodes).toHaveLength(0);
expect(result.relationships).toHaveLength(0);
});
});
describe('class not in graph', () => {
it('skips classes not present in the classNodeIds map', () => {
const tree = parse(`
@Data
public class Orphan {
private String name;
}
`);
const classNodeIds = new Map<string, string>(); // empty
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
expect(result.symbols).toHaveLength(0);
});
});
describe('nested classes', () => {
it('handles nested @Data classes', () => {
const tree = parse(`
public class Outer {
@Data
public static class Inner {
private String value;
}
}
`);
const classNodeIds = new Map([
['Inner', 'Class:/test/Outer.java:Inner'],
['Outer', 'Class:/test/Outer.java:Outer'],
]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
// Only Inner has @Data
expect(result.symbols).toHaveLength(2);
const names = result.symbols.map((s) => s.name).sort();
expect(names).toEqual(['getValue', 'setValue']);
});
});
describe('multi-variable declarations', () => {
it('handles `int x, y;` style declarations', () => {
const tree = parse(`
@Data
public class Point {
private int x, y;
}
`);
const classNodeIds = new Map([['Point', 'Class:/test/Point.java:Point']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
// 2 fields × 2 accessors = 4 methods
expect(result.symbols).toHaveLength(4);
const names = result.symbols.map((s) => s.name).sort();
expect(names).toEqual(['getX', 'getY', 'setX', 'setY']);
});
});
describe('node properties', () => {
it('marks synthetic methods with synthetic: lombok', () => {
const tree = parse(`
@Data
public class Order {
private String orderId;
}
`);
const classNodeIds = new Map([['Order', 'Class:/test/Order.java:Order']]);
const result = synthesizeLombokAccessors(tree, FILE_PATH, classNodeIds);
for (const node of result.nodes) {
expect(node.properties.synthetic).toBe('lombok');
expect(node.properties.visibility).toBe('public');
expect(node.properties.isStatic).toBe(false);
}
});
});
});