Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ _.log
# VS Code extension
*.vsix
.vscode-test
.vscode-test-web

/.agents/
/.claude/
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 35 additions & 15 deletions esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()])
}
}

Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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."
}
}
Expand All @@ -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": {
Expand All @@ -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": {
Expand Down
107 changes: 67 additions & 40 deletions src/appDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,55 @@
* 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<string[]> {
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)
}

/**
* Parses pyproject.toml to find a defined entrypoint.
* Supports module:variable notation, e.g. "my_app.main:app"
*/
async function parsePyprojectForEntryPoint(
folderPath: string,
folderUri: vscode.Uri,
): Promise<EntryPoint | null> {
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<string, unknown>

const entrypoint = (contents.tool as Record<string, unknown> | undefined)
Expand All @@ -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
Expand All @@ -94,7 +90,14 @@ export async function discoverFastAPIApps(
parser: Parser,
): Promise<AppDefinition[]> {
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[] = []

Expand All @@ -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
}
20 changes: 11 additions & 9 deletions src/core/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"

Expand Down Expand Up @@ -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<FileAnalysis | null> {
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
}
}
Loading