Skip to content

Commit 5195718

Browse files
🐛 Resolve string variables in route paths (#66)
1 parent 33500e6 commit 5195718

5 files changed

Lines changed: 179 additions & 2 deletions

File tree

src/core/analyzer.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import type { Tree } from "web-tree-sitter"
66
import { logError } from "../utils/logger"
77
import {
8+
collectStringVariables,
89
decoratorExtractor,
910
findNodesByType,
1011
importExtractor,
@@ -20,6 +21,16 @@ function notNull<T>(value: T | null): value is T {
2021
return value !== null
2122
}
2223

24+
function resolveVariables(
25+
path: string,
26+
variables: Map<string, string>,
27+
): string {
28+
return path.replace(
29+
/\{([^}]+)\}/g,
30+
(match, name) => variables.get(name) ?? match,
31+
)
32+
}
33+
2334
/** Analyze a syntax tree and extract FastAPI-related information */
2435
export function analyzeTree(tree: Tree, filePath: string): FileAnalysis {
2536
const rootNode = tree.rootNode
@@ -44,7 +55,29 @@ export function analyzeTree(tree: Tree, filePath: string): FileAnalysis {
4455
.map(importExtractor)
4556
.filter(notNull)
4657

47-
return { filePath, routes, routers, includeRouters, mounts, imports }
58+
const stringVariables = collectStringVariables(rootNode)
59+
60+
for (const route of routes) {
61+
route.path = resolveVariables(route.path, stringVariables)
62+
}
63+
for (const router of routers) {
64+
router.prefix = resolveVariables(router.prefix, stringVariables)
65+
}
66+
for (const ir of includeRouters) {
67+
ir.prefix = resolveVariables(ir.prefix, stringVariables)
68+
}
69+
for (const mount of mounts) {
70+
mount.path = resolveVariables(mount.path, stringVariables)
71+
}
72+
73+
return {
74+
filePath,
75+
routes,
76+
routers,
77+
includeRouters,
78+
mounts,
79+
imports,
80+
}
4881
}
4982

5083
/** Analyze a file given its URI string and a parser instance */

src/core/extractors.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,39 @@ function collectNodesByType(node: Node, type: string, results: Node[]): void {
3333
}
3434
}
3535

36+
/**
37+
* Collects string variable assignments from the AST for path resolution.
38+
* Only resolves simple assignments (e.g. `WEBHOOK_PATH = "/webhook"`).
39+
*
40+
* Examples:
41+
* WEBHOOK_PATH = "/webhook" -> Map { "WEBHOOK_PATH" => "/webhook" }
42+
* BASE = "/api" -> Map { "BASE" => "/api" }
43+
* settings.PREFIX = "/api" -> (skipped, not a simple identifier)
44+
*/
45+
export function collectStringVariables(rootNode: Node): Map<string, string> {
46+
const variables = new Map<string, string>()
47+
const assignmentNodes = findNodesByType(rootNode, "assignment")
48+
49+
for (const assign of assignmentNodes) {
50+
const left = assign.childForFieldName("left")
51+
const right = assign.childForFieldName("right")
52+
if (
53+
left &&
54+
right &&
55+
left.type === "identifier" &&
56+
right.type === "string"
57+
) {
58+
const varName = left.text
59+
const value = extractStringValue(right)
60+
if (value !== null) {
61+
variables.set(varName, value)
62+
}
63+
}
64+
}
65+
66+
return variables
67+
}
68+
3669
/**
3770
* Extracts the string value from a string AST node, handling quotes and f-string prefix.
3871
* Returns null if the node is not a string.

src/test/core/analyzer.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,88 @@ import os
100100
assert.strictEqual(routesImport.isRelative, true)
101101
})
102102

103+
test("resolves same-file string variables in route paths", () => {
104+
const code = `
105+
from fastapi import FastAPI
106+
107+
app = FastAPI()
108+
109+
WEBHOOK_PATH = "/webhook"
110+
111+
@app.post(WEBHOOK_PATH)
112+
def some_webhook():
113+
pass
114+
`
115+
const tree = parse(code)
116+
const result = analyzeTree(tree, "/test/file.py")
117+
118+
assert.strictEqual(result.routes.length, 1)
119+
assert.strictEqual(result.routes[0].path, "/webhook")
120+
})
121+
122+
test("resolves variable used in path concatenation", () => {
123+
const code = `
124+
from fastapi import FastAPI
125+
126+
app = FastAPI()
127+
128+
BASE = "/api"
129+
130+
@app.get(BASE + "/users")
131+
def list_users():
132+
pass
133+
`
134+
const tree = parse(code)
135+
const result = analyzeTree(tree, "/test/file.py")
136+
137+
assert.strictEqual(result.routes.length, 1)
138+
assert.strictEqual(result.routes[0].path, "/api/users")
139+
})
140+
141+
test("leaves unresolvable variables wrapped", () => {
142+
const code = `
143+
from fastapi import FastAPI
144+
145+
app = FastAPI()
146+
147+
@app.get(settings.API_PREFIX)
148+
def handler():
149+
pass
150+
`
151+
const tree = parse(code)
152+
const result = analyzeTree(tree, "/test/file.py")
153+
154+
assert.strictEqual(result.routes.length, 1)
155+
assert.strictEqual(result.routes[0].path, "{settings.API_PREFIX}")
156+
})
157+
158+
test("resolves variable in router prefix", () => {
159+
const code = `
160+
from fastapi import APIRouter
161+
162+
PREFIX = "/users"
163+
router = APIRouter(prefix=PREFIX)
164+
`
165+
const tree = parse(code)
166+
const result = analyzeTree(tree, "/test/file.py")
167+
168+
const apiRouter = result.routers.find((r) => r.type === "APIRouter")
169+
assert.ok(apiRouter)
170+
assert.strictEqual(apiRouter.prefix, "/users")
171+
})
172+
173+
test("resolves variable in include_router prefix", () => {
174+
const code = `
175+
USERS_PREFIX = "/users"
176+
app.include_router(users.router, prefix=USERS_PREFIX)
177+
`
178+
const tree = parse(code)
179+
const result = analyzeTree(tree, "/test/file.py")
180+
181+
assert.strictEqual(result.includeRouters.length, 1)
182+
assert.strictEqual(result.includeRouters[0].prefix, "/users")
183+
})
184+
103185
test("sets filePath correctly", () => {
104186
const code = "x = 1"
105187
const tree = parse(code)

src/test/providers/pathOperationTreeProvider.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,34 @@ suite("PathOperationTreeProvider", () => {
334334
}
335335
})
336336

337+
test("route tooltip strips dynamic prefix", () => {
338+
const app = makeApp("app", "main.py")
339+
app.routes = [
340+
{
341+
method: "GET",
342+
path: "{settings.API_V1_STR}/users/{user_id}",
343+
functionName: "get_user",
344+
location: { filePath: "users.py", line: 10, column: 0 },
345+
},
346+
]
347+
const p = new PathOperationTreeProvider([app])
348+
const appItem = p.getChildren()[0]
349+
const route = p.getChildren(appItem).find((c) => c.type === "route")!
350+
const treeItem = p.getTreeItem(route)
351+
const tooltipValue =
352+
typeof treeItem.tooltip === "string"
353+
? treeItem.tooltip
354+
: (treeItem.tooltip as { value: string }).value
355+
assert.ok(
356+
tooltipValue.includes("GET /users/{user_id}"),
357+
"Tooltip should show stripped path",
358+
)
359+
assert.ok(
360+
!tooltipValue.includes("settings.API_V1_STR"),
361+
"Tooltip should not include dynamic prefix",
362+
)
363+
})
364+
337365
test("getChildren returns message when no apps", () => {
338366
const emptyProvider = new PathOperationTreeProvider([])
339367
const roots = emptyProvider.getChildren()

src/vscode/pathOperationTreeProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,9 @@ export class PathOperationTreeProvider
268268
routeItem.description = element.route.functionName
269269
routeItem.iconPath = new ThemeIcon(METHOD_ICONS[element.route.method])
270270
routeItem.contextValue = "route"
271+
const tooltipPath = stripLeadingDynamicSegments(element.route.path)
271272
routeItem.tooltip = new MarkdownString(
272-
`${element.route.method} ${element.route.path}\n\nFunction: ${element.route.functionName}\nFile: ${element.route.location.filePath}:${element.route.location.line}`,
273+
`${element.route.method} ${tooltipPath}\n\nFunction: ${element.route.functionName}\nFile: ${element.route.location.filePath}:${element.route.location.line}`,
273274
)
274275
routeItem.command = {
275276
command: "fastapi-vscode.goToPathOperation",

0 commit comments

Comments
 (0)