Skip to content
Open
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
49 changes: 49 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,55 @@ public class Calculator {
expect(methodNode).toBeDefined();
expect(methodNode?.isStatic).toBe(true);
});

it('should emit MyBatis mapper XML references from interface methods', () => {
const code = `
package com.example.mapper;

public interface UserMapper {
User findById(String id);
}
`;
const result = extractFromSource('src/main/java/com/example/mapper/UserMapper.java', code);

const methodNode = result.nodes.find((n) => n.kind === 'method' && n.name === 'findById');
expect(methodNode).toBeDefined();

const mapperRef = result.unresolvedReferences.find(
(r) => r.fromNodeId === methodNode?.id && r.referenceName === 'com.example.mapper.UserMapper.findById'
);
expect(mapperRef).toMatchObject({
referenceKind: 'references',
language: 'java',
});
});
});

describe('MyBatis Mapper XML Extraction', () => {
it('should extract mapper SQL statements as Java method-like nodes', () => {
const xml = `
<?xml version="1.0" encoding="UTF-8" ?>
<mapper namespace="com.example.mapper.UserMapper">
<select id="findById" parameterType="string" resultType="User">
select * from users where id = #{id}
</select>
<update id="touch">
update users set updated_at = now()
</update>
</mapper>
`;
const result = extractFromSource('src/main/resources/mapper/UserMapper.xml', xml);

const statements = result.nodes.filter((n) => n.kind === 'method');
expect(statements.map((n) => n.name).sort()).toEqual(['findById', 'touch']);

const findById = statements.find((n) => n.name === 'findById');
expect(findById).toMatchObject({
language: 'java',
qualifiedName: 'com.example.mapper.UserMapper.findById',
signature: '<select id="findById" parameterType="string" resultType="User">',
});
});
});

describe('C# Extraction', () => {
Expand Down
40 changes: 40 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,46 @@ def bootstrap():
);
expect(callsToUserService).toHaveLength(0);
});

it('links Java mapper interface methods to MyBatis XML statements', async () => {
const javaDir = path.join(tempDir, 'src/main/java/com/example/mapper');
const xmlDir = path.join(tempDir, 'src/main/resources/mapper');
fs.mkdirSync(javaDir, { recursive: true });
fs.mkdirSync(xmlDir, { recursive: true });

fs.writeFileSync(
path.join(javaDir, 'UserMapper.java'),
`package com.example.mapper;

public interface UserMapper {
User findById(String id);
}
`
);

fs.writeFileSync(
path.join(xmlDir, 'UserMapper.xml'),
`<mapper namespace="com.example.mapper.UserMapper">
<select id="findById" parameterType="string" resultType="User">
select * from users where id = #{id}
</select>
</mapper>
`
);

cg = await CodeGraph.init(tempDir, { index: true });
cg.resolveReferences();

const methods = cg.getNodesByKind('method').filter((n) => n.name === 'findById');
const javaMethod = methods.find((n) => n.filePath.endsWith('UserMapper.java'));
const xmlStatement = methods.find((n) => n.filePath.endsWith('UserMapper.xml'));

expect(javaMethod).toBeDefined();
expect(xmlStatement).toBeDefined();

const usages = cg.findUsages(xmlStatement!.id);
expect(usages.some((u) => u.node.id === javaMethod!.id && u.edge.kind === 'references')).toBe(true);
});
});

