Skip to content

Commit 237929d

Browse files
✨ Add support for route to test CodeLens (#143)
1 parent def35f1 commit 237929d

File tree

11 files changed

+580
-83
lines changed

11 files changed

+580
-83
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Using ctrl+shift+E (cmd+shift+E on Mac), you can open the Command Palette and qu
2020

2121
### CodeLens for test client calls
2222

23-
CodeLens links appear above HTTP client calls like `client.get('/items')`, letting you jump directly to the matching route definition.
23+
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.
2424

2525
![CodeLens GIF](media/walkthrough/codelens.gif)
2626

@@ -41,7 +41,7 @@ View real-time logs from your FastAPI Cloud deployed applications directly withi
4141
| Setting | Description | Default |
4242
|---------|-------------|---------|
4343
| `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) |
44-
| `fastapi.codeLens.enabled` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` |
44+
| `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` |
4545
| `fastapi.cloud.enabled` | Enable FastAPI Cloud integration (status bar, deploy commands). | `true` |
4646
| `fastapi.telemetry.enabled` | Send anonymous usage data to help improve the extension. See [TELEMETRY.md](TELEMETRY.md) for details on what is collected. | `true` |
4747

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@
311311
"type": "boolean",
312312
"default": true,
313313
"scope": "resource",
314-
"description": "Show CodeLens links above test client calls (e.g., client.get('/items')) to navigate to the corresponding route definition."
314+
"description": "Show CodeLens links above test client calls to navigate to route definitions, and above route definitions to navigate to matching tests."
315315
},
316316
"fastapi.cloud.enabled": {
317317
"type": "boolean",

src/core/extractors.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,3 +638,59 @@ export function factoryCallExtractor(
638638
functionName: functionName,
639639
}
640640
}
641+
642+
export interface TestClientCall {
643+
method: string
644+
path: string
645+
line: number
646+
column: number
647+
}
648+
649+
export function findTestClientCalls(rootNode: Node): TestClientCall[] {
650+
const calls: TestClientCall[] = []
651+
const nodesByType = getNodesByType(rootNode)
652+
const callNodes = nodesByType.get("call") ?? []
653+
654+
for (const callNode of callNodes) {
655+
// Grammar guarantees: call nodes always have a function field
656+
const functionNode = callNode.childForFieldName("function")!
657+
if (functionNode.type !== "attribute") {
658+
continue
659+
}
660+
661+
// Grammar guarantees: attribute nodes always have an attribute field
662+
const methodNode = functionNode.childForFieldName("attribute")!
663+
664+
const method = methodNode.text.toLowerCase()
665+
if (!ROUTE_METHODS.has(method)) {
666+
continue
667+
}
668+
669+
// Grammar guarantees: call nodes always have an arguments field
670+
const argumentsNode = callNode.childForFieldName("arguments")!
671+
672+
const args = argumentsNode.namedChildren.filter(
673+
(child) => child.type !== "comment",
674+
)
675+
676+
if (args.length === 0) {
677+
continue
678+
}
679+
680+
const pathArg = resolveArgNode(args, 0, "url")
681+
682+
if (!pathArg) {
683+
continue
684+
}
685+
const path = extractPathFromNode(pathArg)
686+
687+
calls.push({
688+
method,
689+
path,
690+
line: callNode.startPosition.row,
691+
column: callNode.startPosition.column,
692+
})
693+
}
694+
695+
return calls
696+
}

src/extension.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ import {
3636
type PathOperationTreeItem,
3737
PathOperationTreeProvider,
3838
} from "./vscode/pathOperationTreeProvider"
39-
import { TestCodeLensProvider } from "./vscode/testCodeLensProvider"
39+
import { RouteToTestCodeLensProvider } from "./vscode/routeToTestCodeLensProvider"
40+
import { TestCallIndex } from "./vscode/testIndex"
41+
import { TestToRouteCodeLensProvider } from "./vscode/testToRouteCodeLensProvider"
4042

4143
export const EXTENSION_ID = "FastAPILabs.fastapi-vscode"
4244

@@ -155,17 +157,32 @@ export async function activate(context: vscode.ExtensionContext) {
155157
apps,
156158
groupApps(apps),
157159
)
158-
const codeLensProvider = new TestCodeLensProvider(parserService, apps)
160+
const testIndex = new TestCallIndex(parserService)
161+
testIndex.build().catch((e) => log(`TestCallIndex build failed: ${e}`))
162+
163+
const testToRouteProvider = new TestToRouteCodeLensProvider(
164+
parserService,
165+
apps,
166+
)
167+
const routeToTestProvider = new RouteToTestCodeLensProvider(apps, testIndex)
159168

