Skip to content

Commit 3f2df7f

Browse files
πŸ› Support stacked decorators and deprecated route warnings (#121)
1 parent a5e5781 commit 3f2df7f

File tree

9 files changed

+277
-125
lines changed

9 files changed

+277
-125
lines changed

β€Žsrc/core/analyzer.tsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function analyzeTree(tree: Tree, filePath: string): FileAnalysis {
4242

4343
// Get all decorated definitions (functions and classes with decorators)
4444
const decoratedDefs = nodesByType.get("decorated_definition") ?? []
45-
const routes = decoratedDefs.map(decoratorExtractor).filter(notNull)
45+
const routes = decoratedDefs.flatMap(decoratorExtractor)
4646

4747
// Get all router assignments
4848
const assignments = nodesByType.get("assignment") ?? []

β€Žsrc/core/extractors.tsβ€Ž

Lines changed: 73 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -167,66 +167,14 @@ export function extractPathFromNode(node: Node): string {
167167

168168
/**
169169
* Extracts from route decorators like @app.get("/path"), @router.post("/path"), etc.
170+
* Handles stacked decorators β€” returns one RouteInfo per route decorator found.
170171
*/
171-
export function decoratorExtractor(node: Node): RouteInfo | null {
172+
export function decoratorExtractor(node: Node): RouteInfo[] {
172173
if (node.type !== "decorated_definition") {
173-
return null
174-
}
175-
176-
// Grammar guarantees: decorated_definition always has a first child (the decorator)
177-
const decoratorNode = node.firstNamedChild!
178-
179-
const callNode =
180-
decoratorNode.firstNamedChild?.type === "call"
181-
? decoratorNode.firstNamedChild
182-
: null
183-
184-
const functionNode = callNode?.childForFieldName("function")
185-
const argumentsNode = callNode?.childForFieldName("arguments")
186-
const objectNode = functionNode?.childForFieldName("object")
187-
const methodNode = functionNode?.childForFieldName("attribute")
188-
189-
if (!objectNode || !methodNode || !argumentsNode) {
190-
return null
191-
}
192-
193-
// Filter out non-route decorators (exception_handler, middleware, on_event)
194-
const method = methodNode.text.toLowerCase()
195-
const isApiRoute = method === "api_route"
196-
if (!ROUTE_METHODS.has(method) && !isApiRoute) {
197-
return null
174+
return []
198175
}
199176

200-
// Find path: first positional arg, or "path" keyword argument
201-
const nonCommentArgs = argumentsNode.namedChildren.filter(
202-
(child) => child.type !== "comment",
203-
)
204-
const pathArgNode = resolveArgNode(nonCommentArgs, 0, "path")
205-
const path = pathArgNode ? extractPathFromNode(pathArgNode) : ""
206-
207-
// For api_route, extract methods from keyword argument
208-
let resolvedMethod = methodNode.text
209-
if (isApiRoute) {
210-
// Default to GET if no methods specified
211-
resolvedMethod = "GET"
212-
for (const argNode of argumentsNode.namedChildren) {
213-
if (argNode.type === "keyword_argument") {
214-
const nameNode = argNode.childForFieldName("name")
215-
const valueNode = argNode.childForFieldName("value")
216-
if (nameNode?.text === "methods" && valueNode) {
217-
// Extract first method from list
218-
const listItems = valueNode.namedChildren
219-
const firstMethod =
220-
listItems.length > 0 ? extractStringValue(listItems[0]) : null
221-
if (firstMethod) {
222-
resolvedMethod = firstMethod
223-
}
224-
}
225-
}
226-
}
227-
}
228-
229-
// Grammar guarantees: decorated_definition always has a definition field with a name
177+
// Shared across all stacked decorators: function name and docstring
230178
const functionDefNode = node.childForFieldName("definition")!
231179
const functionName = functionDefNode.childForFieldName("name")?.text ?? ""
232180
const functionBody = functionDefNode.childForFieldName("body")
@@ -238,15 +186,76 @@ export function decoratorExtractor(node: Node): RouteInfo | null {
238186
docstring = stripDocstring(expr.text)
239187
}
240188
}
241-
return {
242-
owner: objectNode.text,
243-
method: resolvedMethod,
244-
path,
245-
function: functionName,
246-
line: node.startPosition.row + 1,
247-
column: node.startPosition.column,
248-
docstring,
189+
190+
const routes: RouteInfo[] = []
191+
192+
for (const decoratorNode of node.namedChildren) {
193+
if (decoratorNode.type !== "decorator") {
194+
continue
195+
}
196+
197+
const callNode =
198+
decoratorNode.firstNamedChild?.type === "call"
199+
? decoratorNode.firstNamedChild
200+
: null
201+
202+
const functionNode = callNode?.childForFieldName("function")
203+
const argumentsNode = callNode?.childForFieldName("arguments")
204+
const objectNode = functionNode?.childForFieldName("object")
205+
const methodNode = functionNode?.childForFieldName("attribute")
206+
207+
if (!objectNode || !methodNode || !argumentsNode) {
208+
continue
209+
}
210+
211+
// Filter out non-route decorators (exception_handler, middleware, on_event)
212+
const method = methodNode.text.toLowerCase()
213+
const isApiRoute = method === "api_route"
214+
if (!ROUTE_METHODS.has(method) && !isApiRoute) {
215+
continue
216+
}
217+
218+
// Find path: first positional arg, or "path" keyword argument
219+
const nonCommentArgs = argumentsNode.namedChildren.filter(
220+
(child) => child.type !== "comment",
221+
)
222+
const pathArgNode = resolveArgNode(nonCommentArgs, 0, "path")
223+
const path = pathArgNode ? extractPathFromNode(pathArgNode) : ""
224+
225+
let deprecated: boolean | undefined
226+
let resolvedMethod = methodNode.text
227+
if (isApiRoute) resolvedMethod = "GET"
228+
229+
for (const argNode of argumentsNode.namedChildren) {
230+
if (argNode.type !== "keyword_argument") continue
231+
const nameNode = argNode.childForFieldName("name")
232+
const valueNode = argNode.childForFieldName("value")
233+
if (nameNode?.text === "deprecated" && valueNode?.text === "True") {
234+
deprecated = true
235+
}
236+
if (isApiRoute && nameNode?.text === "methods" && valueNode) {
237+
// Extract first method from list
238+
const firstMethod =
239+
valueNode.namedChildren.length > 0
240+
? extractStringValue(valueNode.namedChildren[0])
241+
: null
242+
if (firstMethod) resolvedMethod = firstMethod
243+
}
244+
}
245+
246+
routes.push({
247+
owner: objectNode.text,
248+
method: resolvedMethod,
249+
path,
250+
function: functionName,
251+
line: node.startPosition.row + 1,
252+
column: node.startPosition.column,
253+
docstring,
254+
deprecated,
255+
})
249256
}
257+
258+
return routes
250259
}
251260

252261
/** Extracts tags from a list node like ["users", "admin"] */

β€Žsrc/core/internal.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface RouteInfo {
3636
line: number
3737
column: number
3838
docstring?: string
39+
deprecated?: boolean
3940
}
4041

4142
export type RouterType = "APIRouter" | "FastAPI" | "Unknown"

β€Žsrc/core/routerResolver.tsβ€Ž

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,7 @@ function createRouterNode(
5050
tags: router.tags,
5151
line: router.line,
5252
column: router.column,
53-
routes: routes.map((r) => ({
54-
method: r.method,
55-
path: r.path,
56-
function: r.function,
57-
line: r.line,
58-
column: r.column,
59-
docstring: r.docstring,
60-
})),
53+
routes: routes.map(({ owner: _, ...rest }) => rest),
6154
children: [],
6255
}
6356
}

β€Žsrc/core/transformer.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ function toRouteDefinition(
2121
path: prefix + route.path,
2222
functionName: route.function,
2323
docstring: route.docstring,
24+
deprecated: route.deprecated,
2425
location: {
2526
filePath,
2627
line: route.line,

β€Žsrc/core/types.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface RouteDefinition {
2525
functionName: string
2626
location: SourceLocation
2727
docstring?: string
28+
deprecated?: boolean
2829
}
2930

3031
export interface RouterDefinition {

0 commit comments

Comments
Β (0)