From 10e74f64a5de2da346bade799611158335e620f1 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Tue, 14 Apr 2026 13:55:46 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20route=20t?= =?UTF-8?q?o=20test=20CodeLens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- package.json | 2 +- src/core/extractors.ts | 56 ++++ src/extension.ts | 22 +- src/test/core/extractors.test.ts | 89 +++++++ .../providers/routeCodeLensProvider.test.ts | 245 ++++++++++++++++++ src/utils/telemetry/events.ts | 2 + src/vscode/routeCodeLensProvider.ts | 92 +++++++ src/vscode/testCodeLensProvider.ts | 66 +---- src/vscode/testIndex.ts | 74 ++++++ 10 files changed, 583 insertions(+), 69 deletions(-) create mode 100644 src/test/providers/routeCodeLensProvider.test.ts create mode 100644 src/vscode/routeCodeLensProvider.ts create mode 100644 src/vscode/testIndex.ts diff --git a/README.md b/README.md index 35e1f97..8004e22 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Using ctrl+shift+E (cmd+shift+E on Mac), you can open the Command Palette and qu ### CodeLens for test client calls -CodeLens links appear above HTTP client calls like `client.get('/items')`, letting you jump directly to the matching route definition. +CodeLens links appear above HTTP client calls like `client.get('/items')`, letting you jump directly to the matching route definition. Route definitions also show how many tests reference each endpoint, with links to navigate to the matching test calls. ![CodeLens GIF](media/walkthrough/codelens.gif) @@ -41,7 +41,7 @@ View real-time logs from your FastAPI Cloud deployed applications directly withi | Setting | Description | Default | |---------|-------------|---------| | `fastapi.entryPoint` | Entry point for the main FastAPI application in module notation (e.g., `my_app.main:app`). If not set, the extension searches `pyproject.toml` and common locations. | `""` (auto-detect) | -| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` | +| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls to navigate to route definitions, and above route definitions to navigate to matching tests. | `true` | | `fastapi.cloud.enabled` | Enable FastAPI Cloud integration (status bar, deploy commands). | `true` | | `fastapi.telemetry.enabled` | Send anonymous usage data to help improve the extension. See [TELEMETRY.md](TELEMETRY.md) for details on what is collected. | `true` | diff --git a/package.json b/package.json index 273beb1..ce0e113 100644 --- a/package.json +++ b/package.json @@ -311,7 +311,7 @@ "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." + "description": "Show CodeLens links above test client calls to navigate to route definitions, and above route definitions to navigate to matching tests." }, "fastapi.cloud.enabled": { "type": "boolean", diff --git a/src/core/extractors.ts b/src/core/extractors.ts index ffc90d1..92865ee 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -638,3 +638,59 @@ export function factoryCallExtractor( functionName: functionName, } } + +export interface TestClientCall { + method: string + path: string + line: number + column: number +} + +export function findTestClientCalls(rootNode: Node): TestClientCall[] { + const calls: TestClientCall[] = [] + const nodesByType = getNodesByType(rootNode) + const callNodes = nodesByType.get("call") ?? [] + + for (const callNode of callNodes) { + // Grammar guarantees: call nodes always have a function field + const functionNode = callNode.childForFieldName("function")! + if (functionNode.type !== "attribute") { + continue + } + + // Grammar guarantees: attribute nodes always have an attribute field + const methodNode = functionNode.childForFieldName("attribute")! + + const method = methodNode.text.toLowerCase() + if (!ROUTE_METHODS.has(method)) { + continue + } + + // Grammar guarantees: call nodes always have an arguments field + const argumentsNode = callNode.childForFieldName("arguments")! + + const args = argumentsNode.namedChildren.filter( + (child) => child.type !== "comment", + ) + + if (args.length === 0) { + continue + } + + const pathArg = resolveArgNode(args, 0, "url") + + if (!pathArg) { + continue + } + const path = extractPathFromNode(pathArg) + + calls.push({ + method, + path, + line: callNode.startPosition.row, + column: callNode.startPosition.column, + }) + } + + return calls +} diff --git a/src/extension.ts b/src/extension.ts index 57625d6..c42f8fc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,7 +36,9 @@ import { type PathOperationTreeItem, PathOperationTreeProvider, } from "./vscode/pathOperationTreeProvider" +import { RouteCodeLensProvider } from "./vscode/routeCodeLensProvider" import { TestCodeLensProvider } from "./vscode/testCodeLensProvider" +import { TestCallIndex } from "./vscode/testIndex" export const EXTENSION_ID = "FastAPILabs.fastapi-vscode" @@ -155,17 +157,29 @@ export async function activate(context: vscode.ExtensionContext) { apps, groupApps(apps), ) + const testIndex = new TestCallIndex(parserService) + testIndex.build().catch((e) => log(`TestCallIndex build failed: ${e}`)) + const codeLensProvider = new TestCodeLensProvider(parserService, apps) + const routeCodeLensProvider = new RouteCodeLensProvider(apps, testIndex) // File watcher for auto-refresh let refreshTimeout: ReturnType | null = null - const triggerRefresh = () => { + const triggerRefresh = (uri?: vscode.Uri) => { if (refreshTimeout) clearTimeout(refreshTimeout) refreshTimeout = setTimeout(async () => { if (!parserService) return const newApps = await discoverFastAPIApps(parserService) + + if (uri) { + await testIndex.invalidateFile(uri.toString()) + } else { + await testIndex.build() + } + pathOperationProvider.setApps(newApps, groupApps(newApps)) codeLensProvider.setApps(newApps) + routeCodeLensProvider.setApps(newApps) }, 300) } @@ -176,7 +190,7 @@ export async function activate(context: vscode.ExtensionContext) { // Re-discover when workspace folders change (handles late folder availability in browser) context.subscriptions.push( - vscode.workspace.onDidChangeWorkspaceFolders(triggerRefresh), + vscode.workspace.onDidChangeWorkspaceFolders(() => triggerRefresh()), ) // Tree view @@ -198,6 +212,10 @@ export async function activate(context: vscode.ExtensionContext) { { language: "python", pattern: "**/*test*.py" }, codeLensProvider, ), + vscode.languages.registerCodeLensProvider( + { language: "python", pattern: "**/*.py" }, + routeCodeLensProvider, + ), ) } diff --git a/src/test/core/extractors.test.ts b/src/test/core/extractors.test.ts index 2a9c0a9..03bc3ac 100644 --- a/src/test/core/extractors.test.ts +++ b/src/test/core/extractors.test.ts @@ -5,6 +5,7 @@ import { decoratorExtractor, extractPathFromNode, extractStringValue, + findTestClientCalls, getNodesByType, importExtractor, includeRouterExtractor, @@ -971,6 +972,94 @@ FLAG = True }) }) + suite("findTestClientCalls", () => { + test("extracts simple GET call", () => { + const code = `client.get("/users")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].method, "get") + assert.strictEqual(calls[0].path, "/users") + }) + + test("extracts POST call", () => { + const code = `client.post("/items", json={"name": "test"})` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].method, "post") + assert.strictEqual(calls[0].path, "/items") + }) + + test("extracts url keyword argument", () => { + const code = `client.get(url="/users")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].path, "/users") + }) + + test("extracts multiple calls", () => { + const code = ` +client.get("/users") +client.post("/items") +client.delete("/items/1") +` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 3) + assert.strictEqual(calls[0].method, "get") + assert.strictEqual(calls[1].method, "post") + assert.strictEqual(calls[2].method, "delete") + }) + + test("ignores non-HTTP method calls", () => { + const code = `client.connect("/ws")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 0) + }) + + test("ignores plain function calls", () => { + const code = `get("/users")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 0) + }) + + test("ignores calls with no arguments", () => { + const code = "client.get()" + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 0) + }) + + test("extracts f-string path", () => { + const code = `client.get(f"/users/{user_id}")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].path, "/users/{user_id}") + }) + + test("includes line and column", () => { + const code = `client.get("/users")` + const tree = parse(code) + const calls = findTestClientCalls(tree.rootNode) + + assert.strictEqual(calls[0].line, 0) + assert.strictEqual(calls[0].column, 0) + }) + }) + suite("decoratorExtractor path handling", () => { test("handles concatenated strings", () => { const code = ` diff --git a/src/test/providers/routeCodeLensProvider.test.ts b/src/test/providers/routeCodeLensProvider.test.ts new file mode 100644 index 0000000..f4a09d5 --- /dev/null +++ b/src/test/providers/routeCodeLensProvider.test.ts @@ -0,0 +1,245 @@ +import * as assert from "node:assert" +import * as vscode from "vscode" +import { Parser } from "../../core/parser" +import type { + AppDefinition, + RouteDefinition, + RouterDefinition, +} from "../../core/types" +import { RouteCodeLensProvider } from "../../vscode/routeCodeLensProvider" +import { TestCallIndex } from "../../vscode/testIndex" +import { wasmBinaries } from "../testUtils" + +function createMockApp( + routes: RouteDefinition[], + routers: RouterDefinition[] = [], + filePath = "file:///test/main.py", +): AppDefinition { + return { + name: "app", + filePath, + workspaceFolder: "file:///test", + routes, + routers, + } +} + +function createRoute( + method: string, + path: string, + filePath = "file:///test/main.py", + line = 1, +): RouteDefinition { + return { + method: method.toUpperCase() as RouteDefinition["method"], + path, + functionName: "handler", + location: { + filePath, + line, + column: 0, + }, + } +} + +suite("RouteCodeLensProvider", () => { + let parser: Parser + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmBinaries) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + test("returns empty array when no routes match current file", async () => { + const testIndex = new TestCallIndex(parser) + const app = createMockApp([ + createRoute("GET", "/users", "file:///other/main.py"), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 0) + }) + + test("returns empty array when routes have no matching tests", async () => { + const testIndex = new TestCallIndex(parser) + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const app = createMockApp([ + createRoute("GET", "/users", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 0) + }) + + test("setApps fires change event", () => { + const testIndex = new TestCallIndex(parser) + const provider = new RouteCodeLensProvider([], testIndex) + + let eventFired = false + provider.onDidChangeCodeLenses(() => { + eventFired = true + }) + + provider.setApps([createMockApp([])]) + assert.strictEqual(eventFired, true) + }) + + test("creates CodeLens with correct title for single test", async () => { + const testIndex = new TestCallIndex(parser) + // Manually populate the index with a test call + const testCode = 'client.get("/users")' + const tree = parser.parse(testCode) + if (tree) { + const { findTestClientCalls } = await import("../../core/extractors") + const calls = findTestClientCalls(tree.rootNode) + // Access private index via any cast for testing + ;(testIndex as any).index.set("file:///test/test_app.py", calls) + } + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const app = createMockApp([ + createRoute("GET", "/users", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 1) + assert.strictEqual(lenses[0].command?.title, "1 test") + }) + + test("creates CodeLens with plural title for multiple tests", async () => { + const testIndex = new TestCallIndex(parser) + const testCode = ` +client.get("/users") +client.get("/users") +` + const tree = parser.parse(testCode) + if (tree) { + const { findTestClientCalls } = await import("../../core/extractors") + const calls = findTestClientCalls(tree.rootNode) + ;(testIndex as any).index.set("file:///test/test_app.py", calls) + } + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const app = createMockApp([ + createRoute("GET", "/users", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 1) + assert.strictEqual(lenses[0].command?.title, "2 tests") + }) + + test("uses goToDefinition command with locations", async () => { + const testIndex = new TestCallIndex(parser) + const testCode = 'client.get("/users")' + const tree = parser.parse(testCode) + if (tree) { + const { findTestClientCalls } = await import("../../core/extractors") + const calls = findTestClientCalls(tree.rootNode) + ;(testIndex as any).index.set("file:///test/test_app.py", calls) + } + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const app = createMockApp([ + createRoute("GET", "/users", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual( + lenses[0].command?.command, + "fastapi-vscode.goToDefinition", + ) + assert.ok(Array.isArray(lenses[0].command?.arguments?.[0])) + }) + + test("aggregates test calls from multiple files", async () => { + const testIndex = new TestCallIndex(parser) + const { findTestClientCalls } = await import("../../core/extractors") + + // Populate index with calls from two different test files + const tree1 = parser.parse('client.get("/users")') + if (tree1) { + ;(testIndex as any).index.set( + "file:///test/test_users.py", + findTestClientCalls(tree1.rootNode), + ) + } + const tree2 = parser.parse('client.get("/users")\nclient.get("/users/123")') + if (tree2) { + ;(testIndex as any).index.set( + "file:///test/test_admin.py", + findTestClientCalls(tree2.rootNode), + ) + } + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/users')\ndef handler(): pass", + language: "python", + }) + const app = createMockApp([ + createRoute("GET", "/users", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 1) + assert.strictEqual(lenses[0].command?.title, "2 tests") + + // Verify locations point to different files + const locations = lenses[0].command?.arguments?.[0] as vscode.Location[] + assert.strictEqual(locations.length, 2) + const filePaths = locations.map((l) => l.uri.toString()) + assert.ok(filePaths.includes("file:///test/test_users.py")) + assert.ok(filePaths.includes("file:///test/test_admin.py")) + }) + + test("matches routes case-insensitively", async () => { + const testIndex = new TestCallIndex(parser) + // findTestClientCalls returns lowercase methods + const testCode = 'client.get("/items")' + const tree = parser.parse(testCode) + if (tree) { + const { findTestClientCalls } = await import("../../core/extractors") + const calls = findTestClientCalls(tree.rootNode) + ;(testIndex as any).index.set("file:///test/test_app.py", calls) + } + + const doc = await vscode.workspace.openTextDocument({ + content: "@app.get('/items')\ndef handler(): pass", + language: "python", + }) + // Route has uppercase method + const app = createMockApp([ + createRoute("GET", "/items", doc.uri.toString()), + ]) + const provider = new RouteCodeLensProvider([app], testIndex) + + const lenses = provider.provideCodeLenses(doc) + assert.strictEqual(lenses.length, 1) + }) +}) diff --git a/src/utils/telemetry/events.ts b/src/utils/telemetry/events.ts index e2b59b9..02862a4 100644 --- a/src/utils/telemetry/events.ts +++ b/src/utils/telemetry/events.ts @@ -148,8 +148,10 @@ export function trackSearchExecuted( export function trackCodeLensProvided( testCallsCount: number, matchedCount: number, + type: "test" | "route" = "test", ): void { client.capture(Events.CODELENS_PROVIDED, { + type, test_calls_count: testCallsCount, matched_count: matchedCount, match_rate: testCallsCount > 0 ? matchedCount / testCallsCount : 0, diff --git a/src/vscode/routeCodeLensProvider.ts b/src/vscode/routeCodeLensProvider.ts new file mode 100644 index 0000000..2221e5e --- /dev/null +++ b/src/vscode/routeCodeLensProvider.ts @@ -0,0 +1,92 @@ +import { + CodeLens, + type CodeLensProvider, + type Disposable, + EventEmitter, + Location, + Position, + Range, + type TextDocument, + Uri, +} from "vscode" + +import { type AppDefinition, collectRoutes } from "../core" +import type { RouteDefinition } from "../core/types" +import { trackCodeLensProvided } from "../utils/telemetry" +import type { TestCallIndex } from "./testIndex" + +export class RouteCodeLensProvider implements CodeLensProvider { + private cachedRoutes: RouteDefinition[] = [] + private testIndex: TestCallIndex + private indexListener: Disposable + private trackedFiles = new Set() + + private _onDidChangeCodeLenses = new EventEmitter() + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event + + constructor(apps: AppDefinition[], testIndex: TestCallIndex) { + this.cachedRoutes = collectRoutes(apps) + this.testIndex = testIndex + this.indexListener = testIndex.onDidChangeIndex(() => { + this._onDidChangeCodeLenses.fire() + }) + } + + setApps(apps: AppDefinition[]): void { + this.cachedRoutes = collectRoutes(apps) + this.trackedFiles.clear() + this._onDidChangeCodeLenses.fire() + } + + provideCodeLenses(document: TextDocument): CodeLens[] { + const currentFile = document.uri.toString() + const routes = this.cachedRoutes.filter( + (route) => route.location.filePath === currentFile, + ) + + const codeLenses: CodeLens[] = [] + + for (const route of routes) { + const matchingTests = this.testIndex.getTestCallsForRoute( + route.method, + route.path, + ) + if (matchingTests.length === 0) continue + + const range = new Range( + new Position(route.location.line - 1, route.location.column), + new Position(route.location.line - 1, route.location.column), + ) + + codeLenses.push( + new CodeLens(range, { + title: `${matchingTests.length} ${matchingTests.length === 1 ? "test" : "tests"}`, + command: "fastapi-vscode.goToDefinition", + arguments: [ + matchingTests.map( + (test) => + new Location( + Uri.parse(test.filePath), + new Position(test.line - 1, test.column), + ), + ), + document.uri, + new Position(route.location.line - 1, route.location.column), + ], + }), + ) + } + + if (routes.length > 0 && !this.trackedFiles.has(currentFile)) { + this.trackedFiles.add(currentFile) + trackCodeLensProvided(routes.length, codeLenses.length, "route") + } + + return codeLenses + } + + dispose(): void { + this.indexListener.dispose() + this._onDidChangeCodeLenses.dispose() + } +} diff --git a/src/vscode/testCodeLensProvider.ts b/src/vscode/testCodeLensProvider.ts index 04d7411..c0f20f2 100644 --- a/src/vscode/testCodeLensProvider.ts +++ b/src/vscode/testCodeLensProvider.ts @@ -13,13 +13,7 @@ import { type TextDocument, Uri, } from "vscode" -import type { Node } from "web-tree-sitter" -import { - extractPathFromNode, - getNodesByType, - resolveArgNode, -} from "../core/extractors" -import { ROUTE_METHODS } from "../core/internal" +import { findTestClientCalls } from "../core/extractors" import type { Parser } from "../core/parser" import { pathMatchesPathOperation, @@ -29,13 +23,6 @@ import { collectRoutes } from "../core/treeUtils" import type { AppDefinition, SourceLocation } from "../core/types" import { trackCodeLensProvided } from "../utils/telemetry" -interface TestClientCall { - method: string - path: string - line: number - column: number -} - export class TestCodeLensProvider implements CodeLensProvider { private apps: AppDefinition[] = [] private parser: Parser @@ -62,7 +49,7 @@ export class TestCodeLensProvider implements CodeLensProvider { /* c8 ignore next */ if (!tree) return [] - const testClientCalls = this.findTestClientCalls(tree.rootNode) + const testClientCalls = findTestClientCalls(tree.rootNode) const codeLenses: CodeLens[] = [] @@ -108,55 +95,6 @@ export class TestCodeLensProvider implements CodeLensProvider { return codeLenses } - private findTestClientCalls(rootNode: Node): TestClientCall[] { - const calls: TestClientCall[] = [] - const nodesByType = getNodesByType(rootNode) - const callNodes = nodesByType.get("call") ?? [] - - for (const callNode of callNodes) { - // Grammar guarantees: call nodes always have a function field - const functionNode = callNode.childForFieldName("function")! - if (functionNode.type !== "attribute") { - continue - } - - // Grammar guarantees: attribute nodes always have an attribute field - const methodNode = functionNode.childForFieldName("attribute")! - - const method = methodNode.text.toLowerCase() - if (!ROUTE_METHODS.has(method)) { - continue - } - - // Grammar guarantees: call nodes always have an arguments field - const argumentsNode = callNode.childForFieldName("arguments")! - - const args = argumentsNode.namedChildren.filter( - (child) => child.type !== "comment", - ) - - if (args.length === 0) { - continue - } - - const pathArg = resolveArgNode(args, 0, "url") - - if (!pathArg) { - continue - } - const path = extractPathFromNode(pathArg) - - calls.push({ - method, - path, - line: callNode.startPosition.row, - column: callNode.startPosition.column, - }) - } - - return calls - } - private findMatchingRoutes( testPath: string, testMethod: string, diff --git a/src/vscode/testIndex.ts b/src/vscode/testIndex.ts new file mode 100644 index 0000000..f9a3e25 --- /dev/null +++ b/src/vscode/testIndex.ts @@ -0,0 +1,74 @@ +import { EventEmitter, workspace } from "vscode" +import { findTestClientCalls } from "../core/extractors" +import type { Parser } from "../core/parser" +import { pathMatchesPathOperation } from "../core/pathUtils" +import type { SourceLocation } from "../core/types" + +export class TestCallIndex { + private index = new Map< + string, + { method: string; path: string; line: number; column: number }[] + >() + private parser: Parser + + private _onDidChangeIndex = new EventEmitter() + readonly onDidChangeIndex = this._onDidChangeIndex.event + + constructor(parser: Parser) { + this.parser = parser + } + + async build(): Promise { + this.index.clear() + const testFiles = await workspace.findFiles("**/*test*.py") + for (const file of testFiles) { + const document = await workspace.openTextDocument(file) + const tree = this.parser.parse(document.getText()) + if (!tree) continue + + const calls = findTestClientCalls(tree.rootNode) + this.index.set(file.toString(), calls) + } + this._onDidChangeIndex.fire() + } + + getTestCallsForRoute(method: string, path: string): SourceLocation[] { + const matchingTestCalls: SourceLocation[] = [] + + for (const [filePath, testCalls] of this.index.entries()) { + for (const call of testCalls) { + if ( + call.method.toLowerCase() === method.toLowerCase() && + pathMatchesPathOperation(call.path, path) + ) { + matchingTestCalls.push({ + filePath, + line: call.line + 1, + column: call.column, + }) + } + } + } + + return matchingTestCalls + } + + async invalidateFile(fileUri: string): Promise { + if (!fileUri.includes("test")) { + return + } + try { + const document = await workspace.openTextDocument(fileUri) + const tree = this.parser.parse(document.getText()) + if (!tree) { + this.index.delete(fileUri) + return + } + const calls = findTestClientCalls(tree.rootNode) + this.index.set(fileUri, calls) + } catch { + this.index.delete(fileUri) + } + this._onDidChangeIndex.fire() + } +} From 33c0fa1007a0bb28a7066ceb4aa5b33deb9e0fde Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Tue, 14 Apr 2026 14:08:36 -0700 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Fix=20naming=20for=20c?= =?UTF-8?q?larity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/extension.ts | 25 +++++++++++-------- ...ts => routeToTestCodeLensProvider.test.ts} | 20 +++++++-------- ...ts => testToRouteCodeLensProvider.test.ts} | 12 ++++----- ...ider.ts => routeToTestCodeLensProvider.ts} | 2 +- ...ider.ts => testToRouteCodeLensProvider.ts} | 2 +- 5 files changed, 32 insertions(+), 29 deletions(-) rename src/test/providers/{routeCodeLensProvider.test.ts => routeToTestCodeLensProvider.test.ts} (91%) rename src/test/providers/{testCodeLensProvider.test.ts => testToRouteCodeLensProvider.test.ts} (96%) rename src/vscode/{routeCodeLensProvider.ts => routeToTestCodeLensProvider.ts} (97%) rename src/vscode/{testCodeLensProvider.ts => testToRouteCodeLensProvider.ts} (97%) diff --git a/src/extension.ts b/src/extension.ts index c42f8fc..b06dc08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,9 +36,9 @@ import { type PathOperationTreeItem, PathOperationTreeProvider, } from "./vscode/pathOperationTreeProvider" -import { RouteCodeLensProvider } from "./vscode/routeCodeLensProvider" -import { TestCodeLensProvider } from "./vscode/testCodeLensProvider" +import { RouteToTestCodeLensProvider } from "./vscode/routeToTestCodeLensProvider" import { TestCallIndex } from "./vscode/testIndex" +import { TestToRouteCodeLensProvider } from "./vscode/testToRouteCodeLensProvider" export const EXTENSION_ID = "FastAPILabs.fastapi-vscode" @@ -160,8 +160,11 @@ export async function activate(context: vscode.ExtensionContext) { const testIndex = new TestCallIndex(parserService) testIndex.build().catch((e) => log(`TestCallIndex build failed: ${e}`)) - const codeLensProvider = new TestCodeLensProvider(parserService, apps) - const routeCodeLensProvider = new RouteCodeLensProvider(apps, testIndex) + const testToRouteProvider = new TestToRouteCodeLensProvider( + parserService, + apps, + ) + const routeToTestProvider = new RouteToTestCodeLensProvider(apps, testIndex) // File watcher for auto-refresh let refreshTimeout: ReturnType | null = null @@ -178,8 +181,8 @@ export async function activate(context: vscode.ExtensionContext) { } pathOperationProvider.setApps(newApps, groupApps(newApps)) - codeLensProvider.setApps(newApps) - routeCodeLensProvider.setApps(newApps) + testToRouteProvider.setApps(newApps) + routeToTestProvider.setApps(newApps) }, 300) } @@ -210,11 +213,11 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.languages.registerCodeLensProvider( { language: "python", pattern: "**/*test*.py" }, - codeLensProvider, + testToRouteProvider, ), vscode.languages.registerCodeLensProvider( { language: "python", pattern: "**/*.py" }, - routeCodeLensProvider, + routeToTestProvider, ), ) } @@ -324,7 +327,7 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands( context.extensionUri, pathOperationProvider, - codeLensProvider, + testToRouteProvider, groupApps, ), { dispose: () => clearInterval(telemetryFlushInterval) }, @@ -405,7 +408,7 @@ function registerCloudCommands( function registerCommands( extensionUri: vscode.Uri, pathOperationProvider: PathOperationTreeProvider, - codeLensProvider: TestCodeLensProvider, + testToRouteProvider: TestToRouteCodeLensProvider, groupApps: ( apps: AppDefinition[], ) => Array< @@ -421,7 +424,7 @@ function registerCommands( clearImportCache() const newApps = await discoverFastAPIApps(parserService) pathOperationProvider.setApps(newApps, groupApps(newApps)) - codeLensProvider.setApps(newApps) + testToRouteProvider.setApps(newApps) }, ), diff --git a/src/test/providers/routeCodeLensProvider.test.ts b/src/test/providers/routeToTestCodeLensProvider.test.ts similarity index 91% rename from src/test/providers/routeCodeLensProvider.test.ts rename to src/test/providers/routeToTestCodeLensProvider.test.ts index f4a09d5..e304126 100644 --- a/src/test/providers/routeCodeLensProvider.test.ts +++ b/src/test/providers/routeToTestCodeLensProvider.test.ts @@ -6,7 +6,7 @@ import type { RouteDefinition, RouterDefinition, } from "../../core/types" -import { RouteCodeLensProvider } from "../../vscode/routeCodeLensProvider" +import { RouteToTestCodeLensProvider } from "../../vscode/routeToTestCodeLensProvider" import { TestCallIndex } from "../../vscode/testIndex" import { wasmBinaries } from "../testUtils" @@ -42,7 +42,7 @@ function createRoute( } } -suite("RouteCodeLensProvider", () => { +suite("RouteToTestCodeLensProvider", () => { let parser: Parser suiteSetup(async () => { @@ -59,7 +59,7 @@ suite("RouteCodeLensProvider", () => { const app = createMockApp([ createRoute("GET", "/users", "file:///other/main.py"), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const doc = await vscode.workspace.openTextDocument({ content: "@app.get('/users')\ndef handler(): pass", @@ -78,7 +78,7 @@ suite("RouteCodeLensProvider", () => { const app = createMockApp([ createRoute("GET", "/users", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual(lenses.length, 0) @@ -86,7 +86,7 @@ suite("RouteCodeLensProvider", () => { test("setApps fires change event", () => { const testIndex = new TestCallIndex(parser) - const provider = new RouteCodeLensProvider([], testIndex) + const provider = new RouteToTestCodeLensProvider([], testIndex) let eventFired = false provider.onDidChangeCodeLenses(() => { @@ -116,7 +116,7 @@ suite("RouteCodeLensProvider", () => { const app = createMockApp([ createRoute("GET", "/users", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual(lenses.length, 1) @@ -143,7 +143,7 @@ client.get("/users") const app = createMockApp([ createRoute("GET", "/users", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual(lenses.length, 1) @@ -167,7 +167,7 @@ client.get("/users") const app = createMockApp([ createRoute("GET", "/users", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual( @@ -204,7 +204,7 @@ client.get("/users") const app = createMockApp([ createRoute("GET", "/users", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual(lenses.length, 1) @@ -237,7 +237,7 @@ client.get("/users") const app = createMockApp([ createRoute("GET", "/items", doc.uri.toString()), ]) - const provider = new RouteCodeLensProvider([app], testIndex) + const provider = new RouteToTestCodeLensProvider([app], testIndex) const lenses = provider.provideCodeLenses(doc) assert.strictEqual(lenses.length, 1) diff --git a/src/test/providers/testCodeLensProvider.test.ts b/src/test/providers/testToRouteCodeLensProvider.test.ts similarity index 96% rename from src/test/providers/testCodeLensProvider.test.ts rename to src/test/providers/testToRouteCodeLensProvider.test.ts index 5d067d5..2c0f711 100644 --- a/src/test/providers/testCodeLensProvider.test.ts +++ b/src/test/providers/testToRouteCodeLensProvider.test.ts @@ -6,7 +6,7 @@ import type { RouteDefinition, RouterDefinition, } from "../../core/types" -import { TestCodeLensProvider } from "../../vscode/testCodeLensProvider" +import { TestToRouteCodeLensProvider } from "../../vscode/testToRouteCodeLensProvider" import { wasmBinaries } from "../testUtils" function createMockApp( @@ -54,9 +54,9 @@ function createRouter( } } -suite("TestCodeLensProvider", () => { +suite("TestToRouteCodeLensProvider", () => { let parser: Parser - let provider: TestCodeLensProvider + let provider: TestToRouteCodeLensProvider suiteSetup(async () => { parser = new Parser() @@ -68,18 +68,18 @@ suite("TestCodeLensProvider", () => { }) setup(() => { - provider = new TestCodeLensProvider(parser, []) + provider = new TestToRouteCodeLensProvider(parser, []) }) suite("constructor", () => { test("creates provider with empty apps", () => { - const p = new TestCodeLensProvider(parser, []) + const p = new TestToRouteCodeLensProvider(parser, []) assert.ok(p) }) test("creates provider with apps", () => { const app = createMockApp([createRoute("GET", "/")]) - const p = new TestCodeLensProvider(parser, [app]) + const p = new TestToRouteCodeLensProvider(parser, [app]) assert.ok(p) }) }) diff --git a/src/vscode/routeCodeLensProvider.ts b/src/vscode/routeToTestCodeLensProvider.ts similarity index 97% rename from src/vscode/routeCodeLensProvider.ts rename to src/vscode/routeToTestCodeLensProvider.ts index 2221e5e..e1c064d 100644 --- a/src/vscode/routeCodeLensProvider.ts +++ b/src/vscode/routeToTestCodeLensProvider.ts @@ -15,7 +15,7 @@ import type { RouteDefinition } from "../core/types" import { trackCodeLensProvided } from "../utils/telemetry" import type { TestCallIndex } from "./testIndex" -export class RouteCodeLensProvider implements CodeLensProvider { +export class RouteToTestCodeLensProvider implements CodeLensProvider { private cachedRoutes: RouteDefinition[] = [] private testIndex: TestCallIndex private indexListener: Disposable diff --git a/src/vscode/testCodeLensProvider.ts b/src/vscode/testToRouteCodeLensProvider.ts similarity index 97% rename from src/vscode/testCodeLensProvider.ts rename to src/vscode/testToRouteCodeLensProvider.ts index c0f20f2..6b59da6 100644 --- a/src/vscode/testCodeLensProvider.ts +++ b/src/vscode/testToRouteCodeLensProvider.ts @@ -23,7 +23,7 @@ import { collectRoutes } from "../core/treeUtils" import type { AppDefinition, SourceLocation } from "../core/types" import { trackCodeLensProvided } from "../utils/telemetry" -export class TestCodeLensProvider implements CodeLensProvider { +export class TestToRouteCodeLensProvider implements CodeLensProvider { private apps: AppDefinition[] = [] private parser: Parser From 128888ccd8adeaed5641c6f05f2d2bd81f48e149 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Tue, 14 Apr 2026 14:26:51 -0700 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=85=20Clean=20up=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routeToTestCodeLensProvider.test.ts | 90 +++++++------------ src/vscode/testIndex.ts | 8 ++ 2 files changed, 39 insertions(+), 59 deletions(-) diff --git a/src/test/providers/routeToTestCodeLensProvider.test.ts b/src/test/providers/routeToTestCodeLensProvider.test.ts index e304126..41cb179 100644 --- a/src/test/providers/routeToTestCodeLensProvider.test.ts +++ b/src/test/providers/routeToTestCodeLensProvider.test.ts @@ -42,6 +42,10 @@ function createRoute( } } +function createTestCall(method: string, path: string, line = 0, column = 0) { + return { method, path, line, column } +} + suite("RouteToTestCodeLensProvider", () => { let parser: Parser @@ -62,7 +66,7 @@ suite("RouteToTestCodeLensProvider", () => { const provider = new RouteToTestCodeLensProvider([app], testIndex) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const lenses = provider.provideCodeLenses(doc) @@ -72,7 +76,7 @@ suite("RouteToTestCodeLensProvider", () => { test("returns empty array when routes have no matching tests", async () => { const testIndex = new TestCallIndex(parser) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const app = createMockApp([ @@ -99,18 +103,12 @@ suite("RouteToTestCodeLensProvider", () => { test("creates CodeLens with correct title for single test", async () => { const testIndex = new TestCallIndex(parser) - // Manually populate the index with a test call - const testCode = 'client.get("/users")' - const tree = parser.parse(testCode) - if (tree) { - const { findTestClientCalls } = await import("../../core/extractors") - const calls = findTestClientCalls(tree.rootNode) - // Access private index via any cast for testing - ;(testIndex as any).index.set("file:///test/test_app.py", calls) - } + testIndex.setCallsForFile("file:///test/test_app.py", [ + createTestCall("get", "/users"), + ]) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const app = createMockApp([ @@ -125,19 +123,13 @@ suite("RouteToTestCodeLensProvider", () => { test("creates CodeLens with plural title for multiple tests", async () => { const testIndex = new TestCallIndex(parser) - const testCode = ` -client.get("/users") -client.get("/users") -` - const tree = parser.parse(testCode) - if (tree) { - const { findTestClientCalls } = await import("../../core/extractors") - const calls = findTestClientCalls(tree.rootNode) - ;(testIndex as any).index.set("file:///test/test_app.py", calls) - } + testIndex.setCallsForFile("file:///test/test_app.py", [ + createTestCall("get", "/users"), + createTestCall("get", "/users", 5), + ]) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const app = createMockApp([ @@ -152,16 +144,12 @@ client.get("/users") test("uses goToDefinition command with locations", async () => { const testIndex = new TestCallIndex(parser) - const testCode = 'client.get("/users")' - const tree = parser.parse(testCode) - if (tree) { - const { findTestClientCalls } = await import("../../core/extractors") - const calls = findTestClientCalls(tree.rootNode) - ;(testIndex as any).index.set("file:///test/test_app.py", calls) - } + testIndex.setCallsForFile("file:///test/test_app.py", [ + createTestCall("get", "/users"), + ]) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const app = createMockApp([ @@ -179,26 +167,16 @@ client.get("/users") test("aggregates test calls from multiple files", async () => { const testIndex = new TestCallIndex(parser) - const { findTestClientCalls } = await import("../../core/extractors") - - // Populate index with calls from two different test files - const tree1 = parser.parse('client.get("/users")') - if (tree1) { - ;(testIndex as any).index.set( - "file:///test/test_users.py", - findTestClientCalls(tree1.rootNode), - ) - } - const tree2 = parser.parse('client.get("/users")\nclient.get("/users/123")') - if (tree2) { - ;(testIndex as any).index.set( - "file:///test/test_admin.py", - findTestClientCalls(tree2.rootNode), - ) - } + testIndex.setCallsForFile("file:///test/test_users.py", [ + createTestCall("get", "/users"), + ]) + testIndex.setCallsForFile("file:///test/test_admin.py", [ + createTestCall("get", "/users"), + createTestCall("get", "/users/123"), + ]) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/users')\ndef handler(): pass", + content: "", language: "python", }) const app = createMockApp([ @@ -220,20 +198,14 @@ client.get("/users") test("matches routes case-insensitively", async () => { const testIndex = new TestCallIndex(parser) - // findTestClientCalls returns lowercase methods - const testCode = 'client.get("/items")' - const tree = parser.parse(testCode) - if (tree) { - const { findTestClientCalls } = await import("../../core/extractors") - const calls = findTestClientCalls(tree.rootNode) - ;(testIndex as any).index.set("file:///test/test_app.py", calls) - } + testIndex.setCallsForFile("file:///test/test_app.py", [ + createTestCall("get", "/items"), + ]) const doc = await vscode.workspace.openTextDocument({ - content: "@app.get('/items')\ndef handler(): pass", + content: "", language: "python", }) - // Route has uppercase method const app = createMockApp([ createRoute("GET", "/items", doc.uri.toString()), ]) diff --git a/src/vscode/testIndex.ts b/src/vscode/testIndex.ts index f9a3e25..2bf8383 100644 --- a/src/vscode/testIndex.ts +++ b/src/vscode/testIndex.ts @@ -71,4 +71,12 @@ export class TestCallIndex { } this._onDidChangeIndex.fire() } + + /** @internal Exposed for testing only — set cached calls for a file URI. */ + setCallsForFile( + fileUri: string, + calls: { method: string; path: string; line: number; column: number }[], + ): void { + this.index.set(fileUri, calls) + } }