import { Edge, ExtractionError, ExtractionResult, Node, UnresolvedReference } from '../types'; import { generateNodeId } from './tree-sitter-helpers'; /** * MyBatisExtractor — parses MyBatis mapper XML files. * * MyBatis splits a DAO interface across two files: a Java interface (parsed by * tree-sitter) declares the method, and an XML mapper file holds the SQL keyed * by `` (the fully-qualified Java type name) and `id` (the method * name). Without the XML side in the graph, `trace(Controller, ...DAO.method)` * dead-ends at the interface method — the SQL it actually runs is invisible, * and "what does this query touch" / "where is this column written" can't be * answered. * * This extractor emits one method-shaped node per `` and per `` fragment, qualified as `::` so the * MyBatis framework synthesizer can link the matching Java method → XML * statement by suffix-matching qualified names. `` inside * a statement yields an unresolved reference to the SQL fragment, also keyed * by `::`. * * Both dialects are covered: MyBatis 3 `` and the * legacy iBatis 2 `` (namespaced, or namespace-less with `Map.stmt` * ids, plus its extra ``/`` verbs). Attribute values may * use either quote style, and statements commented out with `` are * ignored (see the constructor's comment-stripping pre-pass). * * Non-mapper XML (Maven `pom.xml`, Spring beans XML, `web.xml`, log4j config, * etc.) is detected by the absence of a `` / * `` root and returns just a file node — we still need the file row so * the watcher can track it, but we emit no symbols. */ export class MyBatisExtractor { private filePath: string; private source: string; private nodes: Node[] = []; private edges: Edge[] = []; private unresolvedReferences: UnresolvedReference[] = []; private errors: ExtractionError[] = []; private lineStarts: number[] = []; constructor(filePath: string, source: string) { this.filePath = filePath; // Blank out XML comments up front so commented-out statements and includes // aren't matched by the scans below (a `` // block must not produce a phantom node). Length-preserving — comment bytes // become spaces, newlines are kept — so the offsets and line numbers // computed afterwards still map to the original source. Text inside // `` is left intact: a literal `', i + 4); const stop = end >= 0 ? end + 3 : n; for (let j = i; j < stop; j++) { if (source.charCodeAt(j) !== 10) out[j] = ' '; } i = stop; continue; } i++; } return out.join(''); } extract(): ExtractionResult { const startTime = Date.now(); const fileNode = this.createFileNode(); try { const root = this.findMapperRoot(); if (root) { this.extractMapper(fileNode.id, root.namespace, root.dialect, root.bodyStart, root.bodyEnd); } } catch (error) { this.errors.push({ message: `MyBatis extraction error: ${error instanceof Error ? error.message : String(error)}`, severity: 'error', code: 'parse_error', }); } return { nodes: this.nodes, edges: this.edges, unresolvedReferences: this.unresolvedReferences, errors: this.errors, durationMs: Date.now() - startTime, }; } private createFileNode(): Node { const lines = this.source.split('\n'); const id = generateNodeId(this.filePath, 'file', this.filePath, 1); const node: Node = { id, kind: 'file', name: this.filePath.split('/').pop() || this.filePath, qualifiedName: this.filePath, filePath: this.filePath, language: 'xml', startLine: 1, endLine: lines.length || 1, startColumn: 0, endColumn: lines[lines.length - 1]?.length ?? 0, updatedAt: Date.now(), }; this.nodes.push(node); return node; } /** * Find the mapper root and its dialect. Two shapes are recognized: * - MyBatis 3: `` — namespace required. * - iBatis 2: ``, or a namespace-less * `` whose statement ids carry the qualifier as `Map.statement`. * Returns the namespace, the dialect, and the byte offsets of the body * (between the opening and closing tag) so statement extraction is scoped to * the root's contents. Either quote style is accepted for the namespace * (`namespace='X'` is legal XML and common in older mappers). */ private findMapperRoot(): | { namespace: string; dialect: 'mybatis' | 'ibatis'; bodyStart: number; bodyEnd: number } | null { const mapper = /]*)>/.exec(this.source); if (mapper) { const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(mapper[1] ?? ''); if (nsMatch) { const bodyStart = mapper.index + mapper[0].length; const closeIdx = this.source.indexOf('', bodyStart); return { namespace: nsMatch[2]!, dialect: 'mybatis', bodyStart, bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length, }; } } // iBatis 2 SqlMap. `\b` keeps `` (the iBatis config root, // which holds no statements) from matching here. namespace is optional. const sqlMap = /]*)>/.exec(this.source); if (sqlMap) { const nsMatch = /\bnamespace\s*=\s*(["'])([^"']+)\1/.exec(sqlMap[1] ?? ''); const bodyStart = sqlMap.index + sqlMap[0].length; const closeIdx = this.source.indexOf('', bodyStart); return { namespace: nsMatch?.[2] ?? '', dialect: 'ibatis', bodyStart, bodyEnd: closeIdx >= 0 ? closeIdx : this.source.length, }; } return null; } private extractMapper( fileNodeId: string, namespace: string, dialect: 'mybatis' | 'ibatis', bodyStart: number, bodyEnd: number ): void { const body = this.source.slice(bodyStart, bodyEnd); // Match each top-level statement-shaped element. The body may have nested // tags (``, ``, ``), so we scan with a regex that // pairs an opening tag to its matching close — the simple form below works // because MyBatis/iBatis statement elements are not themselves nested. // iBatis 2 adds the generic `` and `` on top of the // MyBatis 3 verbs; gating by dialect keeps MyBatis extraction unchanged. const verbs = dialect === 'ibatis' ? 'select|insert|update|delete|sql|statement|procedure' : 'select|insert|update|delete|sql'; const stmtRegex = new RegExp(`<(${verbs})\\b([^>]*)>([\\s\\S]*?)`, 'g'); let m: RegExpExecArray | null; while ((m = stmtRegex.exec(body)) !== null) { const elemType = m[1]!; const attrs = m[2] ?? ''; const elemBody = m[3] ?? ''; // Accept either quote style (`(["'])…\1`). The identifier-shaped MyBatis // attributes matched here and below (namespace/id/refid/resultType/ // parameterType) are Java FQNs, method names, or type aliases and never // contain a quote character, so excluding both quotes from the value is safe. const idMatch = /\bid\s*=\s*(["'])([^"']+)\1/.exec(attrs); if (!idMatch) continue; const id = idMatch[2]!; const absoluteIndex = bodyStart + m.index; const startLine = this.getLineNumber(absoluteIndex); const endLine = this.getLineNumber(absoluteIndex + m[0].length); const { qualifiedName: qualified, name } = this.qualifyStatement(namespace, id); const isSqlFragment = elemType === 'sql'; // The id-hash folds in the statement's byte offset (unique per statement // in the file), not just the start line: two statements sharing a // qualifiedName AND a start line — e.g. a vendor-split `databaseId` pair // (`