@@ -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"] */
0 commit comments