Skip to content

Commit 0db6e28

Browse files
refactor: extract symbol collection logic from overview command (#570)
1 parent 2b66b3b commit 0db6e28

13 files changed

Lines changed: 389 additions & 80 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@effect/language-service": patch
3+
---
4+
5+
Refactor CLI overview command to extract symbol collection logic into reusable utility
6+
7+
- Extract `collectSourceFileExportedSymbols` into `src/cli/utils/ExportedSymbols.ts` for reuse across CLI commands
8+
- Add `--max-symbol-depth` option to overview command (default: 3) to control how deep to traverse nested symbol properties
9+
- Add tests for the overview command with snapshot testing

src/cli/overview.ts

Lines changed: 43 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import * as TypeParser from "../core/TypeParser"
2323
import * as TypeScriptApi from "../core/TypeScriptApi"
2424
import * as TypeScriptUtils from "../core/TypeScriptUtils"
2525
import { getFileNamesInTsConfig, TypeScriptContext } from "./utils"
26+
import * as ExportedSymbols from "./utils/ExportedSymbols"
2627
import * as Spinner from "./utils/Spinner"
2728

2829
/**
@@ -45,7 +46,7 @@ export class NoFilesToCheckError extends Data.TaggedError("NoFilesToCheckError")
4546
}
4647
}
4748

48-
interface ServiceInfo {
49+
export interface ServiceInfo {
4950
readonly name: string
5051
readonly filePath: string
5152
readonly line: number
@@ -54,7 +55,7 @@ interface ServiceInfo {
5455
readonly description: string | undefined
5556
}
5657

57-
interface LayerInfo {
58+
export interface LayerInfo {
5859
readonly name: string
5960
readonly filePath: string
6061
readonly line: number
@@ -63,7 +64,7 @@ interface LayerInfo {
6364
readonly description: string | undefined
6465
}
6566

66-
interface ErrorInfo {
67+
export interface ErrorInfo {
6768
readonly name: string
6869
readonly filePath: string
6970
readonly line: number
@@ -72,7 +73,13 @@ interface ErrorInfo {
7273
readonly description: string | undefined
7374
}
7475

75-
interface OverviewResult {
76+
export interface ExportedItemsResult {
77+
readonly services: Array<ServiceInfo>
78+
readonly layers: Array<LayerInfo>
79+
readonly errors: Array<ErrorInfo>
80+
}
81+
82+
export interface OverviewResult extends ExportedItemsResult {
7683
readonly services: Array<ServiceInfo>
7784
readonly layers: Array<LayerInfo>
7885
readonly errors: Array<ErrorInfo>
@@ -91,32 +98,22 @@ const typeToString = (
9198
): string => typeChecker.typeToString(type, undefined, tsInstance.TypeFormatFlags.NoTruncation)
9299

93100
/**
94-
* Gets the location info from a declaration
95-
*/
96-
const getLocationFromDeclaration = (
97-
declaration: ts.Declaration,
98-
tsInstance: typeof ts
99-
): { filePath: string; line: number; column: number } | undefined => {
100-
const sourceFile = declaration.getSourceFile()
101-
if (!sourceFile) return undefined
102-
const { character, line } = tsInstance.getLineAndCharacterOfPosition(sourceFile, declaration.getStart())
103-
return {
104-
filePath: sourceFile.fileName,
105-
line: line + 1,
106-
column: character + 1
107-
}
108-
}
109-
110-
/**
111-
* Collects all exported services, layers, and errors from a source file using getExportsOfModule
112-
* Also traverses properties of exported symbols to find nested services/layers (e.g., Effect.Service.Default)
101+
* Collects all exported services, layers, and errors from a source file.
102+
* Uses ExportedSymbols to get all exported symbols with their names and locations,
103+
* then checks each symbol's type to categorize it.
104+
*
105+
* @param sourceFile - The source file to collect exports from
106+
* @param tsInstance - The TypeScript instance
107+
* @param typeChecker - The TypeScript type checker
108+
* @param maxSymbolDepth - Maximum depth to traverse nested properties (default: 3)
113109
*/
114-
const collectExportedItems = (
110+
export const collectExportedItems = (
115111
sourceFile: ts.SourceFile,
116112
tsInstance: typeof ts,
117-
typeChecker: ts.TypeChecker
113+
typeChecker: ts.TypeChecker,
114+
maxSymbolDepth: number = 3
118115
): Nano.Nano<
119-
{ services: Array<ServiceInfo>; layers: Array<LayerInfo>; errors: Array<ErrorInfo> },
116+
ExportedItemsResult,
120117
never,
121118
TypeParser.TypeParser
122119
> =>
@@ -126,61 +123,19 @@ const collectExportedItems = (
126123
const layers: Array<LayerInfo> = []
127124
const errors: Array<ErrorInfo> = []
128125

129-
// Get the module symbol for the source file
130-
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile)
131-
if (!moduleSymbol) {
132-
return { services, layers, errors }
133-
}
134-
135-
// Get all exports from the module
136-
const exports = typeChecker.getExportsOfModule(moduleSymbol)
137-
138-
type CodeLocation = { filePath: string; line: number; column: number }
139-
140-
// Work queue: [symbol, qualifiedName, codeLocation | undefined]
141-
// Initialize with exported symbols using their names and declaration locations
142-
const workQueue: Array<[ts.Symbol, string, CodeLocation | undefined]> = exports.map((s) => {
143-
const declarations = s.getDeclarations()
144-
const location = declarations && declarations.length > 0
145-
? getLocationFromDeclaration(declarations[0], tsInstance)
146-
: undefined
147-
return [s, tsInstance.symbolName(s), location]
148-
})
149-
// Track which symbols have been exploded (properties added to queue)
150-
const exploded = new WeakSet<ts.Symbol>()
151-
152-
while (workQueue.length > 0) {
153-
const [symbol, name, location] = workQueue.shift()!
154-
155-
if (!location) continue
156-
157-
const type = typeChecker.getTypeOfSymbol(symbol)
158-
159-
// Explode symbol: add its properties to the queue (only once per symbol)
160-
// Child symbols inherit the parent's code location
161-
if (!exploded.has(symbol)) {
162-
exploded.add(symbol)
163-
164-
const properties = typeChecker.getPropertiesOfType(type)
165-
for (const propSymbol of properties) {
166-
const propName = tsInstance.symbolName(propSymbol)
167-
// Skip prototype property - it contains instance type, not a real export
168-
if (propName === "prototype") continue
169-
const childName = `${name}.${propName}`
170-
workQueue.push([propSymbol, childName, location])
171-
}
172-
}
126+
// Get all exported symbols with their names and locations
127+
const exportedSymbols = ExportedSymbols.collectSourceFileExportedSymbols(
128+
sourceFile,
129+
tsInstance,
130+
typeChecker,
131+
maxSymbolDepth
132+
)
173133

134+
for (const { description, location, name, symbol, type } of exportedSymbols) {
174135
// Get a declaration for type parsing context
175136
const declarations = symbol.getDeclarations()
176137
const declaration = declarations && declarations.length > 0 ? declarations[0] : sourceFile
177138

178-
// Get JSDoc description if available
179-
const docComment = symbol.getDocumentationComment(typeChecker)
180-
const description = docComment.length > 0
181-
? docComment.map((part) => part.text).join("")
182-
: undefined
183-
184139
// Check if it's a Context.Tag (has _Identifier and _Service variance)
185140
const contextTagResult = yield* pipe(
186141
typeParser.contextTag(type, declaration),
@@ -347,7 +302,7 @@ const renderError = (error: ErrorInfo, cwd: string): Doc.AnsiDoc => {
347302
/**
348303
* Renders the overview result as a styled document
349304
*/
350-
const renderOverview = (result: OverviewResult, cwd: string): Doc.AnsiDoc => {
305+
export const renderOverview = (result: OverviewResult, cwd: string): Doc.AnsiDoc => {
351306
const lines: Array<Doc.AnsiDoc> = []
352307

353308
// Errors section
@@ -391,8 +346,9 @@ const renderOverview = (result: OverviewResult, cwd: string): Doc.AnsiDoc => {
391346
const collectAllItems = (
392347
filesToCheck: Set<string>,
393348
tsInstance: typeof ts,
349+
maxSymbolDepth: number,
394350
onProgress: (current: number, total: number) => Effect.Effect<void>
395-
): Effect.Effect<{ services: Array<ServiceInfo>; layers: Array<LayerInfo>; errors: Array<ErrorInfo> }> =>
351+
): Effect.Effect<ExportedItemsResult> =>
396352
Effect.gen(function*() {
397353
const services: Array<ServiceInfo> = []
398354
const layers: Array<LayerInfo> = []
@@ -421,7 +377,7 @@ const collectAllItems = (
421377
if (!sourceFile) continue
422378

423379
const result = pipe(
424-
collectExportedItems(sourceFile, tsInstance, program.getTypeChecker()),
380+
collectExportedItems(sourceFile, tsInstance, program.getTypeChecker(), maxSymbolDepth),
425381
TypeParser.nanoLayer,
426382
TypeCheckerUtils.nanoLayer,
427383
TypeScriptUtils.nanoLayer,
@@ -461,9 +417,15 @@ export const overview = Command.make(
461417
project: Options.file("project").pipe(
462418
Options.optional,
463419
Options.withDescription("The full path of the project tsconfig.json file to analyze.")
420+
),
421+
maxSymbolDepth: Options.integer("max-symbol-depth").pipe(
422+
Options.withDefault(3),
423+
Options.withDescription(
424+
"Maximum depth to traverse nested symbol properties. 0 = only root exports, 1 = root + one level, etc."
425+
)
464426
)
465427
},
466-
Effect.fn("overview")(function*({ file, project }) {
428+
Effect.fn("overview")(function*({ file, maxSymbolDepth, project }) {
467429
const path = yield* Path.Path
468430
const cwd = path.resolve(".")
469431
const tsInstance = yield* TypeScriptContext
@@ -486,6 +448,7 @@ export const overview = Command.make(
486448
collectAllItems(
487449
filesToCheck,
488450
tsInstance,
451+
maxSymbolDepth,
489452
(current, total) => handle.updateMessage(`Processing file ${current}/${total}...`)
490453
),
491454
{

src/cli/utils/ExportedSymbols.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import type * as ts from "typescript"
2+
3+
/**
4+
* Location information for an exported symbol
5+
*/
6+
export interface SymbolLocation {
7+
readonly filePath: string
8+
readonly line: number
9+
readonly column: number
10+
}
11+
12+
/**
13+
* Information about an exported symbol
14+
*/
15+
export interface ExportedSymbolInfo {
16+
readonly symbol: ts.Symbol
17+
readonly name: string
18+
readonly location: SymbolLocation
19+
readonly type: ts.Type
20+
readonly description: string | undefined
21+
}
22+
23+
/**
24+
* Gets the location info from a declaration
25+
*/
26+
const getLocationFromDeclaration = (
27+
declaration: ts.Declaration,
28+
tsInstance: typeof ts
29+
): SymbolLocation | undefined => {
30+
const sourceFile = declaration.getSourceFile()
31+
if (!sourceFile) return undefined
32+
const { character, line } = tsInstance.getLineAndCharacterOfPosition(sourceFile, declaration.getStart())
33+
return {
34+
filePath: sourceFile.fileName,
35+
line: line + 1,
36+
column: character + 1
37+
}
38+
}
39+
40+
/**
41+
* Collects all exported symbols from a source file, including nested properties.
42+
* For example, if a module exports `Foo` which has a `Default` property, this will
43+
* return both `Foo` and `Foo.Default` as separate entries.
44+
*
45+
* @param sourceFile - The source file to collect exports from
46+
* @param tsInstance - The TypeScript instance
47+
* @param typeChecker - The TypeScript type checker
48+
* @param maxSymbolDepth - Maximum depth to traverse nested properties.
49+
* 0 = only root exports, 1 = root + one level of properties, etc.
50+
* Default is 0 (only root exports).
51+
*/
52+
export const collectSourceFileExportedSymbols = (
53+
sourceFile: ts.SourceFile,
54+
tsInstance: typeof ts,
55+
typeChecker: ts.TypeChecker,
56+
maxSymbolDepth: number = 0
57+
): Array<ExportedSymbolInfo> => {
58+
const result: Array<ExportedSymbolInfo> = []
59+
60+
// Get the module symbol for the source file
61+
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile)
62+
if (!moduleSymbol) {
63+
return result
64+
}
65+
66+
// Get all exports from the module
67+
const exports = typeChecker.getExportsOfModule(moduleSymbol)
68+
69+
// Work queue: [symbol, qualifiedName, location | undefined, depth]
70+
// Initialize with exported symbols using their names and declaration locations at depth 0
71+
const workQueue: Array<[ts.Symbol, string, SymbolLocation | undefined, number]> = exports.map((s) => {
72+
const declarations = s.getDeclarations()
73+
const location = declarations && declarations.length > 0
74+
? getLocationFromDeclaration(declarations[0], tsInstance)
75+
: undefined
76+
return [s, tsInstance.symbolName(s), location, 0]
77+
})
78+
79+
// Track which symbols have been exploded (properties added to queue)
80+
const exploded = new WeakSet<ts.Symbol>()
81+
82+
while (workQueue.length > 0) {
83+
const [symbol, name, location, depth] = workQueue.shift()!
84+
85+
if (!location) continue
86+
87+
const type = typeChecker.getTypeOfSymbol(symbol)
88+
89+
// Explode symbol: add its properties to the queue (only once per symbol)
90+
// Child symbols inherit the parent's code location
91+
// Only explode if we haven't reached maxSymbolDepth
92+
if (!exploded.has(symbol) && depth < maxSymbolDepth) {
93+
exploded.add(symbol)
94+
95+
const properties = typeChecker.getPropertiesOfType(type)
96+
for (const propSymbol of properties) {
97+
const propName = tsInstance.symbolName(propSymbol)
98+
// Skip prototype property - it contains instance type, not a real export
99+
if (propName === "prototype") continue
100+
const childName = `${name}.${propName}`
101+
workQueue.push([propSymbol, childName, location, depth + 1])
102+
}
103+
}
104+
105+
// Get JSDoc description if available
106+
const docComment = symbol.getDocumentationComment(typeChecker)
107+
const description = docComment.length > 0
108+
? docComment.map((part) => part.text).join("")
109+
: undefined
110+
111+
result.push({
112+
symbol,
113+
name,
114+
location,
115+
type,
116+
description
117+
})
118+
}
119+
120+
return result
121+
}

0 commit comments

Comments
 (0)