diff --git a/src/core/extractors.ts b/src/core/extractors.ts index 8bcfd8e..0120eb4 100644 --- a/src/core/extractors.ts +++ b/src/core/extractors.ts @@ -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) ?? "" diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts index 5e73223..b69d94a 100644 --- a/src/core/pathUtils.ts +++ b/src/core/pathUtils.ts @@ -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 }) } diff --git a/src/core/routerResolver.ts b/src/core/routerResolver.ts index e92ca9a..6ef7b08 100644 --- a/src/core/routerResolver.ts +++ b/src/core/routerResolver.ts @@ -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, }, diff --git a/src/providers/TestCodeLensProvider.ts b/src/providers/TestCodeLensProvider.ts index 8ef2ccd..5f68722 100644 --- a/src/providers/TestCodeLensProvider.ts +++ b/src/providers/TestCodeLensProvider.ts @@ -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 { @@ -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 } diff --git a/src/test/core/pathUtils.test.ts b/src/test/core/pathUtils.test.ts index 6d8b9f4..6da9016 100644 --- a/src/test/core/pathUtils.test.ts +++ b/src/test/core/pathUtils.test.ts @@ -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) }) }) }) diff --git a/src/test/core/routerResolver.test.ts b/src/test/core/routerResolver.test.ts index b062971..b8c439c 100644 --- a/src/test/core/routerResolver.test.ts +++ b/src/test/core/routerResolver.test.ts @@ -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}`, + ) + }) }) }) diff --git a/src/test/fixtures/aliased-import/app/__init__.py b/src/test/fixtures/aliased-import/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/fixtures/aliased-import/app/main.py b/src/test/fixtures/aliased-import/app/main.py new file mode 100644 index 0000000..4454be7 --- /dev/null +++ b/src/test/fixtures/aliased-import/app/main.py @@ -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"} diff --git a/src/test/fixtures/aliased-import/app/routes/__init__.py b/src/test/fixtures/aliased-import/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/fixtures/aliased-import/app/routes/tokens.py b/src/test/fixtures/aliased-import/app/routes/tokens.py new file mode 100644 index 0000000..a5e1fa2 --- /dev/null +++ b/src/test/fixtures/aliased-import/app/routes/tokens.py @@ -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} diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 4c1afc9..034ca8b 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -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", + ), + }, }