Skip to content

Commit 14f8429

Browse files
✨ Display docstrings on hover in path operations panel (#67)
1 parent 5195718 commit 14f8429

8 files changed

Lines changed: 132 additions & 3 deletions

File tree

src/core/extractors.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,34 @@ export function findNodesByType(node: Node, type: string): Node[] {
2121
return results
2222
}
2323

24+
function stripDocstring(raw: string): string {
25+
let content: string
26+
if (
27+
(raw.startsWith('"""') && raw.endsWith('"""')) ||
28+
(raw.startsWith("'''") && raw.endsWith("'''"))
29+
) {
30+
content = raw.slice(3, -3)
31+
} else {
32+
content = raw.slice(1, -1)
33+
}
34+
35+
// Dedent: strip common leading whitespace (like Python's textwrap.dedent)
36+
const lines = content.split("\n")
37+
// First line is either empty or unindented (follows opening quotes), so skip it
38+
const indentedLines = lines.slice(1).filter((l) => l.trim().length > 0)
39+
if (indentedLines.length === 0) {
40+
return content.trim()
41+
}
42+
43+
// Find minimum indentation of all non-empty lines (except first) so we can
44+
// remove it from all lines, preserving relative indentation
45+
const minIndent = Math.min(
46+
...indentedLines.map((l) => l.length - l.trimStart().length),
47+
)
48+
const dedented = lines.map((l, i) => (i === 0 ? l : l.slice(minIndent)))
49+
return dedented.join("\n").trim()
50+
}
51+
2452
function collectNodesByType(node: Node, type: string, results: Node[]): void {
2553
if (node.type === type) {
2654
results.push(node)
@@ -179,14 +207,23 @@ export function decoratorExtractor(node: Node): RouteInfo | null {
179207
// Grammar guarantees: decorated_definition always has a definition field with a name
180208
const functionDefNode = node.childForFieldName("definition")!
181209
const functionName = functionDefNode.childForFieldName("name")?.text ?? ""
182-
210+
const functionBody = functionDefNode.childForFieldName("body")
211+
const firstStatement = functionBody?.namedChildren[0]
212+
let docstring: string | undefined
213+
if (firstStatement?.type === "expression_statement") {
214+
const expr = firstStatement.firstNamedChild
215+
if (expr?.type === "string") {
216+
docstring = stripDocstring(expr.text)
217+
}
218+
}
183219
return {
184220
owner: objectNode.text,
185221
method: resolvedMethod,
186222
path,
187223
function: functionName,
188224
line: node.startPosition.row + 1,
189225
column: node.startPosition.column,
226+
docstring,
190227
}
191228
}
192229

src/core/internal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface RouteInfo {
3535
function: string
3636
line: number
3737
column: number
38+
docstring?: string
3839
}
3940

4041
export type RouterType = "APIRouter" | "FastAPI" | "Unknown"
@@ -99,6 +100,7 @@ export interface RouterNode {
99100
function: string
100101
line: number
101102
column: number
103+
docstring?: string
102104
}[]
103105
children: { router: RouterNode; prefix: string; tags: string[] }[]
104106
}

src/core/routerResolver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ async function buildRouterGraphInternal(
136136
function: r.function,
137137
line: r.line,
138138
column: r.column,
139+
docstring: r.docstring,
139140
})),
140141
children: [],
141142
}
@@ -232,6 +233,7 @@ async function resolveRouterReference(
232233
function: r.function,
233234
line: r.line,
234235
column: r.column,
236+
docstring: r.docstring,
235237
})),
236238
children: [],
237239
}
@@ -348,6 +350,7 @@ async function resolveRouterReference(
348350
function: r.function,
349351
line: r.line,
350352
column: r.column,
353+
docstring: r.docstring,
351354
})),
352355
children: [],
353356
}

