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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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` |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions src/core/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
39 changes: 30 additions & 9 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import {
type PathOperationTreeItem,
PathOperationTreeProvider,
} from "./vscode/pathOperationTreeProvider"
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"

Expand Down Expand Up @@ -155,17 +157,32 @@ export async function activate(context: vscode.ExtensionContext) {
apps,
groupApps(apps),
)
const codeLensProvider = new TestCodeLensProvider(parserService, apps)
const testIndex = new TestCallIndex(parserService)
testIndex.build().catch((e) => log(`TestCallIndex build failed: ${e}`))

const testToRouteProvider = new TestToRouteCodeLensProvider(
parserService,
apps,
)
const routeToTestProvider = new RouteToTestCodeLensProvider(apps, testIndex)

// File watcher for auto-refresh
let refreshTimeout: ReturnType<typeof setTimeout> | 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)
testToRouteProvider.setApps(newApps)
routeToTestProvider.setApps(newApps)
}, 300)
}

Expand All @@ -176,7 +193,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
Expand All @@ -196,7 +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" },
routeToTestProvider,
),
)
}
Expand Down Expand Up @@ -306,7 +327,7 @@ export async function activate(context: vscode.ExtensionContext) {
registerCommands(
context.extensionUri,
pathOperationProvider,
codeLensProvider,
testToRouteProvider,
groupApps,
),
{ dispose: () => clearInterval(telemetryFlushInterval) },
Expand Down Expand Up @@ -387,7 +408,7 @@ function registerCloudCommands(
function registerCommands(
extensionUri: vscode.Uri,
pathOperationProvider: PathOperationTreeProvider,
codeLensProvider: TestCodeLensProvider,
testToRouteProvider: TestToRouteCodeLensProvider,
groupApps: (
apps: AppDefinition[],
) => Array<
Expand All @@ -403,7 +424,7 @@ function registerCommands(
clearImportCache()
const newApps = await discoverFastAPIApps(parserService)
pathOperationProvider.setApps(newApps, groupApps(newApps))
codeLensProvider.setApps(newApps)
testToRouteProvider.setApps(newApps)
},
),

Expand Down
89 changes: 89 additions & 0 deletions src/test/core/extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
decoratorExtractor,
extractPathFromNode,
extractStringValue,
findTestClientCalls,
getNodesByType,
importExtractor,
includeRouterExtractor,
Expand Down Expand Up @@ -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 = `
Expand Down
Loading
Loading