160169
// File watcher for auto-refresh
161170
let refreshTimeout: ReturnType<typeof setTimeout> | null = null
162-
const triggerRefresh = () => {
171+
const triggerRefresh = (uri?: vscode.Uri) => {
163172
if (refreshTimeout) clearTimeout(refreshTimeout)
164173
refreshTimeout = setTimeout(async () => {
165174
if (!parserService) return
166175
const newApps = await discoverFastAPIApps(parserService)
176+
177+
if (uri) {
178+
await testIndex.invalidateFile(uri.toString())
179+
} else {
180+
await testIndex.build()
181+
}
182+
167183
pathOperationProvider.setApps(newApps, groupApps(newApps))
168-
codeLensProvider.setApps(newApps)
184+
testToRouteProvider.setApps(newApps)
185+
routeToTestProvider.setApps(newApps)
169186
}, 300)
170187
}
171188

@@ -176,7 +193,7 @@ export async function activate(context: vscode.ExtensionContext) {
176193

177194
// Re-discover when workspace folders change (handles late folder availability in browser)
178195
context.subscriptions.push(
179-
vscode.workspace.onDidChangeWorkspaceFolders(triggerRefresh),
196+
vscode.workspace.onDidChangeWorkspaceFolders(() => triggerRefresh()),
180197
)
181198

182199
// Tree view
@@ -196,7 +213,11 @@ export async function activate(context: vscode.ExtensionContext) {
196213
context.subscriptions.push(
197214
vscode.languages.registerCodeLensProvider(
198215
{ language: "python", pattern: "**/*test*.py" },
199-
codeLensProvider,
216+
testToRouteProvider,
217+
),
218+
vscode.languages.registerCodeLensProvider(
219+
{ language: "python", pattern: "**/*.py" },
220+
routeToTestProvider,
200221
),
201222
)
202223
}
@@ -306,7 +327,7 @@ export async function activate(context: vscode.ExtensionContext) {
306327
registerCommands(
307328
context.extensionUri,
308329
pathOperationProvider,
309-
codeLensProvider,
330+
testToRouteProvider,
310331
groupApps,
311332
),
312333
{ dispose: () => clearInterval(telemetryFlushInterval) },
@@ -387,7 +408,7 @@ function registerCloudCommands(
387408
function registerCommands(
388409
extensionUri: vscode.Uri,
389410
pathOperationProvider: PathOperationTreeProvider,
390-
codeLensProvider: TestCodeLensProvider,
411+
testToRouteProvider: TestToRouteCodeLensProvider,
391412
groupApps: (
392413
apps: AppDefinition[],
393414
) => Array<
@@ -403,7 +424,7 @@ function registerCommands(
403424
clearImportCache()
404425
const newApps = await discoverFastAPIApps(parserService)
405426
pathOperationProvider.setApps(newApps, groupApps(newApps))
406-
codeLensProvider.setApps(newApps)
427+
testToRouteProvider.setApps(newApps)
407428
},
408429
),
409430

src/test/core/extractors.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
decoratorExtractor,
66
extractPathFromNode,
77
extractStringValue,
8+
findTestClientCalls,
89
getNodesByType,
910
importExtractor,
1011
includeRouterExtractor,
@@ -971,6 +972,94 @@ FLAG = True
971972
})
972973
})
973974

975+
suite("findTestClientCalls", () => {
976+
test("extracts simple GET call", () => {
977+
const code = `client.get("/users")`
978+
const tree = parse(code)
979+
const calls = findTestClientCalls(tree.rootNode)
980+
981+
assert.strictEqual(calls.length, 1)
982+
assert.strictEqual(calls[0].method, "get")
983+
assert.strictEqual(calls[0].path, "/users")
984+
})
985+
986+
test("extracts POST call", () => {
987+
const code = `client.post("/items", json={"name": "test"})`
988+
const tree = parse(code)
989+
const calls = findTestClientCalls(tree.rootNode)
990+
991+
assert.strictEqual(calls.length, 1)
992+
assert.strictEqual(calls[0].method, "post")
993+
assert.strictEqual(calls[0].path, "/items")
994+
})
995+
996+
test("extracts url keyword argument", () => {
997+
const code = `client.get(url="/users")`
998+
const tree = parse(code)
999+
const calls = findTestClientCalls(tree.rootNode)
1000+
1001+
assert.strictEqual(calls.length, 1)
1002+
assert.strictEqual(calls[0].path, "/users")
1003+
})
1004+
1005+
test("extracts multiple calls", () => {
1006+
const code = `
1007+
client.get("/users")
1008+
client.post("/items")
1009+
client.delete("/items/1")
1010+
`
1011+
const tree = parse(code)
1012+
const calls = findTestClientCalls(tree.rootNode)
1013+
1014+
assert.strictEqual(calls.length, 3)
1015+
assert.strictEqual(calls[0].method, "get")
1016+
assert.strictEqual(calls[1].method, "post")
1017+
assert.strictEqual(calls[2].method, "delete")
1018+
})
1019+
1020+
test("ignores non-HTTP method calls", () => {
1021+
const code = `client.connect("/ws")`
1022+
const tree = parse(code)
1023+
const calls = findTestClientCalls(tree.rootNode)
1024+
1025+
assert.strictEqual(calls.length, 0)
1026+
})
1027+
1028+
test("ignores plain function calls", () => {
1029+
const code = `get("/users")`
1030+
const tree = parse(code)
1031+
const calls = findTestClientCalls(tree.rootNode)
1032+
1033+
assert.strictEqual(calls.length, 0)
1034+
})
1035+
1036+
test("ignores calls with no arguments", () => {
1037+
const code = "client.get()"
1038+
const tree = parse(code)
1039+
const calls = findTestClientCalls(tree.rootNode)
1040+
1041+
assert.strictEqual(calls.length, 0)
1042+
})
1043+
1044+
test("extracts f-string path", () => {
1045+
const code = `client.get(f"/users/{user_id}")`
1046+
const tree = parse(code)
1047+
const calls = findTestClientCalls(tree.rootNode)
1048+
1049+
assert.strictEqual(calls.length, 1)
1050+
assert.strictEqual(calls[0].path, "/users/{user_id}")
1051+
})
1052+
1053+
test("includes line and column", () => {
1054+
const code = `client.get("/users")`
1055+
const tree = parse(code)
1056+
const calls = findTestClientCalls(tree.rootNode)
1057+
1058+
assert.strictEqual(calls[0].line, 0)
1059+
assert.strictEqual(calls[0].column, 0)
1060+
})
1061+
})
1062+
9741063
suite("decoratorExtractor path handling", () => {
9751064
test("handles concatenated strings", () => {
9761065
const code = `

0 commit comments

Comments
 (0)