Skip to content

Commit a848e77

Browse files
Refactor to keep core provider agnostic
1 parent e491111 commit a848e77

22 files changed

Lines changed: 486 additions & 263 deletions

src/core/analyzer.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* Analyzer module to extract FastAPI-related information from syntax trees.
33
*/
44

5-
import * as vscode from "vscode"
65
import type { Tree } from "web-tree-sitter"
76
import {
87
decoratorExtractor,
@@ -12,6 +11,7 @@ import {
1211
mountExtractor,
1312
routerExtractor,
1413
} from "./extractors.js"
14+
import type { FileSystem } from "./filesystem"
1515
import type { FileAnalysis } from "./internal"
1616
import type { Parser } from "./parser.js"
1717

@@ -46,19 +46,20 @@ export function analyzeTree(tree: Tree, filePath: string): FileAnalysis {
4646
return { filePath, routes, routers, includeRouters, mounts, imports }
4747
}
4848

49-
/** Analyze a file given its URI and a parser instance */
49+
/** Analyze a file given its URI string and a parser instance */
5050
export async function analyzeFile(
51-
fileUri: vscode.Uri,
51+
fileUri: string,
5252
parser: Parser,
53+
fs: FileSystem,
5354
): Promise<FileAnalysis | null> {
5455
try {
55-
const content = await vscode.workspace.fs.readFile(fileUri)
56+
const content = await fs.readFile(fileUri)
5657
const code = new TextDecoder().decode(content)
5758
const tree = parser.parse(code)
5859
if (!tree) {
5960
return null
6061
}
61-
return analyzeTree(tree, fileUri.fsPath)
62+
return analyzeTree(tree, fileUri)
6263
} catch {
6364
return null
6465
}

src/core/filesystem.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Abstract filesystem interface for platform-agnostic file operations.
3+
* This allows the core logic to work with any filesystem implementation
4+
* (VS Code virtual filesystem, Node.js fs, Zed, etc.).
5+
*/
6+
7+
/**
8+
* Filesystem abstraction for reading files and checking existence.
9+
* Implementations should use URI strings as identifiers.
10+
*/
11+
export interface FileSystem {
12+
/** Read a file's contents as bytes */
13+
readFile(uri: string): Promise<Uint8Array>
14+
15+
/** Check if a file or directory exists */
16+
exists(uri: string): Promise<boolean>
17+
18+
/** Join path segments to a base URI */
19+
joinPath(base: string, ...segments: string[]): string
20+
21+
/** Get the parent directory of a URI */
22+
dirname(uri: string): string
23+
}

src/core/importResolver.ts

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
* 3. module/ without __init__.py (namespace package)
1212
*/
1313

14-
import * as vscode from "vscode"
15-
import { analyzeFile } from "./analyzer"
14+
import type { FileSystem } from "./filesystem"
1615
import type { ImportInfo } from "./internal"
17-
import type { Parser } from "./parser"
1816
import { uriDirname } from "./pathUtils"
1917

2018
/**
@@ -23,20 +21,14 @@ import { uriDirname } from "./pathUtils"
2321
*/
2422
const fileExistsCache = new Map<string, boolean>()
2523

26-
async function cachedExists(uri: vscode.Uri): Promise<boolean> {
27-
const key = uri.toString()
28-
const cached = fileExistsCache.get(key)
24+
async function cachedExists(uri: string, fs: FileSystem): Promise<boolean> {
25+
const cached = fileExistsCache.get(uri)
2926
if (cached !== undefined) {
3027
return cached
3128
}
32-
try {
33-
await vscode.workspace.fs.stat(uri)
34-
fileExistsCache.set(key, true)
35-
return true
36-
} catch {
37-
fileExistsCache.set(key, false)
38-
return false
39-
}
29+
const exists = await fs.exists(uri)
30+
fileExistsCache.set(uri, exists)
31+
return exists
4032
}
4133

4234
/** Clears the file existence cache. Call when files may have changed. */
@@ -50,16 +42,17 @@ export function clearImportCache(): void {
5042
* (matching Python's import resolution order).
5143
*/
5244
async function resolvePythonModule(
53-
baseUri: vscode.Uri,
54-
): Promise<vscode.Uri | null> {
45+
baseUri: string,
46+
fs: FileSystem,
47+
): Promise<string | null> {
5548
// Try module.py
56-
const pyUri = baseUri.with({ path: `${baseUri.path}.py` })
57-
if (await cachedExists(pyUri)) {
49+
const pyUri = `${baseUri}.py`
50+
if (await cachedExists(pyUri, fs)) {
5851
return pyUri
5952
}
6053
// Try module/__init__.py
61-
const initUri = vscode.Uri.joinPath(baseUri, "__init__.py")
62-
if (await cachedExists(initUri)) {
54+
const initUri = fs.joinPath(baseUri, "__init__.py")
55+
if (await cachedExists(initUri, fs)) {
6356
return initUri
6457
}
6558
return null
@@ -87,10 +80,11 @@ function findImportByExportedName(
8780
*/
8881
function modulePathToDir(
8982
importInfo: Pick<ImportInfo, "modulePath" | "isRelative" | "relativeDots">,
90-
currentFileUri: vscode.Uri,
91-
projectRootUri: vscode.Uri,
92-
): vscode.Uri {
93-
let baseDirUri: vscode.Uri
83+
currentFileUri: string,
84+
projectRootUri: string,
85+
fs: FileSystem,
86+
): string {
87+
let baseDirUri: string
9488
if (importInfo.isRelative) {
9589
// For relative imports, go up 'relativeDots' directories from current file
9690
baseDirUri = uriDirname(currentFileUri)
@@ -102,7 +96,7 @@ function modulePathToDir(
10296
}
10397

10498
if (importInfo.modulePath) {
105-
return vscode.Uri.joinPath(baseDirUri, ...importInfo.modulePath.split("."))
99+
return fs.joinPath(baseDirUri, ...importInfo.modulePath.split("."))
106100
}
107101
return baseDirUri
108102
}
@@ -118,61 +112,68 @@ function modulePathToDir(
118112
*/
119113
export async function resolveImport(
120114
importInfo: Pick<ImportInfo, "modulePath" | "isRelative" | "relativeDots">,
121-
currentFileUri: vscode.Uri,
122-
projectRootUri: vscode.Uri,
123-
): Promise<vscode.Uri | null> {
115+
currentFileUri: string,
116+
projectRootUri: string,
117+
fs: FileSystem,
118+
): Promise<string | null> {
124119
const resolvedUri = modulePathToDir(
125120
importInfo,
126121
currentFileUri,
127122
projectRootUri,
123+
fs,
128124
)
129-
return resolvePythonModule(resolvedUri)
125+
return resolvePythonModule(resolvedUri, fs)
130126
}
131127

132128
/**
133129
* Resolves a named import to its file URI.
134130
* For example, from .routes import users
135131
* will try to resolve to routes/users.py
132+
*
133+
* @param analyzeFileFn - Function to analyze a file (injected to avoid circular dependency)
136134
*/
137135
export async function resolveNamedImport(
138136
importInfo: Pick<
139137
ImportInfo,
140138
"modulePath" | "names" | "isRelative" | "relativeDots"
141139
>,
142-
currentFileUri: vscode.Uri,
143-
projectRootUri: vscode.Uri,
144-
parser?: Parser,
145-
): Promise<vscode.Uri | null> {
140+
currentFileUri: string,
141+
projectRootUri: string,
142+
fs: FileSystem,
143+
analyzeFileFn?: (uri: string) => Promise<{ imports: ImportInfo[] } | null>,
144+
): Promise<string | null> {
146145
const baseUri = await resolveImport(
147146
importInfo,
148147
currentFileUri,
149148
projectRootUri,
149+
fs,
150150
)
151151

152152
// Calculate base directory for named import resolution.
153153
// For namespace packages (directories without __init__.py), baseUri will be null,
154154
// so we compute the directory path directly from the module path.
155155
const baseDirUri = baseUri
156156
? uriDirname(baseUri)
157-
: modulePathToDir(importInfo, currentFileUri, projectRootUri)
157+
: modulePathToDir(importInfo, currentFileUri, projectRootUri, fs)
158158

159159
for (const name of importInfo.names) {
160160
// Try direct file: from .routes import users -> routes/users.py
161-
const namedUri = vscode.Uri.joinPath(baseDirUri, ...name.split("."))
162-
const resolved = await resolvePythonModule(namedUri)
161+
const namedUri = fs.joinPath(baseDirUri, ...name.split("."))
162+
const resolved = await resolvePythonModule(namedUri, fs)
163163
if (resolved) {
164164
return resolved
165165
}
166166

167167
// Try re-exports: from .routes import users where routes/__init__.py re-exports users
168-
if (baseUri?.path.endsWith("__init__.py") && parser) {
169-
const analysis = await analyzeFile(baseUri, parser)
168+
if (baseUri?.endsWith("__init__.py") && analyzeFileFn) {
169+
const analysis = await analyzeFileFn(baseUri)
170170
const imp = analysis && findImportByExportedName(analysis.imports, name)
171171
if (imp) {
172172
const reExportResolved = await resolveImport(
173173
imp,
174174
baseUri,
175175
projectRootUri,
176+
fs,
176177
)
177178
if (reExportResolved) {
178179
return reExportResolved
@@ -192,22 +193,28 @@ export async function resolveNamedImport(
192193
* For example, if integrations/__init__.py contains:
193194
* from .router import router as router
194195
* This will return the path to integrations/router.py
196+
*
197+
* @param analyzeFileFn - Function to analyze a file (injected to avoid circular dependency)
195198
*/
196199
export async function resolveRouterFromInit(
197-
initFileUri: vscode.Uri,
198-
projectRootUri: vscode.Uri,
199-
parser: Parser,
200-
): Promise<vscode.Uri | null> {
201-
if (!initFileUri.path.endsWith("__init__.py")) {
200+
initFileUri: string,
201+
projectRootUri: string,
202+
fs: FileSystem,
203+
analyzeFileFn: (uri: string) => Promise<{
204+
imports: ImportInfo[]
205+
routers: { variableName: string }[]
206+
} | null>,
207+
): Promise<string | null> {
208+
if (!initFileUri.endsWith("__init__.py")) {
202209
return null
203210
}
204211

205-
const analysis = await analyzeFile(initFileUri, parser)
212+
const analysis = await analyzeFileFn(initFileUri)
206213
// If file has routers defined, no need to follow re-exports
207214
if (!analysis || analysis.routers.length > 0) {
208215
return null
209216
}
210217

211218
const imp = findImportByExportedName(analysis.imports, "router")
212-
return imp ? resolveImport(imp, initFileUri, projectRootUri) : null
219+
return imp ? resolveImport(imp, initFileUri, projectRootUri, fs) : null
213220
}

src/core/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
*/
55

66
export { analyzeFile, analyzeTree } from "./analyzer"
7+
export type { FileSystem } from "./filesystem"
8+
export { clearImportCache } from "./importResolver"
79
export type { FileAnalysis } from "./internal"
810
export { Parser } from "./parser"
11+
export { findProjectRoot } from "./pathUtils"
912
export { buildRouterGraph, type RouterNode } from "./routerResolver"
1013
export { routerNodeToAppDefinition } from "./transformer"
1114
export type {

src/core/parser.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,43 @@
22
* Parser service using Web Tree Sitter to parse Python code.
33
*/
44

5-
import * as vscode from "vscode"
65
import { Language, Parser as TreeSitterParser } from "web-tree-sitter"
76

87
export class Parser {
98
private parser: TreeSitterParser | null = null
10-
async init(wasmPaths: { core: vscode.Uri; python: vscode.Uri }) {
9+
10+
/**
11+
* Initialize the parser with WASM binaries.
12+
* @param wasmBinaries.core - The web-tree-sitter.wasm binary
13+
* @param wasmBinaries.python - The tree-sitter-python.wasm binary
14+
*/
15+
async init(wasmBinaries: { core: Uint8Array; python: Uint8Array }) {
1116
if (this.parser) {
1217
return
1318
}
1419

15-
// Read WASM files via VS Code's virtual filesystem API
16-
const [wasmBinary, pythonWasmBinary] = await Promise.all([
17-
vscode.workspace.fs.readFile(wasmPaths.core),
18-
vscode.workspace.fs.readFile(wasmPaths.python),
19-
])
20+
// Pre-compile the WASM module from the binary
21+
const wasmModule = await WebAssembly.compile(wasmBinaries.core)
2022

21-
// Initialize tree-sitter with the core WASM binary
23+
// Use instantiateWasm to provide custom WASM instantiation.
24+
// This bypasses tree-sitter's default URL-based loading which fails
25+
// in VS Code web extensions where import.meta.url is not available.
2226
await TreeSitterParser.init({
23-
locateFile: () => wasmPaths.core.toString(),
24-
wasmBinary,
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
instantiateWasm(imports: any, successCallback: any) {
29+
WebAssembly.instantiate(wasmModule, imports).then(
30+
(instance: WebAssembly.Instance) => {
31+
successCallback(instance, wasmModule)
32+
},
33+
)
34+
return {}
35+
},
2536
})
2637

2738
const parser = new TreeSitterParser()
2839

2940
// Load Python language from WASM binary
30-
const pythonLanguage = await Language.load(new Uint8Array(pythonWasmBinary))
41+
const pythonLanguage = await Language.load(wasmBinaries.python)
3142
parser.setLanguage(pythonLanguage)
3243

3344
this.parser = parser

0 commit comments

Comments
 (0)