describe('Name Matcher: kind bias for new ref kinds', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/extraction/grammars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export function getParser(language: Language): Parser | null {
*/
export function detectLanguage(filePath: string, source?: string): Language {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
if (ext === '.xml' && (filePath.endsWith('Mapper.xml') || !!source?.includes('<mapper'))) {
return 'java';
}

const lang = EXTENSION_MAP[ext] || 'unknown';

// .h files could be C or C++ — check source content for C++ features
Expand Down
48 changes: 46 additions & 2 deletions src/extraction/languages/java.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
import { generateNodeId, getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';

export const javaExtractor: LanguageExtractor = {
functionTypes: [],
Expand All @@ -19,6 +19,12 @@ export const javaExtractor: LanguageExtractor = {
bodyField: 'body',
paramsField: 'parameters',
returnField: 'type',
visitNode: (node, ctx) => {
if (node.type === 'method_declaration') {
addMyBatisMapperStatementRef(node, ctx);
}
return false;
},
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
const returnType = getChildByField(node, 'type');
Expand Down Expand Up @@ -57,3 +63,41 @@ export const javaExtractor: LanguageExtractor = {
return null;
},
};

function addMyBatisMapperStatementRef(node: SyntaxNode, ctx: ExtractorContext): void {
if (node.parent?.type !== 'interface_body') return;

const nameNode = getChildByField(node, 'name');
if (!nameNode) return;

const methodName = getNodeText(nameNode, ctx.source);
if (!methodName) return;

const owner = getCurrentInterfaceNode(ctx);
if (!owner) return;

const line = node.startPosition.row + 1;
const fromNodeId = generateNodeId(ctx.filePath, 'method', methodName, line);
const pkg = extractPackageName(ctx.source);
const mapperRef = pkg ? `${pkg}.${owner.name}.${methodName}` : `${owner.name}.${methodName}`;

ctx.addUnresolvedReference({
fromNodeId,
referenceName: mapperRef,
referenceKind: 'references',
line,
column: node.startPosition.column,
filePath: ctx.filePath,
language: 'java',
});
}

function getCurrentInterfaceNode(ctx: ExtractorContext) {
const currentId = ctx.nodeStack[ctx.nodeStack.length - 1];
return ctx.nodes.find((n) => n.id === currentId && n.kind === 'interface');
}

function extractPackageName(source: string): string | null {
const match = source.match(/^\s*package\s+([\w.]+)\s*;/m);
return match?.[1] ?? null;
}
143 changes: 143 additions & 0 deletions src/extraction/mybatis-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* MyBatis Mapper XML Extractor
*
* Extracts SQL statement ids from mapper XML files as method-like nodes so
* Java mapper interfaces can link to their backing SQL statements.
*/

import * as path from 'path';
import { Edge, ExtractionError, ExtractionResult, Node } from '../types';
import { generateNodeId } from './tree-sitter-helpers';

const STATEMENT_TAGS = new Set(['select', 'insert', 'update', 'delete']);

export class MyBatisExtractor {
constructor(
private filePath: string,
private source: string
) {}

extract(): ExtractionResult {
const startTime = Date.now();
const nodes: Node[] = [];
const edges: Edge[] = [];
const errors: ExtractionError[] = [];
const now = Date.now();
const lineCount = this.source.split('\n').length;

const fileNode: Node = {
id: `file:${this.filePath}`,
kind: 'file',
name: path.basename(this.filePath),
qualifiedName: this.filePath,
filePath: this.filePath,
language: 'java',
startLine: 1,
endLine: lineCount,
startColumn: 0,
endColumn: 0,
isExported: false,
updatedAt: now,
};
nodes.push(fileNode);

const namespace = extractMapperNamespace(this.source);
if (!namespace) {
return {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [],
durationMs: Date.now() - startTime,
};
}

const cleaned = stripXmlComments(this.source);
const statementRegex = /<(select|insert|update|delete)\b([^>]*)>/gi;
let match: RegExpExecArray | null;

while ((match = statementRegex.exec(cleaned)) !== null) {
const tag = match[1]!.toLowerCase();
if (!STATEMENT_TAGS.has(tag)) continue;

const attrs = parseAttributes(match[2] ?? '');
const id = attrs.get('id');
if (!id) continue;

const line = lineAt(cleaned, match.index);
const col = columnAt(cleaned, match.index);
const statementNode: Node = {
id: generateNodeId(this.filePath, 'method', id, line),
kind: 'method',
name: id,
qualifiedName: `${namespace}.${id}`,
filePath: this.filePath,
language: 'java',
startLine: line,
endLine: line,
startColumn: col,
endColumn: col + match[0].length,
signature: buildStatementSignature(tag, attrs),
updatedAt: now,
};
nodes.push(statementNode);
edges.push({
source: fileNode.id,
target: statementNode.id,
kind: 'contains',
});
}

return {
nodes,
edges,
unresolvedReferences: [],
errors,
durationMs: Date.now() - startTime,
};
}
}

export function looksLikeMyBatisMapper(filePath: string, source: string): boolean {
return filePath.toLowerCase().endsWith('.xml') && /<mapper\b[^>]*\bnamespace\s*=/.test(source);
}

function extractMapperNamespace(source: string): string | null {
const match = source.match(/<mapper\b[^>]*\bnamespace\s*=\s*(['"])(.*?)\1/i);
const namespace = match?.[2]?.trim();
return namespace || null;
}

function stripXmlComments(source: string): string {
return source.replace(/<!--[\s\S]*?-->/g, (comment) =>
comment.replace(/[^\r\n]/g, ' ')
);
}

function parseAttributes(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
const attrRegex = /([\w:-]+)\s*=\s*(['"])(.*?)\2/g;
let match: RegExpExecArray | null;
while ((match = attrRegex.exec(raw)) !== null) {
attrs.set(match[1]!, match[3]!);
}
return attrs;
}

function buildStatementSignature(tag: string, attrs: Map<string, string>): string {
const parts = [`<${tag}`];
for (const key of ['id', 'parameterType', 'resultType', 'resultMap']) {
const value = attrs.get(key);
if (value) parts.push(`${key}="${value}"`);
}
return `${parts.join(' ')}>`;
}

function lineAt(source: string, index: number): number {
return source.slice(0, index).split('\n').length;
}

function columnAt(source: string, index: number): number {
const lastNl = source.lastIndexOf('\n', index);
return lastNl === -1 ? index : index - lastNl - 1;
}
14 changes: 14 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { LiquidExtractor } from './liquid-extractor';
import { SvelteExtractor } from './svelte-extractor';
import { DfmExtractor } from './dfm-extractor';
import { VueExtractor } from './vue-extractor';
import { MyBatisExtractor, looksLikeMyBatisMapper } from './mybatis-extractor';
import {
getAllFrameworkResolvers,
getApplicableFrameworks,
Expand Down Expand Up @@ -2514,6 +2515,19 @@ export function extractFromSource(
// Use custom extractor for DFM/FMX form files
const extractor = new DfmExtractor(filePath, source);
result = extractor.extract();
} else if (fileExtension === '.xml') {
if (looksLikeMyBatisMapper(filePath, source)) {
const extractor = new MyBatisExtractor(filePath, source);
result = extractor.extract();
} else {
result = {
nodes: [],
edges: [],
unresolvedReferences: [],
errors: [],
durationMs: 0,
};
}
} else {
const extractor = new TreeSitterExtractor(filePath, source, detectedLanguage);
result = extractor.extract();
Expand Down
2 changes: 2 additions & 0 deletions src/resolution/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@ export class ReferenceResolver {
const receiver = name.substring(0, dotIdx);
const member = name.substring(dotIdx + 1);
if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
const lastMember = name.substring(name.lastIndexOf('.') + 1);
if (this.knownNames.has(lastMember)) return true;
// Also check capitalized receiver (instance-method resolution)
const capitalized = receiver.charAt(0).toUpperCase() + receiver.slice(1);
if (this.knownNames.has(capitalized)) return true;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ export const DEFAULT_CONFIG: CodeGraphConfig = {
'**/*.rs',
// Java
'**/*.java',
'**/*Mapper.xml',
// C/C++
'**/*.c',
'**/*.h',
Expand Down