diff --git a/.gitignore b/.gitignore index 3b295b6..b59b03e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ _.log # VS Code extension *.vsix .vscode-test +.vscode-test-web /.agents/ /.claude/ diff --git a/bun.lock b/bun.lock index 60af306..ca27b8c 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "esbuild": "^0.27.2", "husky": "^9.1.7", "lint-staged": "^16.2.7", + "path-browserify": "^1.0.1", "typescript": "^5.0.0", }, }, @@ -654,6 +655,8 @@ "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], diff --git a/esbuild.js b/esbuild.js index 06e6448..a1f0ddb 100644 --- a/esbuild.js +++ b/esbuild.js @@ -32,37 +32,57 @@ function copyWasmFiles() { async function main() { copyWasmFiles() - const entryPoints = ["src/extension.ts"] - if (!production) { - entryPoints.push(...globSync("src/test/**/*.test.ts")) - } + const testEntryPoints = !production ? globSync("src/test/**/*.test.ts") : [] - const ctx = await esbuild.context({ - entryPoints, + // Shared esbuild options + const sharedOptions = { bundle: true, - format: "cjs", minify: production, sourcemap: !production, sourcesContent: false, - platform: "node", - target: "node20", treeShaking: true, - outdir: "dist", - outbase: "src", - external: ["vscode", "web-tree-sitter"], logLevel: "info", define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', __DIST_ROOT__: JSON.stringify(path.join(import.meta.dirname, "dist")), }, + } + + // Node build (desktop VS Code) + const nodeCtx = await esbuild.context({ + ...sharedOptions, + entryPoints: ["src/extension.ts", ...testEntryPoints], + format: "cjs", + platform: "node", + target: "node20", + outdir: "dist", + outbase: "src", + external: ["vscode", "web-tree-sitter"], + }) + + // Browser build (vscode.dev) + const browserCtx = await esbuild.context({ + ...sharedOptions, + entryPoints: ["src/extension.ts"], + format: "cjs", + platform: "browser", + target: "es2022", + outfile: "dist/web/extension.js", + // Polyfill/alias node modules for browser + alias: { + "node:path": "path-browserify", + }, + // vscode is provided by the runtime; web-tree-sitter is bundled but + // internally references these Node.js modules for environment detection + external: ["vscode", "fs/promises", "module"], }) if (watch) { - await ctx.watch() + await Promise.all([nodeCtx.watch(), browserCtx.watch()]) console.log("Watching for changes...") } else { - await ctx.rebuild() - await ctx.dispose() + await Promise.all([nodeCtx.rebuild(), browserCtx.rebuild()]) + await Promise.all([nodeCtx.dispose(), browserCtx.dispose()]) } } diff --git a/package.json b/package.json index e3877aa..ecc4dbd 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,19 @@ "vscode": "^1.85.0" }, "main": "./dist/extension.js", + "browser": "./dist/web/extension.js", "activationEvents": [ "workspaceContains:**/*.py" ], "categories": [ "Other" ], + "capabilities": { + "virtualWorkspaces": true, + "untrustedWorkspaces": { + "supported": true + } + }, "contributes": { "commands": [ { @@ -112,11 +119,13 @@ "fastapi.entryPoint": { "type": "string", "default": "", + "scope": "resource", "description": "Path to the main FastAPI application file (e.g., 'src/main.py'). If not set, the extension will search common locations." }, "fastapi.showTestCodeLenses": { "type": "boolean", "default": true, + "scope": "resource", "description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition." } } @@ -130,6 +139,7 @@ "publish:marketplace": "vsce publish", "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/", "test": "bun run compile && vscode-test", + "test:web": "bun run compile && bunx @vscode/test-web --extensionDevelopmentPath=. --browserType=none", "prepare": "husky" }, "devDependencies": { @@ -143,6 +153,7 @@ "esbuild": "^0.27.2", "husky": "^9.1.7", "lint-staged": "^16.2.7", + "path-browserify": "^1.0.1", "typescript": "^5.0.0" }, "dependencies": { diff --git a/src/appDiscovery.ts b/src/appDiscovery.ts index 68a602a..f225adc 100644 --- a/src/appDiscovery.ts +++ b/src/appDiscovery.ts @@ -3,38 +3,38 @@ * Handles finding FastAPI apps via pyproject.toml, VS Code settings, or automatic detection. */ -import { existsSync } from "node:fs" -import { isAbsolute, sep } from "node:path" import * as toml from "toml" import * as vscode from "vscode" import type { EntryPoint } from "./core/internal" import type { Parser } from "./core/parser" -import { findProjectRoot } from "./core/pathUtils" +import { findProjectRoot, uriPath } from "./core/pathUtils" import { buildRouterGraph } from "./core/routerResolver" import { routerNodeToAppDefinition } from "./core/transformer" import type { AppDefinition } from "./core/types" +import { vscodeFileSystem } from "./providers/vscodeFileSystem" +import { log } from "./utils/logger" export type { EntryPoint } /** * Scans for common FastAPI entry point files (main.py, __init__.py). - * Returns paths sorted by depth (shallower first). + * Returns URI strings sorted by depth (shallower first). */ async function automaticDetectEntryPoints( - folderPath: string, + folder: vscode.WorkspaceFolder, ): Promise { const [mainFiles, initFiles] = await Promise.all([ vscode.workspace.findFiles( - new vscode.RelativePattern(folderPath, "**/main.py"), + new vscode.RelativePattern(folder, "**/main.py"), ), vscode.workspace.findFiles( - new vscode.RelativePattern(folderPath, "**/__init__.py"), + new vscode.RelativePattern(folder, "**/__init__.py"), ), ]) return [...mainFiles, ...initFiles] - .map((uri) => uri.fsPath) - .sort((a, b) => a.split(sep).length - b.split(sep).length) + .map((uri) => uri.toString()) + .sort((a, b) => uriPath(a).split("/").length - uriPath(b).split("/").length) } /** @@ -42,19 +42,16 @@ async function automaticDetectEntryPoints( * Supports module:variable notation, e.g. "my_app.main:app" */ async function parsePyprojectForEntryPoint( - folderPath: string, + folderUri: vscode.Uri, ): Promise { - const pyprojectPath = vscode.Uri.joinPath( - vscode.Uri.file(folderPath), - "pyproject.toml", - ) + const pyprojectUri = vscode.Uri.joinPath(folderUri, "pyproject.toml") - if (!existsSync(pyprojectPath.fsPath)) { + if (!(await vscodeFileSystem.exists(pyprojectUri.toString()))) { return null } try { - const document = await vscode.workspace.openTextDocument(pyprojectPath) + const document = await vscode.workspace.openTextDocument(pyprojectUri) const contents = toml.parse(document.getText()) as Record const entrypoint = (contents.tool as Record | undefined) @@ -73,13 +70,12 @@ async function parsePyprojectForEntryPoint( colonIndex === -1 ? undefined : entrypointValue.slice(colonIndex + 1) // Convert module path to file path: my_app.main -> my_app/main.py - const relativePath = `${modulePath.replace(/\./g, sep)}.py` - const fullPath = vscode.Uri.joinPath( - vscode.Uri.file(folderPath), - relativePath, - ).fsPath + const relativePath = `${modulePath.replace(/\./g, "/")}.py` + const fullUri = vscode.Uri.joinPath(folderUri, relativePath) - return existsSync(fullPath) ? { filePath: fullPath, variableName } : null + return (await vscodeFileSystem.exists(fullUri.toString())) + ? { filePath: fullUri.toString(), variableName } + : null } catch { // Invalid TOML syntax - silently fall back to auto-detection return null @@ -94,7 +90,14 @@ export async function discoverFastAPIApps( parser: Parser, ): Promise { const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders) return [] + if (!workspaceFolders) { + log("No workspace folders found") + return [] + } + + log( + `Discovering FastAPI apps in ${workspaceFolders.length} workspace folder(s)...`, + ) const apps: AppDefinition[] = [] @@ -106,53 +109,77 @@ export async function discoverFastAPIApps( // If user specified an entry point in settings, use that if (customEntryPoint) { - const entryPath = isAbsolute(customEntryPoint) - ? customEntryPoint - : vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath + const entryUri = customEntryPoint.startsWith("/") + ? vscode.Uri.file(customEntryPoint) + : vscode.Uri.joinPath(folder.uri, customEntryPoint) - if (!existsSync(entryPath)) { + if (!(await vscodeFileSystem.exists(entryUri.toString()))) { + log(`Custom entry point not found: ${customEntryPoint}`) vscode.window.showWarningMessage( `FastAPI entry point not found: ${customEntryPoint}`, ) continue } - candidates = [{ filePath: entryPath }] + log(`Using custom entry point: ${customEntryPoint}`) + candidates = [{ filePath: entryUri.toString() }] } else { // Otherwise, check pyproject.toml or auto-detect - const pyprojectEntry = await parsePyprojectForEntryPoint( - folder.uri.fsPath, - ) - candidates = pyprojectEntry - ? [pyprojectEntry] - : (await automaticDetectEntryPoints(folder.uri.fsPath)).map( - (filePath) => ({ filePath }), - ) + const pyprojectEntry = await parsePyprojectForEntryPoint(folder.uri) + if (pyprojectEntry) { + candidates = [pyprojectEntry] + } else { + const detected = await automaticDetectEntryPoints(folder) + candidates = detected.map((filePath) => ({ filePath })) + log( + `Found ${candidates.length} candidate entry file(s) in ${folder.name}`, + ) + } // If no candidates found, try the active editor as a last resort if (candidates.length === 0) { const activeEditor = vscode.window.activeTextEditor if (activeEditor?.document.languageId === "python") { - candidates = [{ filePath: activeEditor.document.uri.fsPath }] + candidates = [{ filePath: activeEditor.document.uri.toString() }] } } } for (const candidate of candidates) { - const projectRoot = findProjectRoot(candidate.filePath, folder.uri.fsPath) - const routerNode = buildRouterGraph( + const projectRoot = await findProjectRoot( + candidate.filePath, + folder.uri.toString(), + vscodeFileSystem, + ) + const routerNode = await buildRouterGraph( candidate.filePath, parser, projectRoot, + vscodeFileSystem, candidate.variableName, ) if (routerNode) { - apps.push(routerNodeToAppDefinition(routerNode, folder.uri.fsPath)) + const app = routerNodeToAppDefinition(routerNode, folder.uri.fsPath) + // Count all routes: direct routes + routes in all routers (recursively) + const countRoutes = (routers: typeof app.routers): number => + routers.reduce( + (sum, r) => sum + r.routes.length + countRoutes(r.children), + 0, + ) + const totalRoutes = app.routes.length + countRoutes(app.routers) + log( + `Found FastAPI app "${app.name}" with ${totalRoutes} route(s) in ${app.routers.length} router(s)`, + ) + apps.push(app) break } } } + if (apps.length === 0) { + log("No FastAPI apps found in workspace") + } + return apps } diff --git a/src/core/analyzer.ts b/src/core/analyzer.ts index 9f53fac..2b6b4fe 100644 --- a/src/core/analyzer.ts +++ b/src/core/analyzer.ts @@ -2,7 +2,6 @@ * Analyzer module to extract FastAPI-related information from syntax trees. */ -import { readFileSync } from "node:fs" import type { Tree } from "web-tree-sitter" import { logError } from "../utils/logger" import { @@ -13,6 +12,7 @@ import { mountExtractor, routerExtractor, } from "./extractors.js" +import type { FileSystem } from "./filesystem" import type { FileAnalysis } from "./internal" import type { Parser } from "./parser.js" @@ -47,21 +47,23 @@ export function analyzeTree(tree: Tree, filePath: string): FileAnalysis { return { filePath, routes, routers, includeRouters, mounts, imports } } -/** Analyze a file given its path and a parser instance */ -export function analyzeFile( - filePath: string, +/** Analyze a file given its URI string and a parser instance */ +export async function analyzeFile( + fileUri: string, parser: Parser, -): FileAnalysis | null { + fs: FileSystem, +): Promise { try { - const code = readFileSync(filePath, "utf-8") + const content = await fs.readFile(fileUri) + const code = new TextDecoder().decode(content) const tree = parser.parse(code) if (!tree) { - logError(`Failed to parse file: "${filePath}"`) + logError(`Failed to parse file: "${fileUri}"`) return null } - return analyzeTree(tree, filePath) + return analyzeTree(tree, fileUri) } catch (error) { - logError(`Error reading file: "${filePath}"`, error) + logError(`Error reading file: "${fileUri}"`, error) return null } } diff --git a/src/core/filesystem.ts b/src/core/filesystem.ts new file mode 100644 index 0000000..a8bd773 --- /dev/null +++ b/src/core/filesystem.ts @@ -0,0 +1,23 @@ +/** + * Abstract filesystem interface for platform-agnostic file operations. + * This allows the core logic to work with any filesystem implementation + * (VS Code virtual filesystem, Node.js fs, Zed, etc.). + */ + +/** + * Filesystem abstraction for reading files and checking existence. + * Implementations should use URI strings as identifiers. + */ +export interface FileSystem { + /** Read a file's contents as bytes */ + readFile(uri: string): Promise + + /** Check if a file or directory exists */ + exists(uri: string): Promise + + /** Join path segments to a base URI */ + joinPath(base: string, ...segments: string[]): string + + /** Get the parent directory of a URI */ + dirname(uri: string): string +} diff --git a/src/core/importResolver.ts b/src/core/importResolver.ts index 94ee3bf..d345718 100644 --- a/src/core/importResolver.ts +++ b/src/core/importResolver.ts @@ -11,24 +11,23 @@ * 3. module/ without __init__.py (namespace package) */ -import { existsSync } from "node:fs" -import { dirname, join } from "node:path" -import { analyzeFile } from "./analyzer" +import type { FileSystem } from "./filesystem" import type { ImportInfo } from "./internal" -import type { Parser } from "./parser" +import { uriDirname } from "./pathUtils" /** * Cache for file existence checks to avoid repeated filesystem calls. - * Maps file path -> exists (true/false). + * Maps URI string -> exists (true/false). */ const fileExistsCache = new Map() -function cachedExistsSync(path: string): boolean { - if (fileExistsCache.has(path)) { - return fileExistsCache.get(path)! +async function cachedExists(uri: string, fs: FileSystem): Promise { + const cached = fileExistsCache.get(uri) + if (cached !== undefined) { + return cached } - const exists = existsSync(path) - fileExistsCache.set(path, exists) + const exists = await fs.exists(uri) + fileExistsCache.set(uri, exists) return exists } @@ -42,14 +41,19 @@ export function clearImportCache(): void { * Checks for direct .py file first, then package __init__.py * (matching Python's import resolution order). */ -function resolvePythonModule(basePath: string): string | null { - const pyPath = `${basePath}.py` - if (cachedExistsSync(pyPath)) { - return pyPath +async function resolvePythonModule( + baseUri: string, + fs: FileSystem, +): Promise { + // Try module.py + const pyUri = `${baseUri}.py` + if (await cachedExists(pyUri, fs)) { + return pyUri } - const initPath = join(basePath, "__init__.py") - if (cachedExistsSync(initPath)) { - return initPath + // Try module/__init__.py + const initUri = fs.joinPath(baseUri, "__init__.py") + if (await cachedExists(initUri, fs)) { + return initUri } return null } @@ -67,7 +71,7 @@ function findImportByExportedName( } /** - * Converts a Python module path to a filesystem directory path. + * Converts a Python module path to a filesystem directory URI. * * Examples (modulePath, relativeDots → result): * Absolute: ("app.api.routes", 0) from projectRoot="/project" → "/project/app/api/routes" @@ -76,28 +80,29 @@ function findImportByExportedName( */ function modulePathToDir( importInfo: Pick, - currentFilePath: string, - projectRoot: string, + currentFileUri: string, + projectRootUri: string, + fs: FileSystem, ): string { - let baseDir: string + let baseDirUri: string if (importInfo.isRelative) { // For relative imports, go up 'relativeDots' directories from current file - baseDir = dirname(currentFilePath) + baseDirUri = uriDirname(currentFileUri) for (let i = 1; i < importInfo.relativeDots; i++) { - baseDir = dirname(baseDir) + baseDirUri = uriDirname(baseDirUri) } } else { - baseDir = projectRoot + baseDirUri = projectRootUri } if (importInfo.modulePath) { - return join(baseDir, ...importInfo.modulePath.split(".")) + return fs.joinPath(baseDirUri, ...importInfo.modulePath.split(".")) } - return baseDir + return baseDirUri } /** - * Resolves a module import to its file path. + * Resolves a module import to its file URI. * * Examples: * "from app.api import routes" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py" @@ -105,52 +110,71 @@ function modulePathToDir( * * Returns null if the module doesn't exist (may be a namespace package). */ -export function resolveImport( +export async function resolveImport( importInfo: Pick, - currentFilePath: string, - projectRoot: string, -): string | null { - const resolvedPath = modulePathToDir(importInfo, currentFilePath, projectRoot) - return resolvePythonModule(resolvedPath) + currentFileUri: string, + projectRootUri: string, + fs: FileSystem, +): Promise { + const resolvedUri = modulePathToDir( + importInfo, + currentFileUri, + projectRootUri, + fs, + ) + return resolvePythonModule(resolvedUri, fs) } /** - * Resolves a named import to its file path. + * Resolves a named import to its file URI. * For example, from .routes import users * will try to resolve to routes/users.py + * + * @param analyzeFileFn - Function to analyze a file (injected to avoid circular dependency) */ -export function resolveNamedImport( +export async function resolveNamedImport( importInfo: Pick< ImportInfo, "modulePath" | "names" | "isRelative" | "relativeDots" >, - currentFilePath: string, - projectRoot: string, - parser?: Parser, -): string | null { - const basePath = resolveImport(importInfo, currentFilePath, projectRoot) + currentFileUri: string, + projectRootUri: string, + fs: FileSystem, + analyzeFileFn?: (uri: string) => Promise<{ imports: ImportInfo[] } | null>, +): Promise { + const baseUri = await resolveImport( + importInfo, + currentFileUri, + projectRootUri, + fs, + ) // Calculate base directory for named import resolution. - // For namespace packages (directories without __init__.py), basePath will be null, + // For namespace packages (directories without __init__.py), baseUri will be null, // so we compute the directory path directly from the module path. - const baseDir = basePath - ? dirname(basePath) - : modulePathToDir(importInfo, currentFilePath, projectRoot) + const baseDirUri = baseUri + ? uriDirname(baseUri) + : modulePathToDir(importInfo, currentFileUri, projectRootUri, fs) for (const name of importInfo.names) { // Try direct file: from .routes import users -> routes/users.py - const namedPath = join(baseDir, ...name.split(".")) - const resolved = resolvePythonModule(namedPath) + const namedUri = fs.joinPath(baseDirUri, ...name.split(".")) + const resolved = await resolvePythonModule(namedUri, fs) if (resolved) { return resolved } // Try re-exports: from .routes import users where routes/__init__.py re-exports users - if (basePath?.endsWith("__init__.py") && parser) { - const analysis = analyzeFile(basePath, parser) + if (baseUri?.endsWith("__init__.py") && analyzeFileFn) { + const analysis = await analyzeFileFn(baseUri) const imp = analysis && findImportByExportedName(analysis.imports, name) if (imp) { - const reExportResolved = resolveImport(imp, basePath, projectRoot) + const reExportResolved = await resolveImport( + imp, + baseUri, + projectRootUri, + fs, + ) if (reExportResolved) { return reExportResolved } @@ -159,7 +183,7 @@ export function resolveNamedImport( } // Fall back to base module resolution - return basePath + return baseUri } /** @@ -169,22 +193,28 @@ export function resolveNamedImport( * For example, if integrations/__init__.py contains: * from .router import router as router * This will return the path to integrations/router.py + * + * @param analyzeFileFn - Function to analyze a file (injected to avoid circular dependency) */ -export function resolveRouterFromInit( - initFilePath: string, - projectRoot: string, - parser: Parser, -): string | null { - if (!initFilePath.endsWith("__init__.py")) { +export async function resolveRouterFromInit( + initFileUri: string, + projectRootUri: string, + fs: FileSystem, + analyzeFileFn: (uri: string) => Promise<{ + imports: ImportInfo[] + routers: { variableName: string }[] + } | null>, +): Promise { + if (!initFileUri.endsWith("__init__.py")) { return null } - const analysis = analyzeFile(initFilePath, parser) + const analysis = await analyzeFileFn(initFileUri) // If file has routers defined, no need to follow re-exports if (!analysis || analysis.routers.length > 0) { return null } const imp = findImportByExportedName(analysis.imports, "router") - return imp ? resolveImport(imp, initFilePath, projectRoot) : null + return imp ? resolveImport(imp, initFileUri, projectRootUri, fs) : null } diff --git a/src/core/index.ts b/src/core/index.ts index 8e9046d..74dfbb6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,8 +4,11 @@ */ export { analyzeFile, analyzeTree } from "./analyzer" +export type { FileSystem } from "./filesystem" +export { clearImportCache } from "./importResolver" export type { FileAnalysis } from "./internal" export { Parser } from "./parser" +export { findProjectRoot } from "./pathUtils" export { buildRouterGraph, type RouterNode } from "./routerResolver" export { routerNodeToAppDefinition } from "./transformer" export type { diff --git a/src/core/parser.ts b/src/core/parser.ts index 0cc158c..7dc5bdd 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -2,24 +2,46 @@ * Parser service using Web Tree Sitter to parse Python code. */ -import { readFileSync } from "node:fs" import { Language, Parser as TreeSitterParser } from "web-tree-sitter" export class Parser { private parser: TreeSitterParser | null = null - async init(wasmPaths: { core: string; python: string }) { + + /** + * Initialize the parser with Wasm binaries. + * @param wasmBinaries.core - The web-tree-sitter.wasm binary + * @param wasmBinaries.python - The tree-sitter-python.wasm binary + */ + async init(wasmBinaries: { core: Uint8Array; python: Uint8Array }) { if (this.parser) { return } - const wasmBinary = readFileSync(wasmPaths.core) - await TreeSitterParser.init({ wasmBinary }) - - this.parser = new TreeSitterParser() - - const pythonWasmBinary = readFileSync(wasmPaths.python) - const pythonLanguage = await Language.load(pythonWasmBinary) - this.parser.setLanguage(pythonLanguage) + // Pre-compile the Wasm module from the binary + const wasmModule = await WebAssembly.compile(wasmBinaries.core) + + // Use instantiateWasm to provide custom Wasm instantiation. + // This bypasses tree-sitter's default URL-based loading which relies + // on import.meta.url - unavailable in bundled or non-ESM environments. + await TreeSitterParser.init({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + instantiateWasm(imports: any, successCallback: any) { + WebAssembly.instantiate(wasmModule, imports).then( + (instance: WebAssembly.Instance) => { + successCallback(instance, wasmModule) + }, + ) + return {} + }, + }) + + const parser = new TreeSitterParser() + + // Load Python language from Wasm binary + const pythonLanguage = await Language.load(wasmBinaries.python) + parser.setLanguage(pythonLanguage) + + this.parser = parser } parse(code: string) { diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index 5e73223..c2adaea 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -1,5 +1,7 @@ -import { existsSync } from "node:fs" -import { dirname, join, relative, sep } from "node:path" +/** + * Pure path utilities that don't require filesystem access. + * These work with URI strings and path strings. + */ /** * Strips leading dynamic segments (like {settings.API_V1_STR}) from a path. @@ -15,41 +17,49 @@ export function stripLeadingDynamicSegments(path: string): string { } /** - * Checks if a path is within or equal to a base directory. - * Uses relative path calculation to avoid false positives from string prefix matching. + * Gets the directory (parent) of a URI string. + * Works with any URI scheme (file://, vscode-vfs://, etc.). */ -export function isWithinDirectory(filePath: string, baseDir: string): boolean { - const rel = relative(baseDir, filePath) - // If relative path starts with "..", the path is outside baseDir - return !rel.startsWith("..") && !rel.startsWith(sep) +export function uriDirname(uri: string): string { + // Parse the URI to separate scheme/authority from path + const match = uri.match(/^([^:]+:\/\/[^/]*)(.*)$/) + if (!match) { + // Fallback for simple paths + const lastSlash = uri.lastIndexOf("/") + return lastSlash <= 0 ? "/" : uri.slice(0, lastSlash) + } + + const [, prefix, path] = match + const lastSlash = path.lastIndexOf("/") + if (lastSlash <= 0) { + return `${prefix}/` + } + return `${prefix}${path.slice(0, lastSlash)}` } /** - * Finds the Python project root by walking up from the entry file - * until we find a directory without __init__.py (or hit the workspace root). - * This is the directory from which absolute imports are resolved. + * Extracts the path component from a URI string. */ -export function findProjectRoot( - entryPath: string, - workspaceRoot: string, -): string { - let dir = dirname(entryPath) +export function uriPath(uri: string): string { + const match = uri.match(/^[^:]+:\/\/[^/]*(.*)$/) + return match ? match[1] : uri +} - // If the entry file's directory doesn't have __init__.py, it's a top-level script - if (!existsSync(join(dir, "__init__.py"))) { - return dir - } +/** + * Checks if a URI is within or equal to a base directory URI. + * Uses URI path comparison to work across all platforms. + */ +export function isWithinDirectory( + fileUri: string, + baseDirUri: string, +): boolean { + const filePath = uriPath(fileUri).replace(/\/+$/, "") || "/" + const basePath = uriPath(baseDirUri).replace(/\/+$/, "") || "/" - // Walk up until we find a directory whose parent doesn't have __init__.py - while (isWithinDirectory(dir, workspaceRoot) && dir !== workspaceRoot) { - const parent = dirname(dir) - if (!existsSync(join(parent, "__init__.py"))) { - return parent - } - dir = parent + if (filePath === basePath) { + return true } - - return workspaceRoot + return filePath.startsWith(`${basePath}/`) } /** @@ -109,3 +119,38 @@ export function pathMatchesEndpoint( return seg === testSegments[index] }) } + +/** + * Finds the Python project root by walking up from the entry file + * until we find a directory without __init__.py (or hit the workspace root). + * This is the directory from which absolute imports are resolved. + */ +export async function findProjectRoot( + entryUri: string, + workspaceRootUri: string, + fs: { + exists(uri: string): Promise + joinPath(base: string, ...segments: string[]): string + }, +): Promise { + let dirUri = uriDirname(entryUri) + + // If the entry file's directory doesn't have __init__.py, it's a top-level script + if (!(await fs.exists(fs.joinPath(dirUri, "__init__.py")))) { + return dirUri + } + + // Walk up until we find a directory whose parent doesn't have __init__.py + while ( + isWithinDirectory(dirUri, workspaceRootUri) && + uriPath(dirUri) !== uriPath(workspaceRootUri) + ) { + const parentUri = uriDirname(dirUri) + if (!(await fs.exists(fs.joinPath(parentUri, "__init__.py")))) { + return parentUri + } + dirUri = parentUri + } + + return workspaceRootUri +} diff --git a/src/core/routerResolver.ts b/src/core/routerResolver.ts index e92ca9a..3c995f0 100644 --- a/src/core/routerResolver.ts +++ b/src/core/routerResolver.ts @@ -1,7 +1,6 @@ -import { existsSync } from "node:fs" -import { isAbsolute, join } from "node:path" import { log } from "../utils/logger" import { analyzeFile } from "./analyzer" +import type { FileSystem } from "./filesystem" import { resolveNamedImport, resolveRouterFromInit } from "./importResolver" import type { FileAnalysis, RouterInfo, RouterNode } from "./internal" import type { Parser } from "./parser" @@ -30,16 +29,18 @@ function findAppRouter( * Builds a router graph starting from the given entry file. * If targetVariable is specified, only that specific app/router will be used. */ -export function buildRouterGraph( - entryFile: string, +export async function buildRouterGraph( + entryFileUri: string, parser: Parser, - projectRoot: string, + projectRootUri: string, + fs: FileSystem, targetVariable?: string, -): RouterNode | null { +): Promise { return buildRouterGraphInternal( - entryFile, + entryFileUri, parser, - projectRoot, + projectRootUri, + fs, new Set(), targetVariable, ) @@ -48,62 +49,65 @@ export function buildRouterGraph( /** * Internal recursive function to build the router graph. */ -function buildRouterGraphInternal( - entryFile: string, +async function buildRouterGraphInternal( + entryFileUri: string, parser: Parser, - projectRoot: string, + projectRootUri: string, + fs: FileSystem, visited: Set, targetVariable?: string, -): RouterNode | null { - // Resolve the full path of the entry file if necessary - let resolvedEntryFile = entryFile - - if (!existsSync(resolvedEntryFile) && !isAbsolute(entryFile)) { - resolvedEntryFile = join(projectRoot, entryFile) - } - - if (!existsSync(resolvedEntryFile)) { - log(`File not found: "${entryFile}"`) +): Promise { + // Check if file exists + if (!(await fs.exists(entryFileUri))) { + log(`File not found: "${entryFileUri}"`) return null } + // Prevent infinite recursion on circular imports - if (visited.has(resolvedEntryFile)) { - log(`Skipping already visited file: "${resolvedEntryFile}"`) + if (visited.has(entryFileUri)) { + log(`Skipping already visited file: "${entryFileUri}"`) return null } - visited.add(resolvedEntryFile) + visited.add(entryFileUri) + + // Helper to analyze a file with the filesystem + const analyzeFileFn = (uri: string) => analyzeFile(uri, parser, fs) // Analyze the entry file - let analysis = analyzeFile(resolvedEntryFile, parser) + let analysis = await analyzeFileFn(entryFileUri) if (!analysis) { - log(`Failed to analyze file: "${resolvedEntryFile}"`) + log(`Failed to analyze file: "${entryFileUri}"`) return null } + // Track current resolved URI (may change if following re-exports) + let resolvedEntryUri = entryFileUri + log( - `Analyzed "${resolvedEntryFile}": ${analysis.routes.length} routes, ${analysis.routers.length} routers, ${analysis.includeRouters.length} include_router calls`, + `Analyzed "${resolvedEntryUri}": ${analysis.routes.length} routes, ${analysis.routers.length} routers, ${analysis.includeRouters.length} include_router calls`, ) // Find FastAPI instantiation (filter by targetVariable if specified) let appRouter = findAppRouter(analysis.routers, targetVariable) // If no FastAPI/APIRouter found and this is an __init__.py, check for re-exports - if (!appRouter && resolvedEntryFile.endsWith("__init__.py")) { - const actualRouterFile = resolveRouterFromInit( - resolvedEntryFile, - projectRoot, - parser, + if (!appRouter && entryFileUri.endsWith("__init__.py")) { + const actualRouterUri = await resolveRouterFromInit( + entryFileUri, + projectRootUri, + fs, + analyzeFileFn, ) - if (actualRouterFile && !visited.has(actualRouterFile)) { - visited.add(actualRouterFile) - const actualAnalysis = analyzeFile(actualRouterFile, parser) + if (actualRouterUri && !visited.has(actualRouterUri)) { + visited.add(actualRouterUri) + const actualAnalysis = await analyzeFileFn(actualRouterUri) if (actualAnalysis) { const actualRouter = findAppRouter(actualAnalysis.routers) if (actualRouter) { analysis = actualAnalysis appRouter = actualRouter - resolvedEntryFile = actualRouterFile + resolvedEntryUri = actualRouterUri } } } @@ -119,7 +123,7 @@ function buildRouterGraphInternal( (r) => r.owner === appRouter.variableName, ) const rootRouter: RouterNode = { - filePath: resolvedEntryFile, + filePath: resolvedEntryUri, variableName: appRouter.variableName, type: appRouter.type, prefix: appRouter.prefix, @@ -141,12 +145,13 @@ function buildRouterGraphInternal( log( `Resolving include_router: ${include.router} (prefix: ${include.prefix || "none"})`, ) - const childRouter = resolveRouterReference( + const childRouter = await resolveRouterReference( include.router, analysis, - resolvedEntryFile, - projectRoot, + resolvedEntryUri, + projectRootUri, parser, + fs, visited, ) if (childRouter) { @@ -164,12 +169,13 @@ function buildRouterGraphInternal( // Process mount() calls for subapps for (const mount of analysis.mounts) { - const childRouter = resolveRouterReference( + const childRouter = await resolveRouterReference( mount.app, analysis, - resolvedEntryFile, - projectRoot, + resolvedEntryUri, + projectRootUri, parser, + fs, visited, ) if (childRouter) { @@ -187,17 +193,23 @@ function buildRouterGraphInternal( /** * Resolves a router/app reference to its RouterNode. * Used for include_router and mount calls. + * + * Handles both simple references (e.g., "router") and dotted references + * (e.g., "api_routes.router" where api_routes is an imported module). */ -function resolveRouterReference( +async function resolveRouterReference( reference: string, analysis: FileAnalysis, - currentFile: string, - projectRoot: string, + currentFileUri: string, + projectRootUri: string, parser: Parser, + fs: FileSystem, visited: Set, -): RouterNode | null { +): Promise { const parts = reference.split(".") const moduleName = parts[0] + // For dotted references like "api_routes.router", extract the attribute name + const attributeName = parts.length > 1 ? parts.slice(1).join(".") : null // First, check if this is a local router defined in the same file const localRouter = analysis.routers.find( @@ -207,7 +219,7 @@ function resolveRouterReference( // Filter routes that belong to this router (decorated with @router.method) const routerRoutes = analysis.routes.filter((r) => r.owner === moduleName) return { - filePath: currentFile, + filePath: currentFileUri, variableName: localRouter.variableName, type: localRouter.type, prefix: localRouter.prefix, @@ -235,27 +247,78 @@ function resolveRouterReference( return null } - const importedFilePath = resolveNamedImport( + // Helper to analyze a file with the filesystem + const analyzeFileFn = (uri: string) => analyzeFile(uri, parser, fs) + + // Resolve the imported module to a file URI + const importedFileUri = await resolveNamedImport( { modulePath: matchingImport.modulePath, names: [moduleName], isRelative: matchingImport.isRelative, relativeDots: matchingImport.relativeDots, }, - currentFile, - projectRoot, - parser, + currentFileUri, + projectRootUri, + fs, + analyzeFileFn, ) - if (!importedFilePath) { + if (!importedFileUri) { log(`Could not resolve import: ${matchingImport.modulePath}`) return null } + // For dotted references (e.g., "api_routes.router"), we need to find + // the specific attribute within the resolved module + if (attributeName) { + // Analyze the imported file to find the router by attribute name + const importedAnalysis = await analyzeFileFn(importedFileUri) + if (!importedAnalysis) { + return null + } + + // Find the router with the matching variable name + const targetRouter = importedAnalysis.routers.find( + (r) => r.variableName === attributeName, + ) + if (targetRouter) { + // Mark as visited to prevent infinite recursion + if (visited.has(importedFileUri)) { + return null + } + visited.add(importedFileUri) + + // Get routes belonging to this router + const routerRoutes = importedAnalysis.routes.filter( + (r) => r.owner === attributeName, + ) + return { + filePath: importedFileUri, + variableName: targetRouter.variableName, + type: targetRouter.type, + prefix: targetRouter.prefix, + tags: targetRouter.tags, + line: targetRouter.line, + column: targetRouter.column, + routes: routerRoutes.map((r) => ({ + method: r.method, + path: r.path, + function: r.function, + line: r.line, + column: r.column, + })), + children: [], + } + } + // If not found as a router, fall through to try building from file + } + return buildRouterGraphInternal( - importedFilePath, + importedFileUri, parser, - projectRoot, + projectRootUri, + fs, visited, ) } diff --git a/src/extension.ts b/src/extension.ts index 2e14d32..3c5c513 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,14 +11,16 @@ import type { SourceLocation } from "./core/types" import { type EndpointTreeItem, EndpointTreeProvider, -} from "./providers/EndpointTreeProvider" -import { TestCodeLensProvider } from "./providers/TestCodeLensProvider" +} from "./providers/endpointTreeProvider" +import { TestCodeLensProvider } from "./providers/testCodeLensProvider" +import { vscodeFileSystem } from "./providers/vscodeFileSystem" import { disposeLogger, log } from "./utils/logger" let parserService: Parser | null = null function navigateToLocation(location: SourceLocation): void { - const uri = vscode.Uri.file(location.filePath) + // filePath is now a URI string, parse it back to vscode.Uri + const uri = vscode.Uri.parse(location.filePath) const position = new vscode.Position(location.line - 1, location.column) vscode.window.showTextDocument(uri, { selection: new vscode.Range(position, position), @@ -34,19 +36,30 @@ export async function activate(context: vscode.ExtensionContext) { ) parserService = new Parser() + + // Read Wasm files via VS Code's virtual filesystem API + const [coreWasm, pythonWasm] = await Promise.all([ + vscode.workspace.fs.readFile( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "web-tree-sitter.wasm", + ), + ), + vscode.workspace.fs.readFile( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "tree-sitter-python.wasm", + ), + ), + ]) + await parserService.init({ - core: vscode.Uri.joinPath( - context.extensionUri, - "dist", - "wasm", - "web-tree-sitter.wasm", - ).fsPath, - python: vscode.Uri.joinPath( - context.extensionUri, - "dist", - "wasm", - "tree-sitter-python.wasm", - ).fsPath, + core: coreWasm, + python: pythonWasm, }) // Discover apps and create providers @@ -55,7 +68,7 @@ export async function activate(context: vscode.ExtensionContext) { const codeLensProvider = new TestCodeLensProvider(parserService, apps) // File watcher for auto-refresh - let refreshTimeout: NodeJS.Timeout | null = null + let refreshTimeout: ReturnType | null = null const triggerRefresh = () => { if (refreshTimeout) clearTimeout(refreshTimeout) refreshTimeout = setTimeout(async () => { @@ -63,7 +76,7 @@ export async function activate(context: vscode.ExtensionContext) { const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) codeLensProvider.setApps(newApps) - }, 500) + }, 300) } const watcher = vscode.workspace.createFileSystemWatcher("**/*.py") @@ -71,6 +84,11 @@ export async function activate(context: vscode.ExtensionContext) { watcher.onDidCreate(triggerRefresh) watcher.onDidDelete(triggerRefresh) + // Re-discover when workspace folders change (handles late folder availability in browser) + context.subscriptions.push( + vscode.workspace.onDidChangeWorkspaceFolders(triggerRefresh), + ) + // Tree view const treeView = vscode.window.createTreeView("endpoint-explorer", { treeDataProvider: endpointProvider, diff --git a/src/providers/EndpointTreeProvider.ts b/src/providers/endpointTreeProvider.ts similarity index 100% rename from src/providers/EndpointTreeProvider.ts rename to src/providers/endpointTreeProvider.ts diff --git a/src/providers/TestCodeLensProvider.ts b/src/providers/testCodeLensProvider.ts similarity index 99% rename from src/providers/TestCodeLensProvider.ts rename to src/providers/testCodeLensProvider.ts index 8ef2ccd..10f82c5 100644 --- a/src/providers/TestCodeLensProvider.ts +++ b/src/providers/testCodeLensProvider.ts @@ -76,7 +76,7 @@ export class TestCodeLensProvider implements CodeLensProvider { const locations = matchingRoutes.map( (loc) => new Location( - Uri.file(loc.filePath), + Uri.parse(loc.filePath), new Position(loc.line - 1, loc.column), ), ) diff --git a/src/providers/vscodeFileSystem.ts b/src/providers/vscodeFileSystem.ts new file mode 100644 index 0000000..7967ad2 --- /dev/null +++ b/src/providers/vscodeFileSystem.ts @@ -0,0 +1,36 @@ +/** + * VS Code implementation of the FileSystem interface. + * Uses vscode.workspace.fs for virtual filesystem support (vscode.dev, remote containers). + */ + +import * as vscode from "vscode" +import type { FileSystem } from "../core/filesystem" + +export const vscodeFileSystem: FileSystem = { + async readFile(uri: string): Promise { + return vscode.workspace.fs.readFile(vscode.Uri.parse(uri)) + }, + + async exists(uri: string): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.parse(uri)) + return true + } catch { + return false + } + }, + + joinPath(base: string, ...segments: string[]): string { + return vscode.Uri.joinPath(vscode.Uri.parse(base), ...segments).toString() + }, + + dirname(uri: string): string { + const parsed = vscode.Uri.parse(uri) + const path = parsed.path + const lastSlash = path.lastIndexOf("/") + if (lastSlash <= 0) { + return parsed.with({ path: "/" }).toString() + } + return parsed.with({ path: path.slice(0, lastSlash) }).toString() + }, +} diff --git a/src/test/core/analyzer.test.ts b/src/test/core/analyzer.test.ts index 75b8faa..98698c5 100644 --- a/src/test/core/analyzer.test.ts +++ b/src/test/core/analyzer.test.ts @@ -1,7 +1,7 @@ import * as assert from "node:assert" import { analyzeFile, analyzeTree } from "../../core/analyzer" import { Parser } from "../../core/parser" -import { fixtures, wasmPaths } from "../testUtils" +import { fixtures, nodeFileSystem, wasmBinaries } from "../testUtils" suite("analyzer", () => { let parser: Parser @@ -14,7 +14,7 @@ suite("analyzer", () => { suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { @@ -110,8 +110,12 @@ import os }) suite("analyzeFile", () => { - test("analyzes main.py fixture", () => { - const result = analyzeFile(fixtures.standard.mainPy, parser) + test("analyzes main.py fixture", async () => { + const result = await analyzeFile( + fixtures.standard.mainPy, + parser, + nodeFileSystem, + ) assert.ok(result) assert.strictEqual(result.filePath, fixtures.standard.mainPy) @@ -130,8 +134,12 @@ import os assert.strictEqual(healthRoute.method, "get") }) - test("analyzes users.py fixture", () => { - const result = analyzeFile(fixtures.standard.usersPy, parser) + test("analyzes users.py fixture", async () => { + const result = await analyzeFile( + fixtures.standard.usersPy, + parser, + nodeFileSystem, + ) assert.ok(result) @@ -148,8 +156,12 @@ import os assert.ok(methods.includes("post")) }) - test("returns null for non-existent file", () => { - const result = analyzeFile("/nonexistent/file.py", parser) + test("returns null for non-existent file", async () => { + const result = await analyzeFile( + "file:///nonexistent/file.py", + parser, + nodeFileSystem, + ) assert.strictEqual(result, null) }) }) diff --git a/src/test/core/extractors.test.ts b/src/test/core/extractors.test.ts index 712333d..1853d4d 100644 --- a/src/test/core/extractors.test.ts +++ b/src/test/core/extractors.test.ts @@ -8,7 +8,7 @@ import { routerExtractor, } from "../../core/extractors" import { Parser } from "../../core/parser" -import { wasmPaths } from "../testUtils" +import { wasmBinaries } from "../testUtils" suite("Extractors", () => { let parser: Parser @@ -22,7 +22,7 @@ suite("Extractors", () => { suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { diff --git a/src/test/core/importResolver.test.ts b/src/test/core/importResolver.test.ts index f5d4860..5ae6b97 100644 --- a/src/test/core/importResolver.test.ts +++ b/src/test/core/importResolver.test.ts @@ -1,8 +1,8 @@ import * as assert from "node:assert" -import { join } from "node:path" +import { analyzeFile } from "../../core/analyzer" import { resolveImport, resolveNamedImport } from "../../core/importResolver" import { Parser } from "../../core/parser" -import { fixtures, wasmPaths } from "../testUtils" +import { fixtures, nodeFileSystem, wasmBinaries } from "../testUtils" const standardRoot = fixtures.standard.root @@ -11,7 +11,7 @@ suite("importResolver", () => { suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { @@ -19,44 +19,60 @@ suite("importResolver", () => { }) suite("resolveImport", () => { - test("resolves relative import to .py file", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("resolves relative import to .py file", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot - const result = resolveImport( + const result = await resolveImport( { modulePath: "routes.users", isRelative: true, relativeDots: 1 }, currentFile, projectRoot, + nodeFileSystem, ) assert.ok(result) assert.ok(result.endsWith("users.py")) }) - test("resolves relative import to __init__.py", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("resolves relative import to __init__.py", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot - const result = resolveImport( + const result = await resolveImport( { modulePath: "routes", isRelative: true, relativeDots: 1 }, currentFile, projectRoot, + nodeFileSystem, ) assert.ok(result) assert.ok(result.endsWith("__init__.py")) }) - test("resolves double-dot relative import", () => { + test("resolves double-dot relative import", async () => { // from .. import something (2 dots, no module name) // From app/routes/users.py, this goes to parent package (app) - const currentFile = join(standardRoot, "app", "routes", "users.py") + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "routes", + "users.py", + ) const projectRoot = standardRoot - const result = resolveImport( + const result = await resolveImport( { modulePath: "", isRelative: true, relativeDots: 2 }, currentFile, projectRoot, + nodeFileSystem, ) // 2 dots from routes/users.py goes to app/ @@ -64,11 +80,11 @@ suite("importResolver", () => { assert.ok(result.endsWith("app/__init__.py")) }) - test("resolves absolute import", () => { - const currentFile = join(standardRoot, "main.py") + test("resolves absolute import", async () => { + const currentFile = nodeFileSystem.joinPath(standardRoot, "main.py") const projectRoot = standardRoot - const result = resolveImport( + const result = await resolveImport( { modulePath: "app.routes.users", isRelative: false, @@ -76,17 +92,18 @@ suite("importResolver", () => { }, currentFile, projectRoot, + nodeFileSystem, ) assert.ok(result) assert.ok(result.endsWith("users.py")) }) - test("returns null for non-existent module", () => { - const currentFile = join(standardRoot, "main.py") + test("returns null for non-existent module", async () => { + const currentFile = nodeFileSystem.joinPath(standardRoot, "main.py") const projectRoot = standardRoot - const result = resolveImport( + const result = await resolveImport( { modulePath: "nonexistent.module", isRelative: false, @@ -94,6 +111,7 @@ suite("importResolver", () => { }, currentFile, projectRoot, + nodeFileSystem, ) assert.strictEqual(result, null) @@ -101,11 +119,15 @@ suite("importResolver", () => { }) suite("resolveNamedImport", () => { - test("resolves named import to .py file", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("resolves named import to .py file", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot - const result = resolveNamedImport( + const result = await resolveNamedImport( { modulePath: "routes", names: ["users"], @@ -114,19 +136,23 @@ suite("importResolver", () => { }, currentFile, projectRoot, - parser, + nodeFileSystem, ) assert.ok(result) assert.ok(result.endsWith("users.py")) }) - test("resolves re-exported name from __init__.py", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("resolves re-exported name from __init__.py", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot // The __init__.py has: from .users import router as users_router - const result = resolveNamedImport( + const result = await resolveNamedImport( { modulePath: "routes", names: ["users_router"], @@ -135,18 +161,23 @@ suite("importResolver", () => { }, currentFile, projectRoot, - parser, + nodeFileSystem, + (uri) => analyzeFile(uri, parser, nodeFileSystem), ) assert.ok(result) assert.ok(result.endsWith("users.py")) }) - test("falls back to base module for non-existent named import", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("falls back to base module for non-existent named import", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot - const result = resolveNamedImport( + const result = await resolveNamedImport( { modulePath: "routes", names: ["nonexistent"], @@ -155,7 +186,7 @@ suite("importResolver", () => { }, currentFile, projectRoot, - parser, + nodeFileSystem, ) // Falls back to the base module when named import not found @@ -165,12 +196,16 @@ suite("importResolver", () => { ) }) - test("resolves relative named import from namespace package (no __init__.py)", () => { - const currentFile = join(standardRoot, "app", "main.py") + test("resolves relative named import from namespace package (no __init__.py)", async () => { + const currentFile = nodeFileSystem.joinPath( + standardRoot, + "app", + "main.py", + ) const projectRoot = standardRoot // namespace_routes has no __init__.py, but api_routes.py exists - const result = resolveNamedImport( + const result = await resolveNamedImport( { modulePath: "namespace_routes", names: ["api_routes"], @@ -179,19 +214,19 @@ suite("importResolver", () => { }, currentFile, projectRoot, - parser, + nodeFileSystem, ) assert.ok(result) assert.ok(result.endsWith("api_routes.py")) }) - test("resolves absolute named import from namespace package (no __init__.py)", () => { - const currentFile = join(standardRoot, "main.py") + test("resolves absolute named import from namespace package (no __init__.py)", async () => { + const currentFile = nodeFileSystem.joinPath(standardRoot, "main.py") const projectRoot = standardRoot // app.namespace_routes has no __init__.py, but api_routes.py exists - const result = resolveNamedImport( + const result = await resolveNamedImport( { modulePath: "app.namespace_routes", names: ["api_routes"], @@ -200,7 +235,7 @@ suite("importResolver", () => { }, currentFile, projectRoot, - parser, + nodeFileSystem, ) assert.ok(result) diff --git a/src/test/core/parser.test.ts b/src/test/core/parser.test.ts index aca2415..1126dd0 100644 --- a/src/test/core/parser.test.ts +++ b/src/test/core/parser.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert" import { Parser } from "../../core/parser" -import { wasmPaths } from "../testUtils" +import { wasmBinaries } from "../testUtils" suite("parser", () => { test("throws error if parse called before init", () => { @@ -10,7 +10,7 @@ suite("parser", () => { test("parses Python code after init", async () => { const parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) const tree = parser.parse("x = 1") assert.ok(tree) @@ -21,8 +21,8 @@ suite("parser", () => { test("double init is safe", async () => { const parser = new Parser() - await parser.init(wasmPaths) - await parser.init(wasmPaths) // Should not throw + await parser.init(wasmBinaries) + await parser.init(wasmBinaries) // Should not throw const tree = parser.parse("y = 2") assert.ok(tree) @@ -32,7 +32,7 @@ suite("parser", () => { test("parses decorated function", async () => { const parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) const code = ` @router.get("/users") @@ -52,7 +52,7 @@ def get_users(): test("dispose is safe to call multiple times", async () => { const parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) parser.dispose() parser.dispose() // Should not throw diff --git a/src/test/core/pathUtils.test.ts b/src/test/core/pathUtils.test.ts index 6d8b9f4..cac37ea 100644 --- a/src/test/core/pathUtils.test.ts +++ b/src/test/core/pathUtils.test.ts @@ -1,5 +1,4 @@ import * as assert from "node:assert" -import { join } from "node:path" import { countSegments, findProjectRoot, @@ -8,7 +7,7 @@ import { pathMatchesEndpoint, stripLeadingDynamicSegments, } from "../../core/pathUtils" -import { fixtures } from "../testUtils" +import { fixtures, nodeFileSystem } from "../testUtils" const standardRoot = fixtures.standard.root @@ -115,53 +114,86 @@ suite("pathUtils", () => { suite("isWithinDirectory", () => { test("returns true for path inside directory", () => { - assert.strictEqual(isWithinDirectory("/foo/bar/baz", "/foo/bar"), true) + assert.strictEqual( + isWithinDirectory("file:///foo/bar/baz", "file:///foo/bar"), + true, + ) }) test("returns true for path equal to directory", () => { - assert.strictEqual(isWithinDirectory("/foo/bar", "/foo/bar"), true) + assert.strictEqual( + isWithinDirectory("file:///foo/bar", "file:///foo/bar"), + true, + ) }) test("returns false for path outside directory", () => { - assert.strictEqual(isWithinDirectory("/foo/baz", "/foo/bar"), false) + assert.strictEqual( + isWithinDirectory("file:///foo/baz", "file:///foo/bar"), + false, + ) }) test("returns false for sibling with similar prefix", () => { // This is the key test - /foo/ba is NOT a parent of /foo/bar - assert.strictEqual(isWithinDirectory("/foo/bar", "/foo/ba"), false) + assert.strictEqual( + isWithinDirectory("file:///foo/bar", "file:///foo/ba"), + false, + ) }) test("returns false for parent directory", () => { - assert.strictEqual(isWithinDirectory("/foo", "/foo/bar"), false) + assert.strictEqual( + isWithinDirectory("file:///foo", "file:///foo/bar"), + false, + ) }) }) suite("findProjectRoot", () => { - test("returns entry dir when no __init__.py present", () => { + test("returns entry dir when no __init__.py present", async () => { // main.py is at fixtures/standard/main.py, and fixtures/standard has no __init__.py - const mainPyPath = join(standardRoot, "main.py") - const result = findProjectRoot(mainPyPath, standardRoot) + const mainPyUri = nodeFileSystem.joinPath(standardRoot, "main.py") + const result = await findProjectRoot( + mainPyUri, + standardRoot, + nodeFileSystem, + ) assert.strictEqual(result, standardRoot) }) - test("walks up to find project root from nested package", () => { + test("walks up to find project root from nested package", async () => { // users.py is in app/routes/users.py // app has __init__.py, routes has __init__.py // but fixtures/standard does not, so project root should be fixtures/standard - const usersPath = join(standardRoot, "app", "routes", "users.py") - const result = findProjectRoot(usersPath, standardRoot) + const usersUri = nodeFileSystem.joinPath( + standardRoot, + "app", + "routes", + "users.py", + ) + const result = await findProjectRoot( + usersUri, + standardRoot, + nodeFileSystem, + ) assert.strictEqual(result, standardRoot) }) - test("returns workspace root when all dirs have __init__.py", () => { + test("returns workspace root when all dirs have __init__.py", async () => { // If we pretend the workspace root is app, it should return that - const usersPath = join(standardRoot, "app", "routes", "users.py") - const appRoot = join(standardRoot, "app") - const result = findProjectRoot(usersPath, appRoot) + const appRootUri = nodeFileSystem.joinPath(standardRoot, "app") + const usersUri = nodeFileSystem.joinPath( + standardRoot, + "app", + "routes", + "users.py", + ) + const result = await findProjectRoot(usersUri, appRootUri, nodeFileSystem) - assert.strictEqual(result, appRoot) + assert.strictEqual(result, appRootUri) }) }) diff --git a/src/test/core/routerResolver.test.ts b/src/test/core/routerResolver.test.ts index b062971..604bf24 100644 --- a/src/test/core/routerResolver.test.ts +++ b/src/test/core/routerResolver.test.ts @@ -1,14 +1,19 @@ import * as assert from "node:assert" import { Parser } from "../../core/parser" import { buildRouterGraph } from "../../core/routerResolver" -import { fixtures, fixturesPath, wasmPaths } from "../testUtils" +import { + fixtures, + fixturesPath, + nodeFileSystem, + wasmBinaries, +} from "../testUtils" suite("routerResolver", () => { let parser: Parser suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { @@ -16,11 +21,12 @@ suite("routerResolver", () => { }) suite("buildRouterGraph", () => { - test("builds graph from main.py entry point", () => { - const result = buildRouterGraph( + test("builds graph from main.py entry point", async () => { + const result = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -29,11 +35,12 @@ suite("routerResolver", () => { assert.strictEqual(result.filePath, fixtures.standard.mainPy) }) - test("includes direct routes on app", () => { - const result = buildRouterGraph( + test("includes direct routes on app", async () => { + const result = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -44,11 +51,12 @@ suite("routerResolver", () => { assert.strictEqual(healthRoute.function, "health") }) - test("follows include_router to child routers", () => { - const result = buildRouterGraph( + test("follows include_router to child routers", async () => { + const result = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -59,11 +67,12 @@ suite("routerResolver", () => { ) }) - test("captures prefix from router definition", () => { - const result = buildRouterGraph( + test("captures prefix from router definition", async () => { + const result = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -74,31 +83,34 @@ suite("routerResolver", () => { assert.ok(usersChild, "Should have child with /users prefix") }) - test("returns null for non-existent file", () => { - const result = buildRouterGraph( - "/nonexistent/file.py", + test("returns null for non-existent file", async () => { + const result = await buildRouterGraph( + "file:///nonexistent/file.py", parser, - fixturesPath, + `file://${fixturesPath}`, + nodeFileSystem, ) assert.strictEqual(result, null) }) - test("returns null for file without FastAPI/APIRouter", () => { - const result = buildRouterGraph( + test("returns null for file without FastAPI/APIRouter", async () => { + const result = await buildRouterGraph( fixtures.standard.initPy, parser, fixtures.standard.root, + nodeFileSystem, ) // __init__.py has no FastAPI or APIRouter assert.strictEqual(result, null) }) - test("builds graph from APIRouter file", () => { - const result = buildRouterGraph( + test("builds graph from APIRouter file", async () => { + const result = await buildRouterGraph( fixtures.standard.usersPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -109,11 +121,12 @@ suite("routerResolver", () => { assert.ok(result.routes.length >= 3) }) - test("includes line numbers for routes", () => { - const result = buildRouterGraph( + test("includes line numbers for routes", async () => { + const result = await buildRouterGraph( fixtures.standard.usersPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -123,11 +136,12 @@ suite("routerResolver", () => { } }) - test("includes router location info", () => { - const result = buildRouterGraph( + test("includes router location info", async () => { + const result = await buildRouterGraph( fixtures.standard.usersPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -136,12 +150,13 @@ suite("routerResolver", () => { assert.ok(result.column >= 0) }) - test("follows __init__.py re-exports to actual router file", () => { + test("follows __init__.py re-exports to actual router file", async () => { // Use reexport fixture which has integrations/__init__.py re-exporting from router.py - const result = buildRouterGraph( + const result = await buildRouterGraph( fixtures.reexport.initPy, parser, fixtures.reexport.root, + nodeFileSystem, ) assert.ok(result, "Should find router via re-export") @@ -160,11 +175,12 @@ suite("routerResolver", () => { assert.ok(githubRoute, "Should find github route") }) - test("includes router when following include_router chain", () => { - const result = buildRouterGraph( + test("includes router when following include_router chain", async () => { + const result = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(result) @@ -185,11 +201,12 @@ suite("routerResolver", () => { ) }) - test("prioritizes FastAPI over APIRouter in same file", () => { - const result = buildRouterGraph( + test("prioritizes FastAPI over APIRouter in same file", async () => { + const result = await buildRouterGraph( fixtures.sameFile.mainPy, parser, fixtures.sameFile.root, + nodeFileSystem, ) assert.ok(result) @@ -197,11 +214,12 @@ suite("routerResolver", () => { assert.strictEqual(result.variableName, "app") }) - test("assigns routes to correct owner in same file", () => { - const result = buildRouterGraph( + test("assigns routes to correct owner in same file", async () => { + const result = await buildRouterGraph( fixtures.sameFile.mainPy, parser, fixtures.sameFile.root, + nodeFileSystem, ) assert.ok(result) @@ -214,11 +232,12 @@ suite("routerResolver", () => { ) }) - test("resolves local router as child in same file", () => { - const result = buildRouterGraph( + test("resolves local router as child in same file", async () => { + const result = await buildRouterGraph( fixtures.sameFile.mainPy, parser, fixtures.sameFile.root, + nodeFileSystem, ) assert.ok(result) @@ -240,22 +259,24 @@ suite("routerResolver", () => { ) }) - test("selects specific app by targetVariable", () => { + test("selects specific app by targetVariable", async () => { // Without targetVariable, should pick first FastAPI app (public_app) - const defaultResult = buildRouterGraph( + const defaultResult = await buildRouterGraph( fixtures.multiApp.mainPy, parser, fixtures.multiApp.root, + nodeFileSystem, ) assert.ok(defaultResult) assert.strictEqual(defaultResult.variableName, "public_app") // With targetVariable, should select admin_app - const adminResult = buildRouterGraph( + const adminResult = await buildRouterGraph( fixtures.multiApp.mainPy, parser, fixtures.multiApp.root, + nodeFileSystem, "admin_app", ) @@ -271,11 +292,12 @@ suite("routerResolver", () => { assert.ok(routePaths.includes("/users/{user_id}")) }) - test("returns null for non-existent targetVariable", () => { - const result = buildRouterGraph( + test("returns null for non-existent targetVariable", async () => { + const result = await buildRouterGraph( fixtures.multiApp.mainPy, parser, fixtures.multiApp.root, + nodeFileSystem, "nonexistent_app", ) diff --git a/src/test/core/transformer.test.ts b/src/test/core/transformer.test.ts index 2f7132d..0cf57b8 100644 --- a/src/test/core/transformer.test.ts +++ b/src/test/core/transformer.test.ts @@ -2,14 +2,14 @@ import * as assert from "node:assert" import { Parser } from "../../core/parser" import { buildRouterGraph } from "../../core/routerResolver" import { routerNodeToAppDefinition } from "../../core/transformer" -import { fixtures, wasmPaths } from "../testUtils" +import { fixtures, nodeFileSystem, wasmBinaries } from "../testUtils" suite("transformer", () => { let parser: Parser suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { @@ -17,11 +17,12 @@ suite("transformer", () => { }) suite("routerNodeToAppDefinition", () => { - test("transforms router graph to AppDefinition", () => { - const routerNode = buildRouterGraph( + test("transforms router graph to AppDefinition", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -33,11 +34,12 @@ suite("transformer", () => { assert.strictEqual(result.workspaceFolder, "/workspace") }) - test("includes direct routes on app", () => { - const routerNode = buildRouterGraph( + test("includes direct routes on app", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -50,11 +52,12 @@ suite("transformer", () => { assert.strictEqual(healthRoute.functionName, "health") }) - test("flattens nested routers", () => { - const routerNode = buildRouterGraph( + test("flattens nested routers", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -64,11 +67,12 @@ suite("transformer", () => { assert.ok(result.routers.length > 0) }) - test("computes full path with prefixes", () => { - const routerNode = buildRouterGraph( + test("computes full path with prefixes", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -86,11 +90,12 @@ suite("transformer", () => { ) }) - test("normalizes HTTP methods to uppercase", () => { - const routerNode = buildRouterGraph( + test("normalizes HTTP methods to uppercase", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -115,11 +120,12 @@ suite("transformer", () => { } }) - test("includes location info for routes", () => { - const routerNode = buildRouterGraph( + test("includes location info for routes", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -132,11 +138,12 @@ suite("transformer", () => { } }) - test("includes location info for routers", () => { - const routerNode = buildRouterGraph( + test("includes location info for routers", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -149,11 +156,12 @@ suite("transformer", () => { } }) - test("includes tags from routers", () => { - const routerNode = buildRouterGraph( + test("includes tags from routers", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.usersPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) @@ -161,11 +169,12 @@ suite("transformer", () => { assert.ok(routerNode.tags.includes("users")) }) - test("skips routers with no routes or children", () => { - const routerNode = buildRouterGraph( + test("skips routers with no routes or children", async () => { + const routerNode = await buildRouterGraph( fixtures.standard.mainPy, parser, fixtures.standard.root, + nodeFileSystem, ) assert.ok(routerNode) diff --git a/src/test/fixtures/mockEndpointData.ts b/src/test/fixtures/mockEndpointData.ts index 0b6c0a8..882bd72 100644 --- a/src/test/fixtures/mockEndpointData.ts +++ b/src/test/fixtures/mockEndpointData.ts @@ -1,5 +1,5 @@ import type { AppDefinition } from "../../core/types" -import type { EndpointTreeItem } from "../../providers/EndpointTreeProvider" +import type { EndpointTreeItem } from "../../providers/endpointTreeProvider" export const mockApps: AppDefinition[] = [ { diff --git a/src/test/layouts.test.ts b/src/test/layouts.test.ts index f589c4c..a2e9f04 100644 --- a/src/test/layouts.test.ts +++ b/src/test/layouts.test.ts @@ -8,7 +8,7 @@ import type { RouteDefinition, RouterDefinition, } from "../core/types" -import { fixtures, wasmPaths } from "./testUtils" +import { fixtures, nodeFileSystem, wasmBinaries } from "./testUtils" /** Collects all routes from an AppDefinition (direct routes + routes from all routers) */ function collectAllRoutes(appDef: AppDefinition): RouteDefinition[] { @@ -25,23 +25,25 @@ suite("Project Layouts", () => { suiteSetup(async () => { parser = new Parser() - await parser.init(wasmPaths) + await parser.init(wasmBinaries) }) suiteTeardown(() => { parser.dispose() }) - test("standard: discovers routes from package layout", () => { - const projectRoot = findProjectRoot( + test("standard: discovers routes from package layout", async () => { + const projectRoot = await findProjectRoot( fixtures.standard.mainPy, fixtures.standard.root, + nodeFileSystem, ) - const graph = buildRouterGraph( + const graph = await buildRouterGraph( fixtures.standard.mainPy, parser, projectRoot, + nodeFileSystem, ) assert.ok(graph, "Should find FastAPI app") @@ -74,13 +76,19 @@ suite("Project Layouts", () => { ) }) - test("flat: discovers routes from flat layout", () => { - const projectRoot = findProjectRoot( + test("flat: discovers routes from flat layout", async () => { + const projectRoot = await findProjectRoot( fixtures.flat.mainPy, fixtures.flat.root, + nodeFileSystem, ) - const graph = buildRouterGraph(fixtures.flat.mainPy, parser, projectRoot) + const graph = await buildRouterGraph( + fixtures.flat.mainPy, + parser, + projectRoot, + nodeFileSystem, + ) assert.ok(graph, "Should find FastAPI app") const appDef = routerNodeToAppDefinition(graph, fixtures.flat.root) @@ -108,16 +116,18 @@ suite("Project Layouts", () => { ) }) - test("namespace: discovers routes from namespace package (no __init__.py)", () => { - const projectRoot = findProjectRoot( + test("namespace: discovers routes from namespace package (no __init__.py)", async () => { + const projectRoot = await findProjectRoot( fixtures.namespace.mainPy, fixtures.namespace.root, + nodeFileSystem, ) - const graph = buildRouterGraph( + const graph = await buildRouterGraph( fixtures.namespace.mainPy, parser, projectRoot, + nodeFileSystem, ) assert.ok(graph, "Should find FastAPI app") @@ -146,16 +156,18 @@ suite("Project Layouts", () => { ) }) - test("reexport: discovers routes from __init__.py re-exports", () => { - const projectRoot = findProjectRoot( + test("reexport: discovers routes from __init__.py re-exports", async () => { + const projectRoot = await findProjectRoot( fixtures.reexport.mainPy, fixtures.reexport.root, + nodeFileSystem, ) - const graph = buildRouterGraph( + const graph = await buildRouterGraph( fixtures.reexport.mainPy, parser, projectRoot, + nodeFileSystem, ) assert.ok(graph, "Should find FastAPI app") diff --git a/src/test/providers/EndpointTreeProvider.test.ts b/src/test/providers/endpointTreeProvider.test.ts similarity index 98% rename from src/test/providers/EndpointTreeProvider.test.ts rename to src/test/providers/endpointTreeProvider.test.ts index 9d6e780..d3904b3 100644 --- a/src/test/providers/EndpointTreeProvider.test.ts +++ b/src/test/providers/endpointTreeProvider.test.ts @@ -1,5 +1,5 @@ import * as assert from "node:assert" -import { EndpointTreeProvider } from "../../providers/EndpointTreeProvider" +import { EndpointTreeProvider } from "../../providers/endpointTreeProvider" import { mockApps } from "../fixtures/mockEndpointData" suite("EndpointTreeProvider", () => { diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 4c1afc9..2af426b 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -1,46 +1,87 @@ -import { join } from "node:path" +import { existsSync, readFileSync } from "node:fs" +import { dirname, join } from "node:path" +import type { FileSystem } from "../core/filesystem" declare const __DIST_ROOT__: string export const wasmDir = join(__DIST_ROOT__, "wasm") -export const wasmPaths = { - core: join(wasmDir, "web-tree-sitter.wasm"), - python: join(wasmDir, "tree-sitter-python.wasm"), + +// Read Wasm files as Uint8Array for the new parser API +export const wasmBinaries = { + core: new Uint8Array(readFileSync(join(wasmDir, "web-tree-sitter.wasm"))), + python: new Uint8Array( + readFileSync(join(wasmDir, "tree-sitter-python.wasm")), + ), } export const fixturesPath = join(__DIST_ROOT__, "..", "src", "test", "fixtures") + +// Helper to convert string path to file:// URI string +const uri = (path: string) => `file://${path}` + export const fixtures = { standard: { - root: join(fixturesPath, "standard"), - mainPy: join(fixturesPath, "standard", "app", "main.py"), - usersPy: join(fixturesPath, "standard", "app", "routes", "users.py"), - initPy: join(fixturesPath, "standard", "app", "__init__.py"), + root: uri(join(fixturesPath, "standard")), + mainPy: uri(join(fixturesPath, "standard", "app", "main.py")), + usersPy: uri(join(fixturesPath, "standard", "app", "routes", "users.py")), + initPy: uri(join(fixturesPath, "standard", "app", "__init__.py")), }, flat: { - root: join(fixturesPath, "flat"), - mainPy: join(fixturesPath, "flat", "main.py"), + root: uri(join(fixturesPath, "flat")), + mainPy: uri(join(fixturesPath, "flat", "main.py")), }, namespace: { - root: join(fixturesPath, "namespace"), - mainPy: join(fixturesPath, "namespace", "app", "main.py"), + root: uri(join(fixturesPath, "namespace")), + mainPy: uri(join(fixturesPath, "namespace", "app", "main.py")), }, reexport: { - root: join(fixturesPath, "reexport"), - mainPy: join(fixturesPath, "reexport", "app", "main.py"), - initPy: join( - fixturesPath, - "reexport", - "app", - "integrations", - "__init__.py", + root: uri(join(fixturesPath, "reexport")), + mainPy: uri(join(fixturesPath, "reexport", "app", "main.py")), + initPy: uri( + join(fixturesPath, "reexport", "app", "integrations", "__init__.py"), ), }, sameFile: { - root: join(fixturesPath, "same-file"), - mainPy: join(fixturesPath, "same-file", "main.py"), + root: uri(join(fixturesPath, "same-file")), + mainPy: uri(join(fixturesPath, "same-file", "main.py")), }, multiApp: { - root: join(fixturesPath, "multi-app"), - mainPy: join(fixturesPath, "multi-app", "main.py"), + root: uri(join(fixturesPath, "multi-app")), + mainPy: uri(join(fixturesPath, "multi-app", "main.py")), + }, +} + +/** + * Extract file path from a file:// URI string + */ +function uriToPath(uri: string): string { + if (uri.startsWith("file://")) { + return uri.slice(7) + } + return uri +} + +/** + * Node.js FileSystem implementation for tests. + */ +export const nodeFileSystem: FileSystem = { + async readFile(uri: string): Promise { + const path = uriToPath(uri) + return new Uint8Array(readFileSync(path)) + }, + + async exists(uri: string): Promise { + const path = uriToPath(uri) + return existsSync(path) + }, + + joinPath(base: string, ...segments: string[]): string { + const basePath = uriToPath(base) + return `file://${join(basePath, ...segments)}` + }, + + dirname(uri: string): string { + const path = uriToPath(uri) + return `file://${dirname(path)}` }, }