Skip to content
Closed
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
2 changes: 1 addition & 1 deletion src/core/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function extractStringValue(node: Node): string | null {
* Extracts a path string from various AST node types.
* Handles: plain strings, f-strings, concatenation, identifiers.
*/
function extractPathFromNode(node: Node): string {
export function extractPathFromNode(node: Node): string {
switch (node.type) {
case "string":
return extractStringValue(node) ?? ""
Expand Down
35 changes: 27 additions & 8 deletions src/core/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,32 +80,51 @@ export function countSegments(path: string): number {

/**
* Checks if a test path matches an endpoint path pattern.
* Endpoint paths may contain path parameters like {item_id} which match any segment.
* Both paths may contain dynamic segments like {item_id} or {settings.API_V1_STR}
* which match any segment.
*
* Leading dynamic prefixes (like {settings.API_V1_STR}) and query strings are stripped
* before comparison.
*
* 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
* pathMatchesEndpoint("{settings.API}/apps/{id}", "/apps/{app_id}") -> true
* pathMatchesEndpoint("{BASE}/users/{id}", "/users/{user_id}") -> true
* pathMatchesEndpoint("/teams/?owner=true", "/teams") -> true (query string stripped)
*/
export function pathMatchesEndpoint(
testPath: string,
endpointPath: string,
): boolean {
const testSegments = testPath.split("/").filter(Boolean)
const endpointSegments = endpointPath.split("/").filter(Boolean)
// Strip query string from test path (e.g., "/teams/?owner=true" -> "/teams/")
const testPathWithoutQuery = testPath.split("?")[0]

// Strip leading dynamic segments (e.g., {settings.API_V1_STR}) for comparison
const testSegments = stripLeadingDynamicSegments(testPathWithoutQuery)
.split("/")
.filter(Boolean)
const endpointSegments = stripLeadingDynamicSegments(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("}")) {
// Compare each segment positionally
return testSegments.every((testSeg, i) => {
const endpointSeg = endpointSegments[i]
// Dynamic segments (e.g., {id}, {app.id}) match any value
const testIsDynamic = testSeg.startsWith("{") && testSeg.endsWith("}")
const endpointIsDynamic =
endpointSeg.startsWith("{") && endpointSeg.endsWith("}")
if (testIsDynamic || endpointIsDynamic) {
return true
}
// Literal segments must match exactly
return seg === testSegments[index]
return testSeg === endpointSeg
})
}
10 changes: 9 additions & 1 deletion src/core/routerResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,18 @@ function resolveRouterReference(
return null
}

// Find the original import name (in case moduleName is an alias)
// e.g., "from .api_tokens import router as api_tokens_router"
// moduleName = "api_tokens_router", originalName = "router"
const namedImport = matchingImport.namedImports.find(
(ni) => (ni.alias ?? ni.name) === moduleName,
)
const originalName = namedImport?.name ?? moduleName

const importedFilePath = resolveNamedImport(
{
modulePath: matchingImport.modulePath,
names: [moduleName],
names: [originalName],
isRelative: matchingImport.isRelative,
relativeDots: matchingImport.relativeDots,
},
Expand Down
7 changes: 3 additions & 4 deletions src/providers/TestCodeLensProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
Uri,
} from "vscode"
import type { Node } from "web-tree-sitter"
import { extractStringValue, findNodesByType } from "../core/extractors"
import { extractPathFromNode, findNodesByType } from "../core/extractors"
import { ROUTE_METHODS } from "../core/internal"
import type { Parser } from "../core/parser"
import {
Expand Down Expand Up @@ -132,9 +132,8 @@ export class TestCodeLensProvider implements CodeLensProvider {
}

const pathArg = args[0]
// Only handle string literals for now
const path = extractStringValue(pathArg)
if (path === null) {
const path = extractPathFromNode(pathArg)
if (!path) {
continue
}

Expand Down
38 changes: 34 additions & 4 deletions src/test/core/pathUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,19 +239,49 @@ suite("pathUtils", () => {
)
})

test("matches paths with dynamic prefix", () => {
// Dynamic prefixes like {settings.API_V1_STR} match any segment (same as path params)
test("matches paths with dynamic prefix in endpoint", () => {
assert.strictEqual(
pathMatchesEndpoint(
"/v1/items/123",
"/items/123",
"{settings.API_V1_STR}/items/{item_id}",
),
true,
)
assert.strictEqual(
pathMatchesEndpoint("/api/v2/users", "{BASE}/users"),
false, // segment count differs
false,
)
assert.strictEqual(pathMatchesEndpoint("/users", "{BASE}/users"), true)
assert.strictEqual(pathMatchesEndpoint("/items", "{BASE}/users"), false)
})

test("matches paths with dynamic prefix in test path (f-strings)", () => {
assert.strictEqual(
pathMatchesEndpoint(
"{settings.API_V1_STR}/apps/{app.id}/environment-variables",
"/apps/{app_id}/environment-variables",
),
true,
)
assert.strictEqual(
pathMatchesEndpoint(
"{settings.API}/items/{item_id}",
"{BASE}/items/{id}",
),
true,
)
assert.strictEqual(
pathMatchesEndpoint("{BASE}/users/{id}", "/items/{item_id}"),
false,
)
})

test("strips query strings from test path", () => {
assert.strictEqual(
pathMatchesEndpoint("/teams/?owner=true&order_by=created_at", "/teams"),
true,
)
assert.strictEqual(pathMatchesEndpoint("/?page=1", "/"), true)
})
})
})
32 changes: 32 additions & 0 deletions src/test/core/routerResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,5 +281,37 @@ suite("routerResolver", () => {

assert.strictEqual(result, null)
})

test("resolves aliased import (from .tokens import router as tokens_router)", () => {
const result = buildRouterGraph(
fixtures.aliasedImport.mainPy,
parser,
fixtures.aliasedImport.root,
)

assert.ok(result)
assert.strictEqual(result.type, "FastAPI")
assert.strictEqual(result.variableName, "app")

assert.strictEqual(
result.children.length,
1,
"Should have one child router",
)

const tokensRouter = result.children[0]
assert.strictEqual(tokensRouter.router.type, "APIRouter")
assert.strictEqual(tokensRouter.router.prefix, "/tokens")

assert.ok(
tokensRouter.router.routes.length >= 3,
`Expected at least 3 routes, got ${tokensRouter.router.routes.length}`,
)

assert.ok(
tokensRouter.router.filePath.endsWith("tokens.py"),
`Expected filePath to end with tokens.py, got ${tokensRouter.router.filePath}`,
)
})
})
})
Empty file.
12 changes: 12 additions & 0 deletions src/test/fixtures/aliased-import/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from fastapi import FastAPI

from .routes.tokens import router as tokens_router

app = FastAPI(title="Aliased Import Test")

app.include_router(tokens_router)


@app.get("/")
def root():
return {"message": "Hello"}
Empty file.
18 changes: 18 additions & 0 deletions src/test/fixtures/aliased-import/app/routes/tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from fastapi import APIRouter

router = APIRouter(prefix="/tokens", tags=["tokens"])


@router.get("/")
def list_tokens():
return []


@router.post("/")
def create_token():
return {"id": 1}


@router.delete("/{token_id}")
def delete_token(token_id: int):
return {"deleted": token_id}
11 changes: 11 additions & 0 deletions src/test/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,15 @@ export const fixtures = {
root: join(fixturesPath, "multi-app"),
mainPy: join(fixturesPath, "multi-app", "main.py"),
},
aliasedImport: {
root: join(fixturesPath, "aliased-import"),
mainPy: join(fixturesPath, "aliased-import", "app", "main.py"),
tokensPy: join(
fixturesPath,
"aliased-import",
"app",
"routes",
"tokens.py",
),
},
}