Skip to content

Commit b9ae613

Browse files
committed
feat(file-structure): introduce unified file structure types and extraction method for TS/JS files
1 parent de2645d commit b9ae613

4 files changed

Lines changed: 261 additions & 181 deletions

File tree

src/client-pipe.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,44 @@ export interface OrphanedContentResult {
785785
};
786786
}
787787

788+
// ── Unified File Structure Types ─────────────────────────
789+
790+
export interface UnifiedFileSymbolRange {
791+
startLine: number; // 1-indexed
792+
startChar: number; // 0-indexed (column)
793+
endLine: number; // 1-indexed
794+
endChar: number; // 0-indexed (column)
795+
}
796+
797+
export interface UnifiedFileSymbol {
798+
name: string;
799+
kind: string;
800+
detail?: string;
801+
range: UnifiedFileSymbolRange;
802+
children: UnifiedFileSymbol[];
803+
exported?: boolean;
804+
modifiers?: string[];
805+
}
806+
807+
export interface UnifiedFileResult {
808+
symbols: UnifiedFileSymbol[];
809+
content: string;
810+
totalLines: number;
811+
imports: OrphanedSymbolNode[];
812+
exports: OrphanedSymbolNode[];
813+
orphanComments: OrphanedSymbolNode[];
814+
directives: OrphanedSymbolNode[];
815+
gaps: Array<{start: number; end: number; type: 'blank' | 'unknown'}>;
816+
stats: {
817+
totalImports: number;
818+
totalExports: number;
819+
totalOrphanComments: number;
820+
totalDirectives: number;
821+
totalBlankLines: number;
822+
coveragePercent: number;
823+
};
824+
}
825+
788826
export interface FileFindReferencesResult {
789827
references: Array<{file: string; line: number; character: number}>;
790828
}
@@ -970,6 +1008,23 @@ export async function fileExtractOrphanedContent(
9701008
return result;
9711009
}
9721010

1011+
/**
1012+
* Extract the complete file structure using ts-morph only.
1013+
* Returns symbols, content, imports, exports, comments, directives, gaps, and stats.
1014+
* Single round-trip replacement for fileGetSymbols + fileExtractOrphanedContent + fileReadContent.
1015+
*/
1016+
export async function fileExtractStructure(
1017+
filePath: string,
1018+
): Promise<UnifiedFileResult> {
1019+
const result = await sendClientRequest(
1020+
'file.extractStructure',
1021+
{filePath},
1022+
30_000,
1023+
);
1024+
assertResult<UnifiedFileResult>(result, 'file.extractStructure');
1025+
return result;
1026+
}
1027+
9731028
// ── Recovery Handler ─────────────────────────────────────
9741029

9751030
let clientRecoveryHandler: (() => Promise<void>) | undefined;

src/tools/file/file-edit.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,18 @@
66

77
import path from 'node:path';
88

9-
import {fileGetSymbols, fileReadContent} from '../../client-pipe.js';
9+
import {fileExtractStructure, fileReadContent} from '../../client-pipe.js';
1010
import {getHostWorkspace} from '../../config.js';
1111
import {zod} from '../../third_party/index.js';
1212
import {ToolCategory} from '../categories.js';
1313
import {defineTool} from '../ToolDefinition.js';
1414
import {executeEditWithSafetyLayer} from './safety-layer.js';
1515
import {resolveSymbolTarget} from './symbol-resolver.js';
1616

17+
const STRUCTURED_EXTS = new Set([
18+
'ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs',
19+
]);
20+
1721
function resolveFilePath(file: string): string {
1822
if (path.isAbsolute(file)) return file;
1923
return path.resolve(getHostWorkspace(), file);
@@ -58,7 +62,7 @@ export const edit = defineTool({
5862
),
5963
target: zod.string().optional().describe(
6064
'Symbol name to scope the edit: "UserService.findById". ' +
61-
'Uses VS Code DocumentSymbols for precise targeting.',
65+
'Only supported for TS/JS family files (.ts, .tsx, .js, .jsx, .mts, .mjs, .cts, .cjs).',
6266
),
6367
startLine: zod.number().int().optional().describe(
6468
'Fallback: start line (1-indexed). Used when target is not specified.',
@@ -119,21 +123,32 @@ export const edit = defineTool({
119123
let targetLabel: string | undefined;
120124

121125
if (params.target) {
122-
// Symbol targeting: resolve via DocumentSymbols
123-
const symbolsResult = await fileGetSymbols(filePath);
124-
const match = resolveSymbolTarget(symbolsResult.symbols, params.target);
126+
// Symbol targeting: resolve via ts-morph extraction (TS/JS family only)
127+
const ext = path.extname(filePath).slice(1).toLowerCase();
128+
if (!STRUCTURED_EXTS.has(ext)) {
129+
response.appendResponseLine(
130+
`❌ Symbol targeting is only supported for TS/JS family files ` +
131+
`(.ts, .tsx, .js, .jsx, .mts, .mjs, .cts, .cjs).\n\n` +
132+
`Use \`startLine\`/\`endLine\` instead for .${ext} files.`,
133+
);
134+
return;
135+
}
136+
137+
const structure = await fileExtractStructure(filePath);
138+
const match = resolveSymbolTarget(structure.symbols, params.target);
125139

126140
if (!match) {
127-
const available = symbolsResult.symbols.map(s => `${s.kind} ${s.name}`).join(', ');
141+
const available = structure.symbols.map(s => `${s.kind} ${s.name}`).join(', ');
128142
response.appendResponseLine(
129143
`❌ Symbol "${params.target}" not found in ${relativePath}.\n\n` +
130144
`Available symbols: ${available || 'none'}`,
131145
);
132146
return;
133147
}
134148

135-
editStartLine = match.symbol.range.startLine;
136-
editEndLine = match.symbol.range.endLine;
149+
// ts-morph ranges are 1-indexed; safety layer expects 0-indexed
150+
editStartLine = match.symbol.range.startLine - 1;
151+
editEndLine = match.symbol.range.endLine - 1;
137152
targetLabel = params.target;
138153
} else if (params.startLine !== undefined && params.endLine !== undefined) {
139154
// Line-based targeting (convert 1-indexed to 0-indexed)

0 commit comments

Comments
 (0)