From 6d6901874469db559d1d3961c1b0143bffee5cdd Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 14 Jan 2026 10:41:27 -0800 Subject: [PATCH 1/3] Add code lens for tests --- package.json | 5 + src/core/pathUtils.ts | 32 +++++ src/extension.ts | 35 ++++- src/providers/TestCodeLensProvider.ts | 185 ++++++++++++++++++++++++++ src/test/core/pathUtils.test.ts | 91 +++++++++++++ 5 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 src/providers/TestCodeLensProvider.ts diff --git a/package.json b/package.json index 0b5a190..842c72f 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,11 @@ "type": "string", "default": "", "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, + "description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition." } } } diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index 1f17212..5e73223 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -77,3 +77,35 @@ export function getPathSegments(path: string, count: number): string { export function countSegments(path: string): number { return path.split("/").filter(Boolean).length } + +/** + * Checks if a test path matches an endpoint path pattern. + * Endpoint paths may contain path parameters like {item_id} which match any segment. + * + * Examples: + * pathMatchesEndpoint("/items/123", "/items/{item_id}") -> true + * pathMatchesEndpoint("/items/123/details", "/items/{item_id}") -> false + * pathMatchesEndpoint("/users/abc/posts/456", "/users/{user_id}/posts/{post_id}") -> true + * pathMatchesEndpoint("/items/", "/items/{item_id}") -> false + */ +export function pathMatchesEndpoint( + testPath: string, + endpointPath: string, +): boolean { + const testSegments = testPath.split("/").filter(Boolean) + const endpointSegments = endpointPath.split("/").filter(Boolean) + + // Segment counts must match + if (testSegments.length !== endpointSegments.length) { + return false + } + + return endpointSegments.every((seg, index) => { + // Path parameter (e.g., {item_id}) matches any segment + if (seg.startsWith("{") && seg.endsWith("}")) { + return true + } + // Literal segments must match exactly + return seg === testSegments[index] + }) +} diff --git a/src/extension.ts b/src/extension.ts index 167a877..4cf030c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,7 @@ import { type EndpointTreeItem, EndpointTreeProvider, } from "./providers/EndpointTreeProvider" +import { TestCodeLensProvider } from "./providers/TestCodeLensProvider" async function discoverFastAPIApps(parser: Parser): Promise { const apps: AppDefinition[] = [] @@ -102,6 +103,7 @@ export async function activate(context: vscode.ExtensionContext) { // Discover FastAPI endpoints from workspace const apps = await discoverFastAPIApps(parserService) const endpointProvider = new EndpointTreeProvider(apps) + const codeLensProvider = new TestCodeLensProvider(parserService, apps) let refreshTimeout: NodeJS.Timeout | null = null @@ -110,9 +112,11 @@ export async function activate(context: vscode.ExtensionContext) { clearTimeout(refreshTimeout) } refreshTimeout = setTimeout(async () => { + if (!parserService) return const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) - }, 500) // Debounce for 500ms + codeLensProvider.setApps(newApps) + }, 500) } // Watch for changes in Python files to refresh endpoints @@ -126,6 +130,17 @@ export async function activate(context: vscode.ExtensionContext) { treeDataProvider: endpointProvider, }) + // Register CodeLens provider for test files + const config = vscode.workspace.getConfiguration("fastapi") + if (config.get("showTestCodeLenses", true)) { + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: "python", pattern: "**/test*.py" }, + codeLensProvider, + ), + ) + } + context.subscriptions.push( treeView, @@ -181,6 +196,24 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("fastapi-vscode.toggleRouters", () => { endpointProvider.toggleRouters() }), + + vscode.commands.registerCommand( + "fastapi-vscode.goToDefinition", + ( + locations: vscode.Location[], + fromUri: vscode.Uri, + fromPosition: vscode.Position, + ) => { + vscode.commands.executeCommand( + "editor.action.goToLocations", + fromUri, + fromPosition, + locations, + locations.length === 1 ? "goto" : "peek", + "No matching route found", + ) + }, + ), ) } diff --git a/src/providers/TestCodeLensProvider.ts b/src/providers/TestCodeLensProvider.ts new file mode 100644 index 0000000..8ef2ccd --- /dev/null +++ b/src/providers/TestCodeLensProvider.ts @@ -0,0 +1,185 @@ +/** + * CodeLens provider for FastAPI test client HTTP calls. + * Shows "Go to route" links above test client method calls. + */ + +import { + CodeLens, + type CodeLensProvider, + EventEmitter, + Location, + Position, + Range, + type TextDocument, + Uri, +} from "vscode" +import type { Node } from "web-tree-sitter" +import { extractStringValue, findNodesByType } from "../core/extractors" +import { ROUTE_METHODS } from "../core/internal" +import type { Parser } from "../core/parser" +import { + pathMatchesEndpoint, + stripLeadingDynamicSegments, +} from "../core/pathUtils" +import type { + AppDefinition, + RouteDefinition, + RouterDefinition, + SourceLocation, +} from "../core/types" + +interface TestClientCall { + method: string + path: string + line: number + column: number +} + +export class TestCodeLensProvider implements CodeLensProvider { + private apps: AppDefinition[] = [] + private parser: Parser + private _onDidChangeCodeLenses = new EventEmitter() + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event + + constructor(parser: Parser, apps: AppDefinition[]) { + this.parser = parser + this.apps = apps + } + + setApps(apps: AppDefinition[]): void { + this.apps = apps + this._onDidChangeCodeLenses.fire() + } + + provideCodeLenses(document: TextDocument): CodeLens[] { + const code = document.getText() + const tree = this.parser.parse(code) + if (!tree) { + return [] + } + + const testClientCalls = this.findTestClientCalls(tree.rootNode) + + const codeLenses: CodeLens[] = [] + + for (const call of testClientCalls) { + const matchingRoutes = this.findMatchingRoutes(call.path, call.method) + + if (matchingRoutes.length > 0) { + const range = new Range( + new Position(call.line, call.column), + new Position(call.line, call.column), + ) + + const methodUpper = call.method.toUpperCase() + const displayPath = stripLeadingDynamicSegments(call.path) + const locations = matchingRoutes.map( + (loc) => + new Location( + Uri.file(loc.filePath), + new Position(loc.line - 1, loc.column), + ), + ) + codeLenses.push( + new CodeLens(range, { + title: `Go to route: ${methodUpper} ${displayPath}`, + command: "fastapi-vscode.goToDefinition", + arguments: [ + locations, + document.uri, + new Position(call.line, call.column), + ], + }), + ) + } + } + + return codeLenses + } + + private findTestClientCalls(rootNode: Node): TestClientCall[] { + const calls: TestClientCall[] = [] + const callNodes = findNodesByType(rootNode, "call") + + for (const callNode of callNodes) { + const functionNode = callNode.childForFieldName("function") + if (!functionNode || functionNode.type !== "attribute") { + continue + } + + const methodNode = functionNode.childForFieldName("attribute") + if (!methodNode) { + continue + } + + const method = methodNode.text.toLowerCase() + if (!ROUTE_METHODS.has(method)) { + continue + } + + // Get the path argument (first argument) + const argumentsNode = callNode.childForFieldName("arguments") + if (!argumentsNode) { + continue + } + + const args = argumentsNode.namedChildren.filter( + (child) => child.type !== "comment", + ) + + if (args.length === 0) { + continue + } + + const pathArg = args[0] + // Only handle string literals for now + const path = extractStringValue(pathArg) + if (path === null) { + continue + } + + calls.push({ + method, + path, + line: callNode.startPosition.row, + column: callNode.startPosition.column, + }) + } + + return calls + } + + private findMatchingRoutes( + testPath: string, + testMethod: string, + ): SourceLocation[] { + const matches: SourceLocation[] = [] + + const collectRoutes = (routes: RouteDefinition[]) => { + for (const route of routes) { + if ( + route.method.toLowerCase() === testMethod.toLowerCase() && + pathMatchesEndpoint(testPath, route.path) + ) { + matches.push(route.location) + } + } + } + + const walkRouters = (routers: RouterDefinition[]) => { + for (const router of routers) { + collectRoutes(router.routes) + if (router.children) { + walkRouters(router.children) + } + } + } + + for (const app of this.apps) { + collectRoutes(app.routes) + walkRouters(app.routers) + } + + return matches + } +} diff --git a/src/test/core/pathUtils.test.ts b/src/test/core/pathUtils.test.ts index e4998d9..6d8b9f4 100644 --- a/src/test/core/pathUtils.test.ts +++ b/src/test/core/pathUtils.test.ts @@ -5,6 +5,7 @@ import { findProjectRoot, getPathSegments, isWithinDirectory, + pathMatchesEndpoint, stripLeadingDynamicSegments, } from "../../core/pathUtils" import { fixtures } from "../testUtils" @@ -163,4 +164,94 @@ suite("pathUtils", () => { assert.strictEqual(result, appRoot) }) }) + + suite("pathMatchesEndpoint", () => { + test("matches exact static paths", () => { + assert.strictEqual(pathMatchesEndpoint("/items", "/items"), true) + assert.strictEqual(pathMatchesEndpoint("/api/users", "/api/users"), true) + }) + + test("matches path with single parameter", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123", "/items/{item_id}"), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/items/abc", "/items/{item_id}"), + true, + ) + }) + + test("matches path with multiple parameters", () => { + assert.strictEqual( + pathMatchesEndpoint( + "/users/abc/posts/456", + "/users/{user_id}/posts/{post_id}", + ), + true, + ) + }) + + test("rejects when segment count differs", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123/details", "/items/{item_id}"), + false, + ) + assert.strictEqual( + pathMatchesEndpoint("/items", "/items/{item_id}"), + false, + ) + }) + + test("rejects when static segments differ", () => { + assert.strictEqual( + pathMatchesEndpoint("/users/123", "/items/{item_id}"), + false, + ) + assert.strictEqual( + pathMatchesEndpoint("/api/v1/items", "/api/v2/items"), + false, + ) + }) + + test("handles trailing slashes", () => { + assert.strictEqual(pathMatchesEndpoint("/items/", "/items"), true) + assert.strictEqual(pathMatchesEndpoint("/items", "/items/"), true) + assert.strictEqual( + pathMatchesEndpoint("/items/123/", "/items/{id}"), + true, + ) + }) + + test("handles root path", () => { + assert.strictEqual(pathMatchesEndpoint("/", "/"), true) + assert.strictEqual(pathMatchesEndpoint("", ""), true) + }) + + test("rejects empty path against non-empty", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123", "/items/{item_id}"), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/items/", "/items/{item_id}"), + false, + ) + }) + + test("matches paths with dynamic prefix", () => { + // Dynamic prefixes like {settings.API_V1_STR} match any segment (same as path params) + assert.strictEqual( + pathMatchesEndpoint( + "/v1/items/123", + "{settings.API_V1_STR}/items/{item_id}", + ), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/api/v2/users", "{BASE}/users"), + false, // segment count differs + ) + }) + }) }) From 00c3ad2f720baeb33ec063b89398c599faa22693 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 14 Jan 2026 10:50:53 -0800 Subject: [PATCH 2/3] Update pattern --- src/extension.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 4cf030c..a0f9c77 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -112,7 +112,9 @@ export async function activate(context: vscode.ExtensionContext) { clearTimeout(refreshTimeout) } refreshTimeout = setTimeout(async () => { - if (!parserService) return + if (!parserService) { + return + } const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) codeLensProvider.setApps(newApps) @@ -135,7 +137,9 @@ export async function activate(context: vscode.ExtensionContext) { if (config.get("showTestCodeLenses", true)) { context.subscriptions.push( vscode.languages.registerCodeLensProvider( - { language: "python", pattern: "**/test*.py" }, + // Covers common test file patterns + // e.g., test_*.py, *_test.py, tests/*.py + { language: "python", pattern: "**/*test*.py" }, codeLensProvider, ), ) From 6f1f65939bbbfeb8f897d1215caa249aaecd0f15 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Wed, 14 Jan 2026 10:54:36 -0800 Subject: [PATCH 3/3] Refresh codelens on edit --- src/extension.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/extension.ts b/src/extension.ts index a0f9c77..7e51d5b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -157,6 +157,7 @@ export async function activate(context: vscode.ExtensionContext) { clearImportCache() const newApps = await discoverFastAPIApps(parserService) endpointProvider.setApps(newApps) + codeLensProvider.setApps(newApps) }, ),