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
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
32 changes: 32 additions & 0 deletions src/core/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})
}
40 changes: 39 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type EndpointTreeItem,
EndpointTreeProvider,
} from "./providers/EndpointTreeProvider"
import { TestCodeLensProvider } from "./providers/TestCodeLensProvider"

async function discoverFastAPIApps(parser: Parser): Promise<AppDefinition[]> {
const apps: AppDefinition[] = []
Expand Down Expand Up @@ -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

Expand All @@ -110,9 +112,13 @@ 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
Expand All @@ -126,6 +132,19 @@ export async function activate(context: vscode.ExtensionContext) {
treeDataProvider: endpointProvider,
})

// Register CodeLens provider for test files
const config = vscode.workspace.getConfiguration("fastapi")
if (config.get<boolean>("showTestCodeLenses", true)) {
context.subscriptions.push(
vscode.languages.registerCodeLensProvider(
// Covers common test file patterns
// e.g., test_*.py, *_test.py, tests/*.py
{ language: "python", pattern: "**/*test*.py" },
codeLensProvider,
),
)
}

context.subscriptions.push(
treeView,

Expand All @@ -138,6 +157,7 @@ export async function activate(context: vscode.ExtensionContext) {
clearImportCache()
const newApps = await discoverFastAPIApps(parserService)
endpointProvider.setApps(newApps)
codeLensProvider.setApps(newApps)
},
),

Expand Down Expand Up @@ -181,6 +201,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",
)
},
),
)
}

Expand Down
185 changes: 185 additions & 0 deletions src/providers/TestCodeLensProvider.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
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
}
}
Loading
Loading