src/core/transformer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function toRouteDefinition(
2020
method: normalizeMethod(route.method),
2121
path: prefix + route.path,
2222
functionName: route.function,
23+
docstring: route.docstring,
2324
location: {
2425
filePath,
2526
line: route.line,

src/core/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface RouteDefinition {
2424
path: string
2525
functionName: string
2626
location: SourceLocation
27+
docstring?: string
2728
}
2829

2930
export interface RouterDefinition {

src/test/core/extractors.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,84 @@ def handler():
252252
assert.strictEqual(result.line, 2) // 1-indexed
253253
assert.strictEqual(result.column, 0)
254254
})
255+
256+
test("extracts single-line docstring", () => {
257+
const code = `
258+
@router.get("/users")
259+
def list_users():
260+
"""List all users."""
261+
pass
262+
`
263+
const tree = parse(code)
264+
const decoratedDefs = findNodesByType(
265+
tree.rootNode,
266+
"decorated_definition",
267+
)
268+
const result = decoratorExtractor(decoratedDefs[0])
269+
270+
assert.ok(result)
271+
assert.strictEqual(result.docstring, "List all users.")
272+
})
273+
274+
test("extracts multi-line docstring and dedents", () => {
275+
const code = `
276+
@router.get("/users")
277+
def list_users():
278+
"""
279+
List all users.
280+
281+
Returns a list of user objects.
282+
"""
283+
pass
284+
`
285+
const tree = parse(code)
286+
const decoratedDefs = findNodesByType(
287+
tree.rootNode,
288+
"decorated_definition",
289+
)
290+
const result = decoratorExtractor(decoratedDefs[0])
291+
292+
assert.ok(result)
293+
assert.strictEqual(
294+
result.docstring,
295+
"List all users.\n\nReturns a list of user objects.",
296+
)
297+
})
298+
299+
test("extracts single-quote docstring", () => {
300+
const code = `
301+
@router.get("/users")
302+
def list_users():
303+
'''List all users.'''
304+
pass
305+
`
306+
const tree = parse(code)
307+
const decoratedDefs = findNodesByType(
308+
tree.rootNode,
309+
"decorated_definition",
310+
)
311+
const result = decoratorExtractor(decoratedDefs[0])
312+
313+
assert.ok(result)
314+
assert.strictEqual(result.docstring, "List all users.")
315+
})
316+
317+
test("returns undefined docstring when none present", () => {
318+
const code = `
319+
@router.get("/users")
320+
def list_users():
321+
pass
322+
`
323+
const tree = parse(code)
324+
const decoratedDefs = findNodesByType(
325+
tree.rootNode,
326+
"decorated_definition",
327+
)
328+
const result = decoratorExtractor(decoratedDefs[0])
329+
330+
assert.ok(result)
331+
assert.strictEqual(result.docstring, undefined)
332+
})
255333
})
256334

257335
suite("routerExtractor", () => {

src/test/providers/pathOperationTreeProvider.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ suite("PathOperationTreeProvider", () => {
353353
? treeItem.tooltip
354354
: (treeItem.tooltip as { value: string }).value
355355
assert.ok(
356-
tooltipValue.includes("GET /users/{user_id}"),
356+
tooltipValue.includes("/users/{user_id}"),
357357
"Tooltip should show stripped path",
358358
)
359359
assert.ok(

src/vscode/pathOperationTreeProvider.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type TreeDataProvider,
66
TreeItem,
77
TreeItemCollapsibleState,
8+
Uri,
89
} from "vscode"
910
import { stripLeadingDynamicSegments } from "../core/pathUtils"
1011
import { countRoutesInRouter, findRouter } from "../core/treeUtils"
@@ -269,8 +270,14 @@ export class PathOperationTreeProvider
269270
routeItem.iconPath = new ThemeIcon(METHOD_ICONS[element.route.method])
270271
routeItem.contextValue = "route"
271272
const tooltipPath = stripLeadingDynamicSegments(element.route.path)
273+
const docstringSection = element.route.docstring
274+
? `\n\n---\n\n${element.route.docstring}`
275+
: ""
272276
routeItem.tooltip = new MarkdownString(
273-
`${element.route.method} ${tooltipPath}\n\nFunction: ${element.route.functionName}\nFile: ${element.route.location.filePath}:${element.route.location.line}`,
277+
`**${element.route.method}** \`${tooltipPath}\`\n\n` +
278+
`**Function:** \`${element.route.functionName}\`\n\n` +
279+
`**File:** ${Uri.parse(element.route.location.filePath).fsPath}:${element.route.location.line}` +
280+
docstringSection,
274281
)
275282
routeItem.command = {
276283
command: "fastapi-vscode.goToPathOperation",

0 commit comments

Comments
 (0)