Skip to content

Commit 26d2325

Browse files
🐛 Fix CodeLens route matching and nested router discovery (#20)
1 parent 90d4d21 commit 26d2325

22 files changed

Lines changed: 474 additions & 31 deletions

src/core/extractors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export function extractStringValue(node: Node): string | null {
5959
* Extracts a path string from various AST node types.
6060
* Handles: plain strings, f-strings, concatenation, identifiers.
6161
*/
62-
function extractPathFromNode(node: Node): string {
62+
export function extractPathFromNode(node: Node): string {
6363
switch (node.type) {
6464
case "string":
6565
return extractStringValue(node) ?? ""

src/core/importResolver.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,18 @@ export async function resolveNamedImport(
157157
: modulePathToDir(importInfo, currentFileUri, projectRootUri, fs)
158158

159159
for (const name of importInfo.names) {
160-
// Try direct file: from .routes import users -> routes/users.py
161-
const namedUri = fs.joinPath(baseDirUri, ...name.split("."))
162-
const resolved = await resolvePythonModule(namedUri, fs)
163-
if (resolved) {
164-
return resolved
160+
// Only try submodule resolution if baseUri is a package (__init__.py) or namespace package (null).
161+
// For regular .py files, the name is a variable inside the file, not a submodule.
162+
// Example: "from .neon import router" where neon.py defines router
163+
// should resolve to neon.py, not look for router.py
164+
const isPackage = baseUri === null || baseUri.endsWith("__init__.py")
165+
if (isPackage) {
166+
// Try direct file: from .routes import users -> routes/users.py
167+
const namedUri = fs.joinPath(baseDirUri, ...name.split("."))
168+
const resolved = await resolvePythonModule(namedUri, fs)
169+
if (resolved) {
170+
return resolved
171+
}
165172
}
166173

167174
// Try re-exports: from .routes import users where routes/__init__.py re-exports users

src/core/pathUtils.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,33 +90,52 @@ export function countSegments(path: string): number {
9090

9191
/**
9292
* Checks if a test path matches an endpoint path pattern.
93-
* Endpoint paths may contain path parameters like {item_id} which match any segment.
93+
* Both paths may contain dynamic segments like {item_id} or {settings.API_V1_STR}
94+
* which match any segment.
95+
*
96+
* Leading dynamic prefixes (like {settings.API_V1_STR}) and query strings are stripped
97+
* before comparison.
9498
*
9599
* Examples:
96100
* pathMatchesEndpoint("/items/123", "/items/{item_id}") -> true
97101
* pathMatchesEndpoint("/items/123/details", "/items/{item_id}") -> false
98102
* pathMatchesEndpoint("/users/abc/posts/456", "/users/{user_id}/posts/{post_id}") -> true
99103
* pathMatchesEndpoint("/items/", "/items/{item_id}") -> false
104+
* pathMatchesEndpoint("{settings.API}/apps/{id}", "/apps/{app_id}") -> true
105+
* pathMatchesEndpoint("{BASE}/users/{id}", "/users/{user_id}") -> true
106+
* pathMatchesEndpoint("/teams/?owner=true", "/teams") -> true (query string stripped)
100107
*/
101108
export function pathMatchesEndpoint(
102109
testPath: string,
103110
endpointPath: string,
104111
): boolean {
105-
const testSegments = testPath.split("/").filter(Boolean)
106-
const endpointSegments = endpointPath.split("/").filter(Boolean)
112+
// Strip query string from test path (e.g., "/teams/?owner=true" -> "/teams/")
113+
const testPathWithoutQuery = testPath.split("?")[0]
114+
115+
// Strip leading dynamic segments (e.g., {settings.API_V1_STR}) for comparison
116+
const testSegments = stripLeadingDynamicSegments(testPathWithoutQuery)
117+
.split("/")
118+
.filter(Boolean)
119+
const endpointSegments = stripLeadingDynamicSegments(endpointPath)
120+
.split("/")
121+
.filter(Boolean)
107122

108123
// Segment counts must match
109124
if (testSegments.length !== endpointSegments.length) {
110125
return false
111126
}
112127

113-
return endpointSegments.every((seg, index) => {
114-
// Path parameter (e.g., {item_id}) matches any segment
115-
if (seg.startsWith("{") && seg.endsWith("}")) {
128+
// Compare each segment positionally
129+
return testSegments.every((testSeg, i) => {
130+
const endpointSeg = endpointSegments[i]
131+
// Dynamic segments (e.g., {id}, {app.id}) match any value
132+
const testIsDynamic = testSeg.startsWith("{") && testSeg.endsWith("}")
133+
const endpointIsDynamic =
134+
endpointSeg.startsWith("{") && endpointSeg.endsWith("}")
135+
if (testIsDynamic || endpointIsDynamic) {
116136
return true
117137
}
118-
// Literal segments must match exactly
119-
return seg === testSegments[index]
138+
return testSeg === endpointSeg
120139
})
121140
}
122141

src/core/routerResolver.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ async function resolveRouterReference(
218218
if (localRouter) {
219219
// Filter routes that belong to this router (decorated with @router.method)
220220
const routerRoutes = analysis.routes.filter((r) => r.owner === moduleName)
221-
return {
221+
const routerNode: RouterNode = {
222222
filePath: currentFileUri,
223223
variableName: localRouter.variableName,
224224
type: localRouter.type,
@@ -235,6 +235,39 @@ async function resolveRouterReference(
235235
})),
236236
children: [],
237237
}
238+
239+
// Process include_router calls owned by this router (nested routers)
240+
const routerIncludes = analysis.includeRouters.filter(
241+
(inc) => inc.owner === moduleName,
242+
)
243+
for (const include of routerIncludes) {
244+
log(
245+
`Resolving nested include_router: ${include.router} (owner: ${moduleName}, prefix: ${include.prefix || "none"})`,
246+
)
247+
const childRouter = await resolveRouterReference(
248+
include.router,
249+
analysis,
250+
currentFileUri,
251+
projectRootUri,
252+
parser,
253+
fs,
254+
visited,
255+
)
256+
if (childRouter) {
257+
if (include.tags.length > 0) {
258+
childRouter.tags = [
259+
...new Set([...childRouter.tags, ...include.tags]),
260+
]
261+
}
262+
routerNode.children.push({
263+
router: childRouter,
264+
prefix: include.prefix,
265+
tags: include.tags,
266+
})
267+
}
268+
}
269+
270+
return routerNode
238271
}
239272

240273
// Otherwise, look for an imported router
@@ -250,11 +283,19 @@ async function resolveRouterReference(
250283
// Helper to analyze a file with the filesystem
251284
const analyzeFileFn = (uri: string) => analyzeFile(uri, parser, fs)
252285

286+
// Find the original import name (in case moduleName is an alias)
287+
// e.g., "from .api_tokens import router as api_tokens_router"
288+
// moduleName = "api_tokens_router", originalName = "router"
289+
const namedImport = matchingImport.namedImports.find(
290+
(ni) => (ni.alias ?? ni.name) === moduleName,
291+
)
292+
const originalName = namedImport?.name ?? moduleName
293+
253294
// Resolve the imported module to a file URI
254295
const importedFileUri = await resolveNamedImport(
255296
{
256297
modulePath: matchingImport.modulePath,
257-
names: [moduleName],
298+
names: [originalName],
258299
isRelative: matchingImport.isRelative,
259300
relativeDots: matchingImport.relativeDots,
260301
},
@@ -293,7 +334,7 @@ async function resolveRouterReference(
293334
const routerRoutes = importedAnalysis.routes.filter(
294335
(r) => r.owner === attributeName,
295336
)
296-
return {
337+
const routerNode: RouterNode = {
297338
filePath: importedFileUri,
298339
variableName: targetRouter.variableName,
299340
type: targetRouter.type,
@@ -310,6 +351,39 @@ async function resolveRouterReference(
310351
})),
311352
children: [],
312353
}
354+
355+
// Process include_router calls owned by this router (nested routers)
356+
const routerIncludes = importedAnalysis.includeRouters.filter(
357+
(inc) => inc.owner === attributeName,
358+
)
359+
for (const include of routerIncludes) {
360+
log(
361+
`Resolving nested include_router: ${include.router} (owner: ${attributeName}, prefix: ${include.prefix || "none"})`,
362+
)
363+
const childRouter = await resolveRouterReference(
364+
include.router,
365+
importedAnalysis,
366+
importedFileUri,
367+
projectRootUri,
368+
parser,
369+
fs,
370+
visited,
371+
)
372+
if (childRouter) {
373+
if (include.tags.length > 0) {
374+
childRouter.tags = [
375+
...new Set([...childRouter.tags, ...include.tags]),
376+
]
377+
}
378+
routerNode.children.push({
379+
router: childRouter,
380+
prefix: include.prefix,
381+
tags: include.tags,
382+
})
383+
}
384+
}
385+
386+
return routerNode
313387
}
314388
// If not found as a router, fall through to try building from file
315389
}

src/providers/testCodeLensProvider.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
Uri,
1515
} from "vscode"
1616
import type { Node } from "web-tree-sitter"
17-
import { extractStringValue, findNodesByType } from "../core/extractors"
17+
import { extractPathFromNode, findNodesByType } from "../core/extractors"
1818
import { ROUTE_METHODS } from "../core/internal"
1919
import type { Parser } from "../core/parser"
2020
import {
@@ -132,9 +132,8 @@ export class TestCodeLensProvider implements CodeLensProvider {
132132
}
133133

134134
const pathArg = args[0]
135-
// Only handle string literals for now
136-
const path = extractStringValue(pathArg)
137-
if (path === null) {
135+
const path = extractPathFromNode(pathArg)
136+
if (!path) {
138137
continue
139138
}
140139

src/test/core/importResolver.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,5 +241,32 @@ suite("importResolver", () => {
241241
assert.ok(result)
242242
assert.ok(result.endsWith("api_routes.py"))
243243
})
244+
245+
test("resolves variable import from .py file (not submodule)", async () => {
246+
// This tests "from .neon import router" where router is a variable in neon.py,
247+
// NOT a submodule. Should return neon.py, not look for router.py
248+
const reexportRoot = fixtures.reexport.root
249+
const currentFile = nodeFileSystem.joinPath(
250+
reexportRoot,
251+
"app",
252+
"integrations",
253+
"router.py",
254+
)
255+
256+
const result = await resolveNamedImport(
257+
{
258+
modulePath: "neon",
259+
names: ["router"],
260+
isRelative: true,
261+
relativeDots: 1,
262+
},
263+
currentFile,
264+
reexportRoot,
265+
nodeFileSystem,
266+
)
267+
268+
assert.ok(result, "Should resolve import")
269+
assert.ok(result.endsWith("neon.py"), `Expected neon.py, got ${result}`)
270+
})
244271
})
245272
})

src/test/core/pathUtils.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,19 +271,49 @@ suite("pathUtils", () => {
271271
)
272272
})
273273

274-
test("matches paths with dynamic prefix", () => {
275-
// Dynamic prefixes like {settings.API_V1_STR} match any segment (same as path params)
274+
test("matches paths with dynamic prefix in endpoint", () => {
276275
assert.strictEqual(
277276
pathMatchesEndpoint(
278-
"/v1/items/123",
277+
"/items/123",
279278
"{settings.API_V1_STR}/items/{item_id}",
280279
),
281280
true,
282281
)
283282
assert.strictEqual(
284283
pathMatchesEndpoint("/api/v2/users", "{BASE}/users"),
285-
false, // segment count differs
284+
false,
285+
)
286+
assert.strictEqual(pathMatchesEndpoint("/users", "{BASE}/users"), true)
287+
assert.strictEqual(pathMatchesEndpoint("/items", "{BASE}/users"), false)
288+
})
289+
290+
test("matches paths with dynamic prefix in test path (f-strings)", () => {
291+
assert.strictEqual(
292+
pathMatchesEndpoint(
293+
"{settings.API_V1_STR}/apps/{app.id}/environment-variables",
294+
"/apps/{app_id}/environment-variables",
295+
),
296+
true,
297+
)
298+
assert.strictEqual(
299+
pathMatchesEndpoint(
300+
"{settings.API}/items/{item_id}",
301+
"{BASE}/items/{id}",
302+
),
303+
true,
304+
)
305+
assert.strictEqual(
306+
pathMatchesEndpoint("{BASE}/users/{id}", "/items/{item_id}"),
307+
false,
308+
)
309+
})
310+
311+
test("strips query strings from test path", () => {
312+
assert.strictEqual(
313+
pathMatchesEndpoint("/teams/?owner=true&order_by=created_at", "/teams"),
314+
true,
286315
)
316+
assert.strictEqual(pathMatchesEndpoint("/?page=1", "/"), true)
287317
})
288318
})
289319
})

0 commit comments

Comments
 (0)