Skip to content
Merged
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/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function analyzeTree(tree: Tree, filePath: string): FileAnalysis {

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

// Get all router assignments
const assignments = nodesByType.get("assignment") ?? []
Expand Down
137 changes: 73 additions & 64 deletions src/core/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,66 +167,14 @@ export function extractPathFromNode(node: Node): string {

/**
* Extracts from route decorators like @app.get("/path"), @router.post("/path"), etc.
* Handles stacked decorators — returns one RouteInfo per route decorator found.
*/
export function decoratorExtractor(node: Node): RouteInfo | null {
export function decoratorExtractor(node: Node): RouteInfo[] {
if (node.type !== "decorated_definition") {
return null
}

// Grammar guarantees: decorated_definition always has a first child (the decorator)
const decoratorNode = node.firstNamedChild!

const callNode =
decoratorNode.firstNamedChild?.type === "call"
? decoratorNode.firstNamedChild
: null

const functionNode = callNode?.childForFieldName("function")
const argumentsNode = callNode?.childForFieldName("arguments")
const objectNode = functionNode?.childForFieldName("object")
const methodNode = functionNode?.childForFieldName("attribute")

if (!objectNode || !methodNode || !argumentsNode) {
return null
}

// Filter out non-route decorators (exception_handler, middleware, on_event)
const method = methodNode.text.toLowerCase()
const isApiRoute = method === "api_route"
if (!ROUTE_METHODS.has(method) && !isApiRoute) {
return null
return []
}

// Find path: first positional arg, or "path" keyword argument
const nonCommentArgs = argumentsNode.namedChildren.filter(
(child) => child.type !== "comment",
)
const pathArgNode = resolveArgNode(nonCommentArgs, 0, "path")
const path = pathArgNode ? extractPathFromNode(pathArgNode) : ""

// For api_route, extract methods from keyword argument
let resolvedMethod = methodNode.text
if (isApiRoute) {
// Default to GET if no methods specified
resolvedMethod = "GET"
for (const argNode of argumentsNode.namedChildren) {
if (argNode.type === "keyword_argument") {
const nameNode = argNode.childForFieldName("name")
const valueNode = argNode.childForFieldName("value")
if (nameNode?.text === "methods" && valueNode) {
// Extract first method from list
const listItems = valueNode.namedChildren
const firstMethod =
listItems.length > 0 ? extractStringValue(listItems[0]) : null
if (firstMethod) {
resolvedMethod = firstMethod
}
}
}
}
}

// Grammar guarantees: decorated_definition always has a definition field with a name
// Shared across all stacked decorators: function name and docstring
const functionDefNode = node.childForFieldName("definition")!
const functionName = functionDefNode.childForFieldName("name")?.text ?? ""
const functionBody = functionDefNode.childForFieldName("body")
Expand All @@ -238,15 +186,76 @@ export function decoratorExtractor(node: Node): RouteInfo | null {
docstring = stripDocstring(expr.text)
}
}
return {
owner: objectNode.text,
method: resolvedMethod,
path,
function: functionName,
line: node.startPosition.row + 1,
column: node.startPosition.column,
docstring,

const routes: RouteInfo[] = []

for (const decoratorNode of node.namedChildren) {
if (decoratorNode.type !== "decorator") {
continue
}

const callNode =
decoratorNode.firstNamedChild?.type === "call"
? decoratorNode.firstNamedChild
: null

const functionNode = callNode?.childForFieldName("function")
const argumentsNode = callNode?.childForFieldName("arguments")
const objectNode = functionNode?.childForFieldName("object")
const methodNode = functionNode?.childForFieldName("attribute")

if (!objectNode || !methodNode || !argumentsNode) {
continue
}

// Filter out non-route decorators (exception_handler, middleware, on_event)
const method = methodNode.text.toLowerCase()
const isApiRoute = method === "api_route"
if (!ROUTE_METHODS.has(method) && !isApiRoute) {
continue
}

// Find path: first positional arg, or "path" keyword argument
const nonCommentArgs = argumentsNode.namedChildren.filter(
(child) => child.type !== "comment",
)
const pathArgNode = resolveArgNode(nonCommentArgs, 0, "path")
const path = pathArgNode ? extractPathFromNode(pathArgNode) : ""

let deprecated: boolean | undefined
let resolvedMethod = methodNode.text
if (isApiRoute) resolvedMethod = "GET"

for (const argNode of argumentsNode.namedChildren) {
if (argNode.type !== "keyword_argument") continue
const nameNode = argNode.childForFieldName("name")
const valueNode = argNode.childForFieldName("value")
if (nameNode?.text === "deprecated" && valueNode?.text === "True") {
deprecated = true
}
if (isApiRoute && nameNode?.text === "methods" && valueNode) {
// Extract first method from list
const firstMethod =
valueNode.namedChildren.length > 0
? extractStringValue(valueNode.namedChildren[0])
: null
if (firstMethod) resolvedMethod = firstMethod
}
}

routes.push({
owner: objectNode.text,
method: resolvedMethod,
path,
function: functionName,
line: node.startPosition.row + 1,
column: node.startPosition.column,
docstring,
deprecated,
})
}

return routes
}

/** Extracts tags from a list node like ["users", "admin"] */
Expand Down
1 change: 1 addition & 0 deletions src/core/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface RouteInfo {
line: number
column: number
docstring?: string
deprecated?: boolean
}

export type RouterType = "APIRouter" | "FastAPI" | "Unknown"
Expand Down
9 changes: 1 addition & 8 deletions src/core/routerResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,7 @@ function createRouterNode(
tags: router.tags,
line: router.line,
column: router.column,
routes: routes.map((r) => ({
method: r.method,
path: r.path,
function: r.function,
line: r.line,
column: r.column,
docstring: r.docstring,
})),
routes: routes.map(({ owner: _, ...rest }) => rest),
children: [],
}
}
Expand Down
1 change: 1 addition & 0 deletions src/core/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function toRouteDefinition(
path: prefix + route.path,
functionName: route.function,
docstring: route.docstring,
deprecated: route.deprecated,
location: {
filePath,
line: route.line,
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface RouteDefinition {
functionName: string
location: SourceLocation
docstring?: string
deprecated?: boolean
}

export interface RouterDefinition {
Expand Down
Loading
Loading