Skip to content

Commit aeb2ea2

Browse files
committed
refactor(file): rename FileSymbol and related types to NativeDocumentSymbol; enhance file structure interfaces
1 parent 19f66d3 commit aeb2ea2

7 files changed

Lines changed: 127 additions & 97 deletions

File tree

src/client-pipe.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -637,24 +637,24 @@ export async function codebaseGetDiagnostics(
637637

638638
// ── File Service Types ───────────────────────────────────
639639

640-
export interface FileSymbolRange {
640+
export interface NativeDocumentSymbolRange {
641641
startLine: number;
642642
startChar: number;
643643
endLine: number;
644644
endChar: number;
645645
}
646646

647-
export interface FileSymbol {
647+
export interface NativeDocumentSymbol {
648648
name: string;
649649
kind: string;
650650
detail?: string;
651-
range: FileSymbolRange;
652-
selectionRange: FileSymbolRange;
653-
children: FileSymbol[];
651+
range: NativeDocumentSymbolRange;
652+
selectionRange: NativeDocumentSymbolRange;
653+
children: NativeDocumentSymbol[];
654654
}
655655

656656
export interface FileGetSymbolsResult {
657-
symbols: FileSymbol[];
657+
symbols: NativeDocumentSymbol[];
658658
}
659659

660660
export interface FileReadContentResult {
@@ -759,6 +759,59 @@ export interface FileFindReferencesResult {
759759
references: Array<{file: string; line: number; character: number}>;
760760
}
761761

762+
// ── Shared File Structure Types (Multi-Language) ─────────
763+
764+
export interface FileSymbolRange {
765+
startLine: number;
766+
endLine: number;
767+
startChar?: number;
768+
endChar?: number;
769+
}
770+
771+
export interface FileSymbol {
772+
name: string;
773+
kind: string;
774+
detail?: string;
775+
range: FileSymbolRange;
776+
children: FileSymbol[];
777+
exported?: boolean;
778+
modifiers?: string[];
779+
}
780+
781+
export type OrphanedCategory =
782+
| 'import'
783+
| 'export'
784+
| 'comment'
785+
| 'directive'
786+
| 'footnote'
787+
| 'linkdef';
788+
789+
export interface OrphanedItem {
790+
name: string;
791+
kind: string;
792+
detail?: string;
793+
range: {start: number; end: number};
794+
children?: OrphanedItem[];
795+
category: OrphanedCategory;
796+
}
797+
798+
export interface FileStructureStats {
799+
totalSymbols: number;
800+
totalOrphaned: number;
801+
totalBlankLines: number;
802+
coveragePercent: number;
803+
}
804+
805+
export interface FileStructure {
806+
symbols: FileSymbol[];
807+
content: string;
808+
totalLines: number;
809+
fileType: 'typescript' | 'markdown' | 'json' | 'unknown';
810+
orphaned: {items: OrphanedItem[]};
811+
gaps: Array<{start: number; end: number; type: 'blank' | 'unknown'}>;
812+
stats: FileStructureStats;
813+
}
814+
762815
export interface FileCodeActionItem {
763816
index: number;
764817
title: string;
@@ -943,20 +996,20 @@ export async function fileExtractOrphanedContent(
943996
}
944997

945998
/**
946-
* Extract the complete file structure using ts-morph only.
947-
* Returns symbols, content, imports, exports, comments, directives, gaps, and stats.
948-
* Single round-trip replacement for fileGetSymbols + fileExtractOrphanedContent + fileReadContent.
999+
* Extract the complete file structure via the LanguageServiceRegistry.
1000+
* Returns FileStructure if the file type is supported, undefined otherwise.
9491001
*/
9501002
export async function fileExtractStructure(
9511003
filePath: string,
952-
): Promise<UnifiedFileResult> {
1004+
): Promise<FileStructure | undefined> {
9531005
const result = await sendClientRequest(
9541006
'file.extractStructure',
9551007
{filePath},
9561008
30_000,
9571009
);
958-
assertResult<UnifiedFileResult>(result, 'file.extractStructure');
959-
return result;
1010+
// The registry returns undefined for unsupported file types
1011+
if (result === undefined || result === null) return undefined;
1012+
return result as FileStructure;
9601013
}
9611014

9621015
// ── Recovery Handler ─────────────────────────────────────

src/tools/file/file-edit.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,6 @@ 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-
2117
function resolveFilePath(file: string): string {
2218
if (path.isAbsolute(file)) return file;
2319
return path.resolve(getClientWorkspace(), file);
@@ -123,18 +119,17 @@ export const edit = defineTool({
123119
let targetLabel: string | undefined;
124120

125121
if (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)) {
122+
// Symbol targeting: resolve via registered language service
123+
const structure = await fileExtractStructure(filePath);
124+
if (!structure) {
125+
const ext = path.extname(filePath).slice(1).toLowerCase();
129126
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.`,
127+
`❌ Symbol targeting is not supported for .${ext} files.\n\n` +
128+
`Use \`startLine\`/\`endLine\` instead.`,
133129
);
134130
return;
135131
}
136132

137-
const structure = await fileExtractStructure(filePath);
138133
const match = resolveSymbolTarget(structure.symbols, params.target);
139134

140135
if (!match) {
@@ -146,7 +141,7 @@ export const edit = defineTool({
146141
return;
147142
}
148143

149-
// ts-morph ranges are 1-indexed; safety layer expects 0-indexed
144+
// Ranges are 1-indexed; safety layer expects 0-indexed
150145
editStartLine = match.symbol.range.startLine - 1;
151146
editEndLine = match.symbol.range.endLine - 1;
152147
targetLabel = params.target;

src/tools/file/file-read.ts

Lines changed: 43 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ import {
1111
fileExtractStructure,
1212
fileReadContent,
1313
fileHighlightReadRange,
14-
OrphanedSymbolNode,
15-
UnifiedFileSymbol,
16-
UnifiedFileResult,
14+
type OrphanedItem,
15+
type FileSymbol,
16+
type FileStructure,
1717
} from '../../client-pipe.js';
1818
import {getClientWorkspace} from '../../config.js';
1919
import {zod} from '../../third_party/index.js';
@@ -27,11 +27,6 @@ function resolveFilePath(file: string): string {
2727
return path.resolve(getClientWorkspace(), file);
2828
}
2929

30-
// Supported TS/JS file extensions for structured extraction
31-
const STRUCTURED_EXTS = new Set([
32-
'ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs',
33-
]);
34-
3530
// Special target keywords for orphaned content
3631
const SPECIAL_TARGETS = ['#imports', '#exports', '#comments'] as const;
3732
type SpecialTarget = typeof SPECIAL_TARGETS[number];
@@ -55,12 +50,12 @@ function addLineNumbers(content: string, startLine1: number): string {
5550
}
5651

5752
function formatSkeletonEntry(
58-
symbol: SymbolLike | OrphanedSymbolNode,
53+
symbol: SymbolLike | OrphanedItem,
5954
indent = '',
6055
recursive = false,
6156
): string[] {
6257
const lines: string[] = [];
63-
// Both UnifiedFileSymbol and OrphanedSymbolNode are 1-indexed
58+
// FileSymbol uses startLine/endLine, OrphanedItem uses start/end
6459
const startLine = 'startLine' in symbol.range ? symbol.range.startLine : symbol.range.start;
6560
const endLine = 'startLine' in symbol.range ? symbol.range.endLine : symbol.range.end;
6661
const range = startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`;
@@ -129,15 +124,15 @@ interface NonSymbolBlock {
129124
}
130125

131126
type LineOwner =
132-
| { type: 'symbol'; symbol: UnifiedFileSymbol }
127+
| { type: 'symbol'; symbol: FileSymbol }
133128
| { type: 'block'; block: NonSymbolBlock };
134129

135130
/**
136131
* Group consecutive non-symbol items of the same type into atomic blocks.
137132
* Each block represents a contiguous run of the same non-symbol category.
138133
* Lines within symbol ranges are excluded — only "between-symbol" content forms blocks.
139134
*/
140-
function buildNonSymbolBlocks(structure: UnifiedFileResult): NonSymbolBlock[] {
135+
function buildNonSymbolBlocks(structure: FileStructure): NonSymbolBlock[] {
141136
// Build set of lines owned by symbols so we can exclude them
142137
const symbolLines = new Set<number>();
143138
for (const sym of structure.symbols) {
@@ -148,26 +143,19 @@ function buildNonSymbolBlocks(structure: UnifiedFileResult): NonSymbolBlock[] {
148143

149144
const tagged: Array<{ line: number; type: NonSymbolType }> = [];
150145

151-
for (const imp of structure.imports) {
152-
for (let line = imp.range.start; line <= imp.range.end; line++) {
153-
if (!symbolLines.has(line)) tagged.push({ line, type: 'import' });
154-
}
155-
}
156-
for (const exp of structure.exports) {
157-
for (let line = exp.range.start; line <= exp.range.end; line++) {
158-
if (!symbolLines.has(line)) tagged.push({ line, type: 'export' });
159-
}
160-
}
161-
for (const comment of structure.orphanComments) {
162-
for (let line = comment.range.start; line <= comment.range.end; line++) {
163-
if (!symbolLines.has(line)) tagged.push({ line, type: 'comment' });
164-
}
165-
}
166-
for (const dir of structure.directives) {
167-
for (let line = dir.range.start; line <= dir.range.end; line++) {
168-
if (!symbolLines.has(line)) tagged.push({ line, type: 'directive' });
146+
for (const item of structure.orphaned.items) {
147+
const mappedType: NonSymbolType =
148+
item.category === 'import' ? 'import' :
149+
item.category === 'export' ? 'export' :
150+
item.category === 'comment' ? 'comment' :
151+
item.category === 'directive' ? 'directive' :
152+
'comment'; // footnote/linkdef fall under comment for rendering
153+
154+
for (let line = item.range.start; line <= item.range.end; line++) {
155+
if (!symbolLines.has(line)) tagged.push({ line, type: mappedType });
169156
}
170157
}
158+
171159
for (const gap of structure.gaps) {
172160
for (let line = gap.start; line <= gap.end; line++) {
173161
if (!symbolLines.has(line)) tagged.push({ line, type: 'gap' });
@@ -197,7 +185,7 @@ function buildNonSymbolBlocks(structure: UnifiedFileResult): NonSymbolBlock[] {
197185
* Only covers lines within the requested range for efficiency.
198186
*/
199187
function classifyLines(
200-
structure: UnifiedFileResult,
188+
structure: FileStructure,
201189
blocks: NonSymbolBlock[],
202190
startLine: number,
203191
endLine: number,
@@ -259,7 +247,7 @@ function expandToBlockBoundaries(
259247
* Returns the actual source-line range that the output covers (for highlighting).
260248
*/
261249
function renderStructuredRange(
262-
structure: UnifiedFileResult,
250+
structure: FileStructure,
263251
allLines: string[],
264252
requestedStart: number,
265253
requestedEnd: number,
@@ -280,7 +268,7 @@ function renderStructuredRange(
280268
const owners = classifyLines(structure, blocks, expandedStart, expandedEnd);
281269

282270
const result: string[] = [];
283-
const emittedSymbols = new Set<UnifiedFileSymbol>();
271+
const emittedSymbols = new Set<FileSymbol>();
284272
const emittedBlocks = new Set<NonSymbolBlock>();
285273

286274
// Track the actual source-line range that the output covers
@@ -504,14 +492,11 @@ export const read = defineTool({
504492
}
505493

506494
// Check if this is a TS/JS file that supports structured extraction
507-
const ext = path.extname(filePath).slice(1).toLowerCase();
508-
const isStructuredFile = STRUCTURED_EXTS.has(ext);
509-
510-
// Get unified structure for TS/JS files (one call replaces three)
511-
let structure: UnifiedFileResult | undefined;
495+
// Get file structure via registry (supports any registered language)
496+
let structure: FileStructure | undefined;
512497
let allLines: string[] = [];
513-
if (isStructuredFile) {
514-
structure = await fileExtractStructure(filePath);
498+
structure = await fileExtractStructure(filePath);
499+
if (structure) {
515500
allLines = structure.content.split('\n');
516501
}
517502

@@ -573,7 +558,7 @@ export const read = defineTool({
573558
// ── Skeleton mode (no targets, no line range) ─────────────
574559
if (targets.length === 0 && skeleton) {
575560
if (!structure) {
576-
response.appendResponseLine('Skeleton mode requires a TypeScript or JavaScript file.');
561+
response.appendResponseLine('Skeleton mode requires a supported structured file type.');
577562
return;
578563
}
579564

@@ -582,22 +567,19 @@ export const read = defineTool({
582567
startLine: number;
583568
endLine: number;
584569
category: 'imports' | 'exports' | 'comments' | 'directives' | 'symbol' | 'raw';
585-
symbol?: UnifiedFileSymbol;
570+
symbol?: FileSymbol;
586571
}
587572

588573
const pieces: SkeletonPiece[] = [];
589574

590-
for (const imp of structure.imports) {
591-
pieces.push({ startLine: imp.range.start, endLine: imp.range.end, category: 'imports' });
592-
}
593-
for (const exp of structure.exports) {
594-
pieces.push({ startLine: exp.range.start, endLine: exp.range.end, category: 'exports' });
595-
}
596-
for (const comment of structure.orphanComments) {
597-
pieces.push({ startLine: comment.range.start, endLine: comment.range.end, category: 'comments' });
598-
}
599-
for (const dir of structure.directives) {
600-
pieces.push({ startLine: dir.range.start, endLine: dir.range.end, category: 'directives' });
575+
for (const item of structure.orphaned.items) {
576+
const cat: SkeletonPiece['category'] =
577+
item.category === 'import' ? 'imports' :
578+
item.category === 'export' ? 'exports' :
579+
item.category === 'comment' ? 'comments' :
580+
item.category === 'directive' ? 'directives' :
581+
'comments';
582+
pieces.push({ startLine: item.range.start, endLine: item.range.end, category: cat });
601583
}
602584
for (const sym of structure.symbols) {
603585
pieces.push({ startLine: sym.range.startLine, endLine: sym.range.endLine, category: 'symbol', symbol: sym });
@@ -662,22 +644,22 @@ export const read = defineTool({
662644

663645
// ── Targets mode ─────────────────────────────────────────
664646

665-
// Targets require structured extraction (TS/JS only)
647+
// Targets require structured extraction
666648
if (!structure) {
667649
response.appendResponseLine(
668-
'Target-based reading requires a TypeScript or JavaScript file.',
650+
'Target-based reading requires a supported structured file type.',
669651
);
670652
return;
671653
}
672654

673655
for (const target of targets) {
674656
if (isSpecialTarget(target)) {
675657
// Handle special keywords: #imports, #exports, #comments
676-
const items = target === '#imports'
677-
? structure.imports
678-
: target === '#exports'
679-
? structure.exports
680-
: structure.orphanComments;
658+
const categoryFilter =
659+
target === '#imports' ? 'import' :
660+
target === '#exports' ? 'export' :
661+
'comment';
662+
const items = structure.orphaned.items.filter(i => i.category === categoryFilter);
681663

682664
if (skeleton) {
683665
for (const item of items) {
@@ -694,7 +676,7 @@ export const read = defineTool({
694676
}
695677
}
696678
} else {
697-
// Symbol targeting (1-indexed ranges from ts-morph)
679+
// Symbol targeting (1-indexed ranges)
698680
const match = resolveSymbolTarget(structure.symbols, target);
699681

700682
if (!match) {

0 commit comments

Comments
 (0)