diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d095e895b..1a971e1c5 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -5822,6 +5822,89 @@ end`; expect(components.length).toBe(2); }); + describe('component source ranges (#1350)', () => { + let tempDir: string; + let cg: CodeGraph | undefined; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + cleanupTempDir(tempDir); + }); + + it.each(['dfm', 'fmx'])('persists complete nested %s bodies and retrieves event bindings', async (extension) => { + const source = `inherited Form1: TForm1 + inline Frame1: TFrame + object Button1: TButton + Caption = 'Click' + Items.Strings = ( + 'First' + 'Second') + Panels = < + item + Width = 100 + end + item + Width = 200 + end> + OnClick = Button1Click + end + OnEnter = FrameEnter + end + object Label1: TLabel + Caption = 'Sibling' + end +end`; + const fileName = `Form1.${extension}`; + fs.writeFileSync(path.join(tempDir, fileName), source); + cg = CodeGraph.initSync(tempDir); + expect((await cg.indexAll()).filesIndexed).toBe(1); + + const nodes = cg.getNodesInFile(fileName); + const components = nodes.filter((node) => node.kind === 'component'); + expect(components.map(({ name, startLine, endLine, endColumn }) => ({ + name, startLine, endLine, endColumn, + }))).toEqual(expect.arrayContaining([ + { name: 'Form1', startLine: 1, endLine: 22, endColumn: 3 }, + { name: 'Frame1', startLine: 2, endLine: 18, endColumn: 5 }, + { name: 'Button1', startLine: 3, endLine: 16, endColumn: 7 }, + { name: 'Label1', startLine: 19, endLine: 21, endColumn: 5 }, + ])); + expect(components).toHaveLength(4); + + const extracted = extractFromSource(fileName, source); + const file = extracted.nodes.find((node) => node.kind === 'file')!; + const form = components.find((node) => node.name === 'Form1')!; + const frame = components.find((node) => node.name === 'Frame1')!; + const button = components.find((node) => node.name === 'Button1')!; + const label = components.find((node) => node.name === 'Label1')!; + for (const [parent, child] of [[file, form], [form, frame], [frame, button], [form, label]]) { + expect(extracted.edges).toContainEqual({ source: parent!.id, target: child!.id, kind: 'contains' }); + } + expect(extracted.unresolvedReferences).toEqual([ + expect.objectContaining({ fromNodeId: button.id, referenceName: 'Button1Click' }), + expect.objectContaining({ fromNodeId: frame.id, referenceName: 'FrameEnter' }), + ]); + expect(file.endLine).toBe(22); + + const { ToolHandler } = await import('../src/mcp/tools'); + const handler = new ToolHandler(cg); + for (const [tool, args] of [ + ['codegraph_node', { symbol: 'Button1', includeCode: true }], + ['codegraph_explore', { query: 'Button1' }], + ] as const) { + const result = await handler.execute(tool, args); + expect(result.isError).toBeUndefined(); + expect(result.content[0]!.text).toContain("Caption = 'Click'"); + expect(result.content[0]!.text).toContain('OnClick = Button1Click'); + } + }); + }); + describe('Full fixture: MainForm.dfm', () => { const code = `object frmMain: TfrmMain Left = 0 diff --git a/src/extraction/dfm-extractor.ts b/src/extraction/dfm-extractor.ts index 678451300..3b2bfb1c7 100644 --- a/src/extraction/dfm-extractor.ts +++ b/src/extraction/dfm-extractor.ts @@ -34,7 +34,7 @@ export class DfmExtractor { try { const fileNode = this.createFileNode(); - this.parseComponents(fileNode.id); + this.parseComponents(fileNode); } catch (error) { this.errors.push({ message: `DFM extraction error: ${error instanceof Error ? error.message : String(error)}`, @@ -76,9 +76,9 @@ export class DfmExtractor { } /** Parse object/end blocks and extract components + event handlers */ - private parseComponents(fileNodeId: string): void { + private parseComponents(fileNode: Node): void { const lines = this.source.split('\n'); - const stack: string[] = [fileNodeId]; + const stack: Node[] = [fileNode]; const objectPattern = /^\s*(object|inherited|inline)\s+(\w+)\s*:\s*(\w+)/; const eventPattern = /^\s*(On\w+)\s*=\s*(\w+)\s*$/; @@ -113,7 +113,7 @@ export class DfmExtractor { if (objMatch) { const [, , name, typeName] = objMatch; const nodeId = generateNodeId(this.filePath, 'component', name!, lineNum); - this.nodes.push({ + const node: Node = { id: nodeId, kind: 'component', name: name!, @@ -126,13 +126,14 @@ export class DfmExtractor { endColumn: line.length, signature: typeName, updatedAt: Date.now(), - }); + }; + this.nodes.push(node); this.edges.push({ - source: stack[stack.length - 1]!, + source: stack[stack.length - 1]!.id, target: nodeId, kind: 'contains', }); - stack.push(nodeId); + stack.push(node); continue; } @@ -141,7 +142,7 @@ export class DfmExtractor { if (eventMatch) { const [, , methodName] = eventMatch; this.unresolvedReferences.push({ - fromNodeId: stack[stack.length - 1]!, + fromNodeId: stack[stack.length - 1]!.id, referenceName: methodName!, referenceKind: 'references', line: lineNum, @@ -152,7 +153,11 @@ export class DfmExtractor { // Block end if (endPattern.test(line)) { - if (stack.length > 1) stack.pop(); + if (stack.length > 1) { + const node = stack.pop()!; + node.endLine = lineNum; + node.endColumn = line.length; + } } } }