diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index b08408a4..ac3b5e66 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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 = ` + + + + + update users set updated_at = now() + + +`; + 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 * from users where id = #{id} + + +` + ); + + 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', () => { diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d1540424..f99407d9 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -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(' { + if (node.type === 'method_declaration') { + addMyBatisMapperStatementRef(node, ctx); + } + return false; + }, getSignature: (node, source) => { const params = getChildByField(node, 'parameters'); const returnType = getChildByField(node, 'type'); @@ -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; +} diff --git a/src/extraction/mybatis-extractor.ts b/src/extraction/mybatis-extractor.ts new file mode 100644 index 00000000..d776bfbe --- /dev/null +++ b/src/extraction/mybatis-extractor.ts @@ -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') && /]*\bnamespace\s*=/.test(source); +} + +function extractMapperNamespace(source: string): string | null { + const match = source.match(/]*\bnamespace\s*=\s*(['"])(.*?)\1/i); + const namespace = match?.[2]?.trim(); + return namespace || null; +} + +function stripXmlComments(source: string): string { + return source.replace(//g, (comment) => + comment.replace(/[^\r\n]/g, ' ') + ); +} + +function parseAttributes(raw: string): Map { + const attrs = new Map(); + 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 { + 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; +} diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 00830ab8..9133670d 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -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, @@ -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(); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 34aa4b90..a752e880 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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; diff --git a/src/types.ts b/src/types.ts index 328f7432..d3d414be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -509,6 +509,7 @@ export const DEFAULT_CONFIG: CodeGraphConfig = { '**/*.rs', // Java '**/*.java', + '**/*Mapper.xml', // C/C++ '**/*.c', '**/*.h',