diff --git a/.vscodeignore b/.vscodeignore index 705ebf0..7a0a24e 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -7,5 +7,6 @@ src/** **/*.ts **/tsconfig.json esbuild.js -node_modules/** bun.lockb +node_modules/** +!node_modules/web-tree-sitter/** diff --git a/README.md b/README.md index 03cd6fd..5503bc9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,23 @@ # FastAPI VS Code Extension -A VS Code extension for FastAPI development. +A VS Code extension for FastAPI development that discovers and displays your API endpoints in a tree view. ## Features -- Hello World command (example) +- Automatic discovery of FastAPI routes and routers +- Tree view showing all endpoints organized by router +- Click to navigate to route definitions from the endpoint explorer +- Supports `include_router` chains with prefix resolution +- CodeLens in test files to jump from test client calls to routes + +## Settings + +| Setting | Description | Default | +|---------|-------------|---------| +| `fastapi.entryPoint` | Path to the main FastAPI application file (e.g., `src/main.py`). If not set, the extension searches common locations: `main.py`, `app/main.py`, `api/main.py`, `src/main.py`, `backend/app/main.py`. | `""` (auto-detect) | +| `fastapi.showTestCodeLenses` | Show CodeLens links above test client calls (e.g., `client.get('/items')`) to navigate to the corresponding route definition. | `true` | + +**Note:** Currently the extension discovers one FastAPI app per workspace folder. If you have multiple apps, use separate workspace folders or configure `fastapi.entryPoint` to point to your primary app. ## Development diff --git a/bun.lock b/bun.lock index e32461e..9a09d79 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "fastapi-vscode", + "dependencies": { + "tree-sitter-python": "^0.25.0", + "web-tree-sitter": "^0.26.3", + }, "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/bun": "latest", @@ -15,9 +19,7 @@ "esbuild": "^0.27.2", "husky": "^9.1.7", "lint-staged": "^16.2.7", - }, - "peerDependencies": { - "typescript": "^5", + "typescript": "^5.0.0", }, }, }, @@ -612,7 +614,9 @@ "node-abi": ["node-abi@3.85.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg=="], - "node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], @@ -796,6 +800,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tree-sitter-python": ["tree-sitter-python@0.25.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], @@ -832,6 +838,8 @@ "version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="], + "web-tree-sitter": ["web-tree-sitter@0.26.3", "", {}, "sha512-JIVgIKFS1w6lejxSntCtsS/QsE/ecTS00en809cMxMPxaor6MvUnQ+ovG8uTTTvQCFosSh4MeDdI5bSGw5SoBw=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -904,6 +912,8 @@ "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], diff --git a/esbuild.js b/esbuild.js index b40f61e..44f8a8a 100644 --- a/esbuild.js +++ b/esbuild.js @@ -1,27 +1,51 @@ +import { copyFileSync, globSync, mkdirSync } from "node:fs" +import path from "node:path" import esbuild from "esbuild" const production = process.argv.includes("--production") const watch = process.argv.includes("--watch") +function copyWasmFiles() { + const wasmDestDir = path.join(import.meta.dirname, "dist", "wasm") + mkdirSync(wasmDestDir, { recursive: true }) + + const wasmFiles = [ + ["web-tree-sitter", "web-tree-sitter.wasm"], + ["tree-sitter-python", "tree-sitter-python.wasm"], + ] + + for (const [pkg, file] of wasmFiles) { + const src = path.join(import.meta.dirname, "node_modules", pkg, file) + copyFileSync(src, path.join(wasmDestDir, file)) + console.log(`Copied ${file} -> dist/wasm/`) + } +} + async function main() { + copyWasmFiles() + + const entryPoints = ["src/extension.ts"] + if (!production) { + entryPoints.push(...globSync("src/test/**/*.test.ts")) + } + const ctx = await esbuild.context({ - entryPoints: [ - "src/extension.ts", - "src/test/extension.test.ts", - "src/test/EndpointTreeProvider.test.ts", - ], + entryPoints, bundle: true, format: "cjs", minify: production, sourcemap: !production, sourcesContent: false, platform: "node", + target: "node20", + treeShaking: true, outdir: "dist", outbase: "src", - external: ["vscode"], + external: ["vscode", "web-tree-sitter"], logLevel: "info", define: { "process.env.NODE_ENV": production ? '"production"' : '"development"', + __DIST_ROOT__: JSON.stringify(path.join(import.meta.dirname, "dist")), }, }) diff --git a/package.json b/package.json index f9fcbe7..d211d03 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,82 @@ { "name": "fastapi-vscode", - "displayName": "FastAPI Extension", - "description": "VS Code extension for FastAPI development", - "version": "0.0.1", - "publisher": "FastAPI Labs", - "engines": { - "vscode": "^1.85.0" + "version": "0.0.2", + "repository": { + "type": "git", + "url": "https://github.com/fastapi/fastapi-vscode" + }, + "main": "./dist/extension.js", + "devDependencies": { + "@biomejs/biome": "^2.3.11", + "@types/bun": "latest", + "@types/mocha": "^10.0.10", + "@types/vscode": "^1.85.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", + "@vscode/vsce": "^3.7.1", + "esbuild": "^0.27.2", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", + "typescript": "^5.0.0" }, - "categories": [ - "Other" - ], "activationEvents": [ "workspaceContains:**/*.py" ], - "main": "./dist/extension.js", + "bugs": { + "url": "https://github.com/fastapi/fastapi-vscode/issues" + }, + "categories": [ + "Other" + ], "contributes": { "commands": [ { "command": "fastapi-vscode.refreshEndpoints", "title": "Refresh Endpoints", + "category": "FastAPI", "icon": "$(refresh)" }, { - "command": "fastapi-vscode.goToEndpoint", - "title": "Go to Definition" + "command": "fastapi-vscode.toggleRouters", + "title": "Toggle Expand/Collapse Routers", + "category": "FastAPI", + "icon": "$(fold)" }, { "command": "fastapi-vscode.copyEndpointPath", - "title": "Copy Path" + "title": "Copy Endpoint Path", + "category": "FastAPI" }, { "command": "fastapi-vscode.goToRouter", - "title": "Go to Router Definition" - }, - { - "command": "fastapi-vscode.copyRouterPrefix", - "title": "Copy Prefix" + "title": "Go to Router Definition", + "category": "FastAPI" } ], "menus": { + "commandPalette": [ + { + "command": "fastapi-vscode.copyEndpointPath", + "when": "false" + }, + { + "command": "fastapi-vscode.goToRouter", + "when": "false" + } + ], "view/title": [ { "command": "fastapi-vscode.refreshEndpoints", "when": "view == endpoint-explorer", - "group": "navigation" + "group": "navigation@1" + }, + { + "command": "fastapi-vscode.toggleRouters", + "when": "view == endpoint-explorer", + "group": "navigation@2" } ], "view/item/context": [ - { - "command": "fastapi-vscode.goToEndpoint", - "when": "view == endpoint-explorer && viewItem == route", - "group": "navigation" - }, { "command": "fastapi-vscode.copyEndpointPath", "when": "view == endpoint-explorer && viewItem == route", @@ -61,11 +86,6 @@ "command": "fastapi-vscode.goToRouter", "when": "view == endpoint-explorer && viewItem == router", "group": "navigation" - }, - { - "command": "fastapi-vscode.copyRouterPrefix", - "when": "view == endpoint-explorer && viewItem == router", - "group": "navigation" } ] }, @@ -85,8 +105,36 @@ "name": "Endpoint Explorer" } ] + }, + "configuration": { + "title": "FastAPI", + "properties": { + "fastapi.entryPoint": { + "type": "string", + "default": "", + "description": "Path to the main FastAPI application file (e.g., 'src/main.py'). If not set, the extension will search common locations." + }, + "fastapi.showTestCodeLenses": { + "type": "boolean", + "default": true, + "description": "Show CodeLens links above test client calls (e.g., client.get('/items'), client.websocket('/ws')) to navigate to the corresponding route definition." + } + } } }, + "description": "VS Code extension for FastAPI development", + "displayName": "FastAPI Extension", + "engines": { + "vscode": "^1.85.0" + }, + "homepage": "https://github.com/fastapi/fastapi-vscode#readme", + "license": "MIT", + "lint-staged": { + "*.{ts,js,json}": [ + "biome check --write" + ] + }, + "publisher": "FastAPILabs", "scripts": { "vscode:prepublish": "bun run esbuild.js --production", "compile": "bun run esbuild.js", @@ -94,36 +142,12 @@ "package": "vsce package", "publish:marketplace": "vsce publish", "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true src/", + "pretest": "bun run compile", "test": "vscode-test", "prepare": "husky" }, - "devDependencies": { - "@biomejs/biome": "^2.3.11", - "@types/bun": "latest", - "@types/mocha": "^10.0.10", - "@types/vscode": "^1.85.0", - "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.7.1", - "esbuild": "^0.27.2", - "husky": "^9.1.7", - "lint-staged": "^16.2.7" - }, - "peerDependencies": { - "typescript": "^5" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/fastapi/fastapi-vscode" - }, - "bugs": { - "url": "https://github.com/fastapi/fastapi-vscode/issues" - }, - "homepage": "https://github.com/fastapi/fastapi-vscode#readme", - "lint-staged": { - "*.{ts,js,json}": [ - "biome check --write" - ] + "dependencies": { + "tree-sitter-python": "^0.25.0", + "web-tree-sitter": "^0.26.3" } } diff --git a/src/commands/index.ts b/src/commands/index.ts deleted file mode 100644 index 336ce12..0000000 --- a/src/commands/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/src/core/analyzer.ts b/src/core/analyzer.ts new file mode 100644 index 0000000..ebd7b83 --- /dev/null +++ b/src/core/analyzer.ts @@ -0,0 +1,64 @@ +/** + * Analyzer module to extract FastAPI-related information from syntax trees. + */ + +import { readFileSync } from "node:fs" +import type { Tree } from "web-tree-sitter" +import { + decoratorExtractor, + findNodesByType, + importExtractor, + includeRouterExtractor, + mountExtractor, + routerExtractor, +} from "./extractors.js" +import type { FileAnalysis } from "./internal" +import type { Parser } from "./parser.js" + +function notNull(value: T | null): value is T { + return value !== null +} + +/** Analyze a syntax tree and extract FastAPI-related information */ +export function analyzeTree(tree: Tree, filePath: string): FileAnalysis { + const rootNode = tree.rootNode + + // Get all decorated definitions (functions and classes with decorators) + const decoratedDefs = findNodesByType(rootNode, "decorated_definition") + const routes = decoratedDefs.map(decoratorExtractor).filter(notNull) + + // Get all router assignments + const assignments = findNodesByType(rootNode, "assignment") + const routers = assignments.map(routerExtractor).filter(notNull) + + // Get all include_router and mount calls + const callNodes = findNodesByType(rootNode, "call") + const includeRouters = callNodes.map(includeRouterExtractor).filter(notNull) + const mounts = callNodes.map(mountExtractor).filter(notNull) + + // Get all import statements + const importNodes = findNodesByType(rootNode, "import_statement") + const importFromNodes = findNodesByType(rootNode, "import_from_statement") + const imports = [...importNodes, ...importFromNodes] + .map(importExtractor) + .filter(notNull) + + return { filePath, routes, routers, includeRouters, mounts, imports } +} + +/** Analyze a file given its path and a parser instance */ +export function analyzeFile( + filePath: string, + parser: Parser, +): FileAnalysis | null { + try { + const code = readFileSync(filePath, "utf-8") + const tree = parser.parse(code) + if (!tree) { + return null + } + return analyzeTree(tree, filePath) + } catch { + return null + } +} diff --git a/src/core/extractors.ts b/src/core/extractors.ts new file mode 100644 index 0000000..4ab9d09 --- /dev/null +++ b/src/core/extractors.ts @@ -0,0 +1,382 @@ +/** + * Utility functions to extract FastAPI-related information from AST nodes. + */ + +import type { Node } from "web-tree-sitter" +import type { + ImportedName, + ImportInfo, + IncludeRouterInfo, + MountInfo, + RouteInfo, + RouterInfo, + RouterType, +} from "./internal" +import { ROUTE_METHODS } from "./internal" + +/** Recursively finds all nodes of a given type within a subtree */ +export function findNodesByType(node: Node, type: string): Node[] { + const results: Node[] = [] + collectNodesByType(node, type, results) + return results +} + +function collectNodesByType(node: Node, type: string, results: Node[]): void { + if (node.type === type) { + results.push(node) + } + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i) + if (child) { + collectNodesByType(child, type, results) + } + } +} + +/** + * Extracts the string value from a string AST node, handling quotes and f-string prefix. + * Returns null if the node is not a string. + * + * Examples: + * '"/users"' -> "/users" + * "'/users'" -> "/users" + * 'f"/users/{id}"' -> "/users/{id}" + */ +export function extractStringValue(node: Node): string | null { + if (node.type !== "string") { + return null + } + const text = node.text + // Handle f-string prefix: f"..." or f'...' + if (text.startsWith('f"') || text.startsWith("f'")) { + return text.slice(2, -1) + } + // Regular string: "..." or '...' + return text.slice(1, -1) +} + +/** + * Extracts a path string from various AST node types. + * Handles: plain strings, f-strings, concatenation, identifiers. + */ +function extractPathFromNode(node: Node): string { + switch (node.type) { + case "string": + return extractStringValue(node) ?? "" + + case "concatenated_string": + // Adjacent strings: "/api" "/v1" -> "/api/v1" + return node.namedChildren + .map((child) => extractPathFromNode(child)) + .join("") + + case "binary_operator": { + // Concatenation: BASE + "/users" + const left = node.childForFieldName("left") + const right = node.childForFieldName("right") + const operator = node.childForFieldName("operator") + if (operator?.text === "+" && left && right) { + return extractPathFromNode(left) + extractPathFromNode(right) + } + // For other operators, just return the raw text + return `{${node.text}}` + } + + case "identifier": + case "attribute": + case "call": + // Dynamic values: variable, attribute access, or function call + return `{${node.text}}` + + default: + // Fallback: wrap unknown types in braces to indicate dynamic + return node.text ? `{${node.text}}` : "" + } +} + +/** + * Extracts from route decorators like @app.get("/path"), @router.post("/path"), etc. + */ +export function decoratorExtractor(node: Node): RouteInfo | null { + if (node.type !== "decorated_definition") { + return null + } + + const decoratorNode = node.firstNamedChild + if (!decoratorNode) { + return null + } + + const callNode = findNodesByType(decoratorNode, "call")[0] + 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 + } + + // Skip comment nodes to find the actual first argument + const pathArgNode = argumentsNode.namedChildren.find( + (child) => child.type !== "comment", + ) + 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 + } + } + } + } + } + + const functionDefNode = node.childForFieldName("definition") + const functionNameDefNode = functionDefNode + ? functionDefNode.childForFieldName("name") + : null + const functionName = functionNameDefNode ? functionNameDefNode.text : "" + + return { + object: objectNode.text, + method: resolvedMethod, + path, + function: functionName, + line: node.startPosition.row + 1, + column: node.startPosition.column, + } +} + +/** Extracts tags from a list node like ["users", "admin"] */ +function extractTags(listNode: Node): string[] { + return listNode.namedChildren + .map((elem) => extractStringValue(elem)) + .filter((v): v is string => v !== null) +} + +export function routerExtractor(node: Node): RouterInfo | null { + if (node.type !== "assignment") { + return null + } + + const variableNameNode = node.childForFieldName("left") + const valueNode = node.childForFieldName("right") + if (!variableNameNode || valueNode?.type !== "call") { + return null + } + + const funcName = valueNode.childForFieldName("function")?.text + let type: RouterType + if (funcName === "APIRouter" || funcName === "fastapi.APIRouter") { + type = "APIRouter" + } else if (funcName === "FastAPI" || funcName === "fastapi.FastAPI") { + type = "FastAPI" + } else { + return null + } + + let prefix = "" + let tags: string[] = [] + const argumentsNode = valueNode.childForFieldName("arguments") + for (const child of argumentsNode?.namedChildren ?? []) { + if (child.type !== "keyword_argument") { + continue + } + const argName = child.childForFieldName("name")?.text + const argValue = child.childForFieldName("value") + + if (argName === "prefix" && argValue) { + prefix = extractPathFromNode(argValue) + } else if (argName === "tags" && argValue?.type === "list") { + tags = extractTags(argValue) + } + } + + return { + variableName: variableNameNode.text, + type, + prefix, + tags, + line: node.startPosition.row + 1, + column: node.startPosition.column, + } +} + +/** Checks if a node is inside an ancestor of a given type */ +function hasAncestor(node: Node, ancestorType: string): boolean { + let parent = node.parent + while (parent) { + if (parent.type === ancestorType) { + return true + } + parent = parent.parent + } + return false +} + +/** Parses a module path, extracting relative dots if present */ +function parseModulePath(rawPath: string): { + modulePath: string + isRelative: boolean + relativeDots: number +} { + const matches = rawPath.match(/^(\.+)(.*)/) + if (matches) { + return { + modulePath: matches[2], + isRelative: true, + relativeDots: matches[1].length, + } + } + return { modulePath: rawPath, isRelative: false, relativeDots: 0 } +} + +export function importExtractor(node: Node): ImportInfo | null { + if ( + node.type !== "import_statement" && + node.type !== "import_from_statement" + ) { + return null + } + + const names: string[] = [] + const namedImports: ImportedName[] = [] + + if (node.type === "import_statement") { + const nameNodes = findNodesByType(node, "dotted_name") + for (const nameNode of nameNodes) { + const firstName = nameNode.text.split(".")[0] + names.push(firstName) + namedImports.push({ name: firstName, alias: null }) + } + const modulePath = nameNodes[0]?.text ?? "" + return { + modulePath, + names, + namedImports, + isRelative: false, + relativeDots: 0, + } + } + + // import_from_statement + const moduleNode = node.childForFieldName("module_name") + const { modulePath, isRelative, relativeDots } = parseModulePath( + moduleNode?.text ?? "", + ) + + // Aliased imports (e.g., "router as users_router") + for (const aliased of findNodesByType(node, "aliased_import")) { + const nameNode = aliased.childForFieldName("name") + const aliasNode = aliased.childForFieldName("alias") + if (nameNode) { + const alias = aliasNode?.text ?? null + names.push(alias ?? nameNode.text) + namedImports.push({ name: nameNode.text, alias }) + } + } + + // Non-aliased imports (skip first dotted_name which is the module path) + const nameNodes = findNodesByType(node, "dotted_name") + for (let i = 1; i < nameNodes.length; i++) { + const nameNode = nameNodes[i] + if (!hasAncestor(nameNode, "aliased_import")) { + names.push(nameNode.text) + namedImports.push({ name: nameNode.text, alias: null }) + } + } + + return { modulePath, names, namedImports, isRelative, relativeDots } +} + +/** Extracts method call info: object.method(args) */ +function extractMethodCall( + node: Node, + methodName: string, +): { object: string; args: Node[] } | null { + if (node.type !== "call") { + return null + } + + const functionNode = node.childForFieldName("function") + if (functionNode?.type !== "attribute") { + return null + } + + const objectNode = functionNode.childForFieldName("object") + const methodNode = functionNode.childForFieldName("attribute") + if (!objectNode || methodNode?.text !== methodName) { + return null + } + + const argumentsNode = node.childForFieldName("arguments") + const args = + argumentsNode?.namedChildren.filter((c) => c.type !== "comment") ?? [] + + return { object: objectNode.text, args } +} + +export function includeRouterExtractor(node: Node): IncludeRouterInfo | null { + const call = extractMethodCall(node, "include_router") + if (!call) { + return null + } + + let prefix = "" + let tags: string[] = [] + for (const arg of call.args) { + if (arg.type !== "keyword_argument") { + continue + } + const name = arg.childForFieldName("name")?.text + const value = arg.childForFieldName("value") + + if (name === "prefix" && value) { + prefix = extractPathFromNode(value) + } else if (name === "tags" && value?.type === "list") { + tags = extractTags(value) + } + } + + return { + object: call.object, + router: call.args[0]?.text ?? "", + prefix, + tags, + } +} + +/** Extracts mount() calls for subapps: app.mount("/path", subapp) */ +export function mountExtractor(node: Node): MountInfo | null { + const call = extractMethodCall(node, "mount") + if (!call || call.args.length < 2) { + return null + } + + return { + object: call.object, + path: extractPathFromNode(call.args[0]), + app: call.args[1].text, + } +} diff --git a/src/core/importResolver.ts b/src/core/importResolver.ts new file mode 100644 index 0000000..94ee3bf --- /dev/null +++ b/src/core/importResolver.ts @@ -0,0 +1,190 @@ +/** + * Python import resolution for static analysis. + * + * Resolves Python import statements to file paths without executing code. + * Handles relative imports, absolute imports, namespace packages (PEP 420), + * and re-exports from __init__.py files. + * + * Resolution order (matching Python): + * 1. module.py (direct file) + * 2. module/__init__.py (package) + * 3. module/ without __init__.py (namespace package) + */ + +import { existsSync } from "node:fs" +import { dirname, join } from "node:path" +import { analyzeFile } from "./analyzer" +import type { ImportInfo } from "./internal" +import type { Parser } from "./parser" + +/** + * Cache for file existence checks to avoid repeated filesystem calls. + * Maps file path -> exists (true/false). + */ +const fileExistsCache = new Map() + +function cachedExistsSync(path: string): boolean { + if (fileExistsCache.has(path)) { + return fileExistsCache.get(path)! + } + const exists = existsSync(path) + fileExistsCache.set(path, exists) + return exists +} + +/** Clears the file existence cache. Call when files may have changed. */ +export function clearImportCache(): void { + fileExistsCache.clear() +} + +/** + * Resolves a module path to its Python file. + * Checks for direct .py file first, then package __init__.py + * (matching Python's import resolution order). + */ +function resolvePythonModule(basePath: string): string | null { + const pyPath = `${basePath}.py` + if (cachedExistsSync(pyPath)) { + return pyPath + } + const initPath = join(basePath, "__init__.py") + if (cachedExistsSync(initPath)) { + return initPath + } + return null +} + +/** Finds an import that provides a given exported name (for re-exports in __init__.py) */ +function findImportByExportedName( + imports: ImportInfo[], + name: string, +): ImportInfo | undefined { + return imports.find((imp) => + imp.namedImports.some( + (namedImport) => (namedImport.alias ?? namedImport.name) === name, + ), + ) +} + +/** + * Converts a Python module path to a filesystem directory path. + * + * Examples (modulePath, relativeDots → result): + * Absolute: ("app.api.routes", 0) from projectRoot="/project" → "/project/app/api/routes" + * Relative: ("routes", 1) from "/project/app/api/main.py" → "/project/app/api/routes" + * Relative: ("routes", 2) from "/project/app/api/main.py" → "/project/app/routes" + */ +function modulePathToDir( + importInfo: Pick, + currentFilePath: string, + projectRoot: string, +): string { + let baseDir: string + if (importInfo.isRelative) { + // For relative imports, go up 'relativeDots' directories from current file + baseDir = dirname(currentFilePath) + for (let i = 1; i < importInfo.relativeDots; i++) { + baseDir = dirname(baseDir) + } + } else { + baseDir = projectRoot + } + + if (importInfo.modulePath) { + return join(baseDir, ...importInfo.modulePath.split(".")) + } + return baseDir +} + +/** + * Resolves a module import to its file path. + * + * Examples: + * "from app.api import routes" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py" + * "from .routes import users" → "/project/app/api/routes.py" or "/project/app/api/routes/__init__.py" + * + * Returns null if the module doesn't exist (may be a namespace package). + */ +export function resolveImport( + importInfo: Pick, + currentFilePath: string, + projectRoot: string, +): string | null { + const resolvedPath = modulePathToDir(importInfo, currentFilePath, projectRoot) + return resolvePythonModule(resolvedPath) +} + +/** + * Resolves a named import to its file path. + * For example, from .routes import users + * will try to resolve to routes/users.py + */ +export function resolveNamedImport( + importInfo: Pick< + ImportInfo, + "modulePath" | "names" | "isRelative" | "relativeDots" + >, + currentFilePath: string, + projectRoot: string, + parser?: Parser, +): string | null { + const basePath = resolveImport(importInfo, currentFilePath, projectRoot) + + // Calculate base directory for named import resolution. + // For namespace packages (directories without __init__.py), basePath will be null, + // so we compute the directory path directly from the module path. + const baseDir = basePath + ? dirname(basePath) + : modulePathToDir(importInfo, currentFilePath, projectRoot) + + for (const name of importInfo.names) { + // Try direct file: from .routes import users -> routes/users.py + const namedPath = join(baseDir, ...name.split(".")) + const resolved = resolvePythonModule(namedPath) + if (resolved) { + return resolved + } + + // Try re-exports: from .routes import users where routes/__init__.py re-exports users + if (basePath?.endsWith("__init__.py") && parser) { + const analysis = analyzeFile(basePath, parser) + const imp = analysis && findImportByExportedName(analysis.imports, name) + if (imp) { + const reExportResolved = resolveImport(imp, basePath, projectRoot) + if (reExportResolved) { + return reExportResolved + } + } + } + } + + // Fall back to base module resolution + return basePath +} + +/** + * When an __init__.py has no router definitions but re-exports a router, + * this function finds the actual file containing the router. + * + * For example, if integrations/__init__.py contains: + * from .router import router as router + * This will return the path to integrations/router.py + */ +export function resolveRouterFromInit( + initFilePath: string, + projectRoot: string, + parser: Parser, +): string | null { + if (!initFilePath.endsWith("__init__.py")) { + return null + } + + const analysis = analyzeFile(initFilePath, parser) + // If file has routers defined, no need to follow re-exports + if (!analysis || analysis.routers.length > 0) { + return null + } + + const imp = findImportByExportedName(analysis.imports, "router") + return imp ? resolveImport(imp, initFilePath, projectRoot) : null +} diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..8e9046d --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,18 @@ +/** + * Public API for FastAPI endpoint discovery. + * This module can be used independently of VSCode. + */ + +export { analyzeFile, analyzeTree } from "./analyzer" +export type { FileAnalysis } from "./internal" +export { Parser } from "./parser" +export { buildRouterGraph, type RouterNode } from "./routerResolver" +export { routerNodeToAppDefinition } from "./transformer" +export type { + AppDefinition, + HTTPMethod, + RouteDefinition, + RouteMethod, + RouterDefinition, + SourceLocation, +} from "./types" diff --git a/src/core/internal.ts b/src/core/internal.ts new file mode 100644 index 0000000..7acf254 --- /dev/null +++ b/src/core/internal.ts @@ -0,0 +1,101 @@ +/** + * Internal types and utilities for AST analysis. + * These are implementation details not exposed in the public API. + */ + +import type { RouteMethod } from "./types" + +/** + * Valid HTTP methods plus WEBSOCKET, used for decorator validation. + * Lowercase for case-insensitive comparison during extraction. + */ +export const ROUTE_METHODS: ReadonlySet = new Set([ + "get", + "post", + "put", + "delete", + "patch", + "options", + "head", + "websocket", +]) + +/** Normalizes a method string to a valid RouteMethod. Returns "GET" for invalid methods. */ +export function normalizeMethod(method: string): RouteMethod { + return ROUTE_METHODS.has(method.toLowerCase()) + ? (method.toUpperCase() as RouteMethod) + : "GET" +} + +export interface RouteInfo { + object: string + method: string + path: string + function: string + line: number + column: number +} + +export type RouterType = "APIRouter" | "FastAPI" | "Unknown" + +export interface RouterInfo { + variableName: string + type: RouterType + prefix: string + tags: string[] + line: number + column: number +} + +export interface ImportedName { + name: string + alias: string | null +} + +export interface ImportInfo { + modulePath: string + names: string[] + namedImports: ImportedName[] + isRelative: boolean + relativeDots: number +} + +export interface IncludeRouterInfo { + object: string + router: string + prefix: string + tags: string[] +} + +export interface MountInfo { + object: string + path: string + app: string +} + +export interface FileAnalysis { + filePath: string + routes: RouteInfo[] + routers: RouterInfo[] + includeRouters: IncludeRouterInfo[] + mounts: MountInfo[] + imports: ImportInfo[] +} + +export interface RouterNode { + filePath: string + variableName: string + type: RouterType + prefix: string + tags: string[] + line: number + column: number + routes: { + method: string + path: string + function: string + line: number + column: number + }[] + children: { router: RouterNode; prefix: string; tags: string[] }[] +} diff --git a/src/core/parser.ts b/src/core/parser.ts new file mode 100644 index 0000000..0cc158c --- /dev/null +++ b/src/core/parser.ts @@ -0,0 +1,37 @@ +/** + * Parser service using Web Tree Sitter to parse Python code. + */ + +import { readFileSync } from "node:fs" +import { Language, Parser as TreeSitterParser } from "web-tree-sitter" + +export class Parser { + private parser: TreeSitterParser | null = null + async init(wasmPaths: { core: string; python: string }) { + if (this.parser) { + return + } + + const wasmBinary = readFileSync(wasmPaths.core) + await TreeSitterParser.init({ wasmBinary }) + + this.parser = new TreeSitterParser() + + const pythonWasmBinary = readFileSync(wasmPaths.python) + const pythonLanguage = await Language.load(pythonWasmBinary) + this.parser.setLanguage(pythonLanguage) + } + + parse(code: string) { + if (!this.parser) { + throw new Error("ParserService not initialized. Call init() first.") + } + + return this.parser.parse(code) + } + + dispose() { + this.parser?.delete() + this.parser = null + } +} diff --git a/src/core/pathUtils.ts b/src/core/pathUtils.ts new file mode 100644 index 0000000..5e73223 --- /dev/null +++ b/src/core/pathUtils.ts @@ -0,0 +1,111 @@ +import { existsSync } from "node:fs" +import { dirname, join, relative, sep } from "node:path" + +/** + * Strips leading dynamic segments (like {settings.API_V1_STR}) from a path. + * + * Examples: + * "{settings.API_V1_STR}/users/{id}" -> "/users/{id}" + * "{BASE}/api/items" -> "/api/items" + * "/users/{id}/posts" -> "/users/{id}/posts" (unchanged) + * "{settings.API_V1_STR}" -> "/" + */ +export function stripLeadingDynamicSegments(path: string): string { + return path.replace(/^(\{[^}]+\})+/, "") || "/" +} + +/** + * Checks if a path is within or equal to a base directory. + * Uses relative path calculation to avoid false positives from string prefix matching. + */ +export function isWithinDirectory(filePath: string, baseDir: string): boolean { + const rel = relative(baseDir, filePath) + // If relative path starts with "..", the path is outside baseDir + return !rel.startsWith("..") && !rel.startsWith(sep) +} + +/** + * Finds the Python project root by walking up from the entry file + * until we find a directory without __init__.py (or hit the workspace root). + * This is the directory from which absolute imports are resolved. + */ +export function findProjectRoot( + entryPath: string, + workspaceRoot: string, +): string { + let dir = dirname(entryPath) + + // If the entry file's directory doesn't have __init__.py, it's a top-level script + if (!existsSync(join(dir, "__init__.py"))) { + return dir + } + + // Walk up until we find a directory whose parent doesn't have __init__.py + while (isWithinDirectory(dir, workspaceRoot) && dir !== workspaceRoot) { + const parent = dirname(dir) + if (!existsSync(join(parent, "__init__.py"))) { + return parent + } + dir = parent + } + + return workspaceRoot +} + +/** + * Gets the first N segments of a path. + * + * Examples: + * getPathSegments("/integrations/neon/foo", 2) -> "/integrations/neon" + * getPathSegments("/users", 1) -> "/users" + * getPathSegments("/a/b/c", 5) -> "/a/b/c" (returns full path if count >= segments) + */ +export function getPathSegments(path: string, count: number): string { + const segments = path.split("/").filter(Boolean) + if (count >= segments.length) return path + return `/${segments.slice(0, count).join("/")}` +} + +/** + * Counts the number of segments in a path. + * + * Examples: + * countSegments("/integrations/neon") -> 2 + * countSegments("/") -> 0 + * countSegments("/users") -> 1 + */ +export function countSegments(path: string): number { + return path.split("/").filter(Boolean).length +} + +/** + * Checks if a test path matches an endpoint path pattern. + * Endpoint paths may contain path parameters like {item_id} which match any segment. + * + * Examples: + * pathMatchesEndpoint("/items/123", "/items/{item_id}") -> true + * pathMatchesEndpoint("/items/123/details", "/items/{item_id}") -> false + * pathMatchesEndpoint("/users/abc/posts/456", "/users/{user_id}/posts/{post_id}") -> true + * pathMatchesEndpoint("/items/", "/items/{item_id}") -> false + */ +export function pathMatchesEndpoint( + testPath: string, + endpointPath: string, +): boolean { + const testSegments = testPath.split("/").filter(Boolean) + const endpointSegments = endpointPath.split("/").filter(Boolean) + + // Segment counts must match + if (testSegments.length !== endpointSegments.length) { + return false + } + + return endpointSegments.every((seg, index) => { + // Path parameter (e.g., {item_id}) matches any segment + if (seg.startsWith("{") && seg.endsWith("}")) { + return true + } + // Literal segments must match exactly + return seg === testSegments[index] + }) +} diff --git a/src/core/routerResolver.ts b/src/core/routerResolver.ts new file mode 100644 index 0000000..101f521 --- /dev/null +++ b/src/core/routerResolver.ts @@ -0,0 +1,195 @@ +import { existsSync } from "node:fs" +import { isAbsolute, join } from "node:path" +import { analyzeFile } from "./analyzer" +import { resolveNamedImport, resolveRouterFromInit } from "./importResolver" +import type { FileAnalysis, RouterInfo, RouterNode } from "./internal" +import type { Parser } from "./parser" + +export type { RouterNode } + +/** + * Finds the main FastAPI app or APIRouter in the list of routers. + */ +function findAppRouter(routers: RouterInfo[]): RouterInfo | undefined { + return routers.find((r) => r.type === "FastAPI" || r.type === "APIRouter") +} + +/** + * Builds a router graph starting from the given entry file. + */ +export function buildRouterGraph( + entryFile: string, + parser: Parser, + projectRoot: string, +): RouterNode | null { + return buildRouterGraphInternal(entryFile, parser, projectRoot, new Set()) +} + +/** + * Internal recursive function to build the router graph. + */ +function buildRouterGraphInternal( + entryFile: string, + parser: Parser, + projectRoot: string, + visited: Set, +): RouterNode | null { + // Resolve the full path of the entry file if necessary + let resolvedEntryFile = entryFile + if (!existsSync(resolvedEntryFile) && !isAbsolute(entryFile)) { + resolvedEntryFile = join(projectRoot, entryFile) + } + if (!existsSync(resolvedEntryFile)) { + return null + } + + // Prevent infinite recursion on circular imports + if (visited.has(resolvedEntryFile)) { + return null + } + + visited.add(resolvedEntryFile) + + // Analyze the entry file + let analysis = analyzeFile(resolvedEntryFile, parser) + if (!analysis) { + return null + } + + // Find FastAPI instantiation + let appRouter = findAppRouter(analysis.routers) + + // If no FastAPI/APIRouter found and this is an __init__.py, check for re-exports + if (!appRouter && resolvedEntryFile.endsWith("__init__.py")) { + const actualRouterFile = resolveRouterFromInit( + resolvedEntryFile, + projectRoot, + parser, + ) + if (actualRouterFile && !visited.has(actualRouterFile)) { + visited.add(actualRouterFile) + const actualAnalysis = analyzeFile(actualRouterFile, parser) + if (actualAnalysis) { + const actualRouter = findAppRouter(actualAnalysis.routers) + if (actualRouter) { + analysis = actualAnalysis + appRouter = actualRouter + resolvedEntryFile = actualRouterFile + } + } + } + } + + if (!appRouter || !analysis) { + return null + } + + // Find all routers included in the app + const rootRouter: RouterNode = { + filePath: resolvedEntryFile, + variableName: appRouter.variableName, + type: appRouter.type, + prefix: appRouter.prefix, + tags: appRouter.tags, + line: appRouter.line, + column: appRouter.column, + routes: analysis.routes.map((r) => ({ + method: r.method, + path: r.path, + function: r.function, + line: r.line, + column: r.column, + })), + children: [], + } + + // Process include_router calls to find child routers + for (const include of analysis.includeRouters) { + const childRouter = resolveRouterReference( + include.router, + analysis, + resolvedEntryFile, + projectRoot, + parser, + visited, + ) + if (!childRouter) { + continue + } + // Merge tags from include_router call with the router's own tags + if (include.tags.length > 0) { + childRouter.tags = [...new Set([...childRouter.tags, ...include.tags])] + } + rootRouter.children.push({ + router: childRouter, + prefix: include.prefix, + tags: include.tags, + }) + } + + // Process mount() calls for subapps + for (const mount of analysis.mounts) { + const childRouter = resolveRouterReference( + mount.app, + analysis, + resolvedEntryFile, + projectRoot, + parser, + visited, + ) + if (!childRouter) { + continue + } + rootRouter.children.push({ + router: childRouter, + prefix: mount.path, + tags: [], + }) + } + + return rootRouter +} + +/** + * Resolves a router/app reference to its RouterNode. + */ +function resolveRouterReference( + reference: string, + analysis: FileAnalysis, + currentFile: string, + projectRoot: string, + parser: Parser, + visited: Set, +): RouterNode | null { + const parts = reference.split(".") + const moduleName = parts[0] + + const matchingImport = analysis.imports.find((imp) => + imp.names.includes(moduleName), + ) + if (!matchingImport) { + return null + } + + const importedFilePath = resolveNamedImport( + { + modulePath: matchingImport.modulePath, + names: [moduleName], + isRelative: matchingImport.isRelative, + relativeDots: matchingImport.relativeDots, + }, + currentFile, + projectRoot, + parser, + ) + if (!importedFilePath) { + return null + } + + return buildRouterGraphInternal( + importedFilePath, + parser, + projectRoot, + visited, + ) +} diff --git a/src/core/transformer.ts b/src/core/transformer.ts new file mode 100644 index 0000000..1e2a609 --- /dev/null +++ b/src/core/transformer.ts @@ -0,0 +1,245 @@ +/** + * Transforms a RouterNode graph into an AppDefinition for consumption. + */ + +import { normalizeMethod } from "./internal" +import { + countSegments, + getPathSegments, + stripLeadingDynamicSegments, +} from "./pathUtils" +import type { RouterNode } from "./routerResolver" +import type { AppDefinition, RouteDefinition, RouterDefinition } from "./types" + +function toRouteDefinition( + route: RouterNode["routes"][number], + prefix: string, + filePath: string, +): RouteDefinition { + return { + method: normalizeMethod(route.method), + path: prefix + route.path, + functionName: route.function, + location: { + filePath, + line: route.line, + column: route.column, + }, + } +} + +/** + * Collects routers into a flat list with full prefixes. + */ +function collectFlatRouters( + node: RouterNode, + parentPrefix: string, + routers: RouterDefinition[], +): void { + const fullPrefix = parentPrefix + node.prefix + + // Convert routes from this node + const routes = node.routes.map((r) => + toRouteDefinition(r, fullPrefix, node.filePath), + ) + + // Add this router (skip the root FastAPI app and routers with no routes) + if (node.type === "APIRouter" && routes.length > 0) { + routers.push({ + name: node.variableName, + prefix: fullPrefix, + tags: node.tags, + location: { + filePath: node.filePath, + line: node.line, + column: node.column, + }, + routes, + children: [], + }) + } + + // Recurse into children + for (const child of node.children) { + collectFlatRouters(child.router, fullPrefix + child.prefix, routers) + } +} + +/** + * Finds the nearest parent router by walking up the prefix hierarchy. + */ +function findParentRouter( + prefix: string, + segmentCount: number, + prefixToRouter: Map, +): RouterDefinition | undefined { + for (let i = segmentCount - 1; i >= 1; i--) { + const parentPrefix = getPathSegments(prefix, i) + const parent = prefixToRouter.get(parentPrefix) + if (parent) { + return parent + } + } + return undefined +} + +/** + * Builds a prefix hierarchy from flat routers. + * Groups routers by common prefix segments. + * Example: /integrations/neon and /integrations/redis grouped under /integrations + */ +function buildPrefixHierarchy( + flatRouters: RouterDefinition[], +): RouterDefinition[] { + // Sort by segment count (fewest first) to process parents before children + const sorted = [...flatRouters].sort((a, b) => { + const segmentsA = countSegments(stripLeadingDynamicSegments(a.prefix)) + const segmentsB = countSegments(stripLeadingDynamicSegments(b.prefix)) + return segmentsA - segmentsB + }) + + // Build a map of prefix -> router for grouping + // Also track synthetic group routers we create + const prefixToRouter = new Map() + const rootRouters: RouterDefinition[] = [] + + for (const router of sorted) { + const strippedPrefix = stripLeadingDynamicSegments(router.prefix) + const segmentCount = countSegments(strippedPrefix) + + // Handle routers with no meaningful prefix + if (segmentCount === 0) { + rootRouters.push(router) + prefixToRouter.set(strippedPrefix, router) + continue + } + + // Check if a router with the exact same prefix already exists - nest under it + const existingRouter = prefixToRouter.get(strippedPrefix) + if (existingRouter) { + // If existing is a synthetic group (no routes), add as child; otherwise merge + if (existingRouter.routes.length === 0 && router.routes.length > 0) { + existingRouter.children.push(router) + } else { + // Merge routes and children into the existing router + existingRouter.routes.push(...router.routes) + existingRouter.children.push(...router.children) + } + continue + } + + // Look for a parent at each level up + const parent = findParentRouter( + strippedPrefix, + segmentCount, + prefixToRouter, + ) + if (parent) { + parent.children.push(router) + prefixToRouter.set(strippedPrefix, router) + continue + } + + // No parent found - check if we should create a synthetic group + // Only for routers with 2+ segments (e.g., /integrations/neon) + if (segmentCount >= 2) { + const groupPrefix = getPathSegments(strippedPrefix, 1) + let groupRouter = prefixToRouter.get(groupPrefix) + + if (!groupRouter) { + // Check if there's a root-level router with matching tag that should be the parent + const matchingRootRouter = rootRouters.find((r) => { + if (r === router) return false + // Router has no prefix but has a tag matching this group + const rPrefix = stripLeadingDynamicSegments(r.prefix) + return ( + countSegments(rPrefix) === 0 && + r.tags.length > 0 && + `/${r.tags[0]}` === groupPrefix + ) + }) + + if (matchingRootRouter) { + // Use this router as the group parent + groupRouter = matchingRootRouter + // Remove from root and re-register under the group prefix + const idx = rootRouters.indexOf(matchingRootRouter) + if (idx !== -1) rootRouters.splice(idx, 1) + matchingRootRouter.prefix = groupPrefix + prefixToRouter.set(groupPrefix, matchingRootRouter) + rootRouters.push(matchingRootRouter) + } else { + // Check if there are other routers that would be siblings under this group + const wouldHaveSiblings = sorted.some((other) => { + if (other === router) return false + const otherPrefix = stripLeadingDynamicSegments(other.prefix) + const otherSegments = countSegments(otherPrefix) + return ( + otherSegments >= 2 && + getPathSegments(otherPrefix, 1) === groupPrefix && + otherPrefix !== strippedPrefix + ) + }) + + if (wouldHaveSiblings) { + // Create a synthetic group router + groupRouter = { + name: groupPrefix.replace(/^\//, ""), + prefix: groupPrefix, + tags: [], + location: router.location, + routes: [], + children: [], + } + prefixToRouter.set(groupPrefix, groupRouter) + rootRouters.push(groupRouter) + } + } + } + + if (groupRouter) { + groupRouter.children.push(router) + prefixToRouter.set(strippedPrefix, router) + continue + } + } + + rootRouters.push(router) + prefixToRouter.set(strippedPrefix, router) + } + + // Remove empty routers + return rootRouters.filter((r) => r.routes.length > 0 || r.children.length > 0) +} + +export function routerNodeToAppDefinition( + rootNode: RouterNode, + workspaceFolder: string, +): AppDefinition { + const flatRouters: RouterDefinition[] = [] + + // Collect direct routes on the FastAPI app + const directRoutes = rootNode.routes.map((r) => + toRouteDefinition(r, rootNode.prefix, rootNode.filePath), + ) + + // Collect all routers flat first + for (const child of rootNode.children) { + collectFlatRouters( + child.router, + rootNode.prefix + child.prefix, + flatRouters, + ) + } + + // Build prefix hierarchy + const routers = buildPrefixHierarchy(flatRouters) + + return { + name: rootNode.variableName, + filePath: rootNode.filePath, + workspaceFolder, + routers, + routes: directRoutes, + } +} diff --git a/src/types/endpoint.ts b/src/core/types.ts similarity index 75% rename from src/types/endpoint.ts rename to src/core/types.ts index 06f40c9..b669e83 100644 --- a/src/types/endpoint.ts +++ b/src/core/types.ts @@ -1,3 +1,7 @@ +/** + * Public API types for FastAPI endpoint discovery. + */ + export type HTTPMethod = | "GET" | "POST" @@ -25,8 +29,10 @@ export interface RouteDefinition { export interface RouterDefinition { name: string prefix: string + tags: string[] location: SourceLocation routes: RouteDefinition[] + children: RouterDefinition[] // Nested routers (by prefix hierarchy) } export interface AppDefinition { @@ -36,9 +42,3 @@ export interface AppDefinition { routers: RouterDefinition[] routes: RouteDefinition[] // Direct routes on the app } - -export type EndpointTreeItem = - | { type: "workspace"; label: string; apps: AppDefinition[] } - | { type: "app"; app: AppDefinition } - | { type: "router"; router: RouterDefinition } - | { type: "route"; route: RouteDefinition } diff --git a/src/extension.ts b/src/extension.ts index 6569e47..d5b9d38 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,47 +1,158 @@ +/** + * VSCode extension entry point for FastAPI endpoint discovery. + */ + +import { existsSync } from "node:fs" +import { sep } from "node:path" import * as vscode from "vscode" -import { EndpointTreeProvider } from "./providers/EndpointTreeProvider" -// TODO: Replace with real endpoint discovery service +import { clearImportCache } from "./core/importResolver" +import { Parser } from "./core/parser" +import { findProjectRoot, stripLeadingDynamicSegments } from "./core/pathUtils" +import { buildRouterGraph } from "./core/routerResolver" +import { routerNodeToAppDefinition } from "./core/transformer" +import type { AppDefinition, SourceLocation } from "./core/types" import { - groupAppsByWorkspace, - mockApps, -} from "./test/fixtures/mockEndpointData" -import type { EndpointTreeItem, SourceLocation } from "./types/endpoint" - -function navigateToLocation(location: SourceLocation): void { - if (!location.filePath) { - vscode.window.showErrorMessage("File path is missing for the endpoint.") - return + type EndpointTreeItem, + EndpointTreeProvider, +} from "./providers/EndpointTreeProvider" +import { TestCodeLensProvider } from "./providers/TestCodeLensProvider" + +// Endpoint Discovery + +async function discoverFastAPIApps(parser: Parser): Promise { + const apps: AppDefinition[] = [] + const workspaceFolders = vscode.workspace.workspaceFolders + + if (!workspaceFolders) { + return apps } - const uri = vscode.Uri.file(location.filePath) - const position = new vscode.Position(location.line - 1, location.column) - vscode.window.showTextDocument(uri, { - selection: new vscode.Range(position, position), - }) + + for (const folder of workspaceFolders) { + const config = vscode.workspace.getConfiguration("fastapi", folder.uri) + const customEntryPoint = config.get("entryPoint") + + let candidates: string[] = [] + + if (customEntryPoint) { + // Use custom entry point if specified + const entryPath = customEntryPoint.startsWith("/") + ? customEntryPoint + : vscode.Uri.joinPath(folder.uri, customEntryPoint).fsPath + + if (!existsSync(entryPath)) { + vscode.window.showWarningMessage( + `FastAPI entry point not found: ${customEntryPoint}`, + ) + continue + } + + candidates = [entryPath] + } else { + // Scan for main.py and __init__.py files (likely FastAPI entry points) + const mainFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(folder, "**/main.py"), + ) + const initFiles = await vscode.workspace.findFiles( + new vscode.RelativePattern(folder, "**/__init__.py"), + ) + // Prefer main.py, then __init__.py, sorted by path depth (shallower first) + candidates = [...mainFiles, ...initFiles] + .map((uri) => uri.fsPath) + .sort((a, b) => a.split(sep).length - b.split(sep).length) + } + + for (const entryPath of candidates) { + const projectRoot = findProjectRoot(entryPath, folder.uri.fsPath) + const routerNode = buildRouterGraph(entryPath, parser, projectRoot) + + if (routerNode) { + apps.push(routerNodeToAppDefinition(routerNode, folder.uri.fsPath)) + break + } + } + } + + return apps } -// This method is called when your extension is activated -export function activate(context: vscode.ExtensionContext) { - const endpointProvider = new EndpointTreeProvider( - mockApps, - groupAppsByWorkspace, - ) +// Extension Activation - context.subscriptions.push( - vscode.window.registerTreeDataProvider( - "endpoint-explorer", - endpointProvider, - ), +let parserService: Parser | null = null - vscode.commands.registerCommand("fastapi-vscode.refreshEndpoints", () => { - endpointProvider.refresh() - }), +export async function activate(context: vscode.ExtensionContext) { + // Parser Initialization + parserService = new Parser() + await parserService.init({ + core: vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "web-tree-sitter.wasm", + ).fsPath, + python: vscode.Uri.joinPath( + context.extensionUri, + "dist", + "wasm", + "tree-sitter-python.wasm", + ).fsPath, + }) + + // Providers + const apps = await discoverFastAPIApps(parserService) + const endpointProvider = new EndpointTreeProvider(apps) + const codeLensProvider = new TestCodeLensProvider(parserService, apps) + + // File Watching + let refreshTimeout: NodeJS.Timeout | null = null + const triggerRefresh = () => { + if (refreshTimeout) { + clearTimeout(refreshTimeout) + } + refreshTimeout = setTimeout(async () => { + if (!parserService) { + return + } + const newApps = await discoverFastAPIApps(parserService) + endpointProvider.setApps(newApps) + codeLensProvider.setApps(newApps) + }, 500) + } + + const watcher = vscode.workspace.createFileSystemWatcher("**/*.py") + watcher.onDidChange(triggerRefresh) + watcher.onDidCreate(triggerRefresh) + watcher.onDidDelete(triggerRefresh) + context.subscriptions.push(watcher) + + // Tree View + const treeView = vscode.window.createTreeView("endpoint-explorer", { + treeDataProvider: endpointProvider, + }) + context.subscriptions.push(treeView) + + // CodeLens + const config = vscode.workspace.getConfiguration("fastapi") + if (config.get("showTestCodeLenses", true)) { + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: "python", pattern: "**/test*.py" }, + codeLensProvider, + ), + ) + } + + // Commands + context.subscriptions.push( vscode.commands.registerCommand( - "fastapi-vscode.goToEndpoint", - (item: EndpointTreeItem) => { - if (item.type === "route") { - navigateToLocation(item.route.location) + "fastapi-vscode.refreshEndpoints", + async () => { + if (!parserService) { + return } + clearImportCache() + const newApps = await discoverFastAPIApps(parserService) + endpointProvider.setApps(newApps) }, ), @@ -49,31 +160,66 @@ export function activate(context: vscode.ExtensionContext) { "fastapi-vscode.copyEndpointPath", (item: EndpointTreeItem) => { if (item.type === "route") { - vscode.env.clipboard.writeText(item.route.path) - vscode.window.showInformationMessage(`Copied: ${item.route.path}`) + vscode.env.clipboard.writeText( + stripLeadingDynamicSegments(item.route.path), + ) } }, ), + vscode.commands.registerCommand("fastapi-vscode.toggleRouters", () => { + endpointProvider.toggleRouters() + }), + vscode.commands.registerCommand( "fastapi-vscode.goToRouter", (item: EndpointTreeItem) => { if (item.type === "router") { - navigateToLocation(item.router.location) + const uri = vscode.Uri.file(item.router.location.filePath) + const pos = new vscode.Position( + item.router.location.line - 1, + item.router.location.column, + ) + vscode.window.showTextDocument(uri, { + selection: new vscode.Range(pos, pos), + }) } }, ), vscode.commands.registerCommand( - "fastapi-vscode.copyRouterPrefix", - (item: EndpointTreeItem) => { - if (item.type === "router") { - vscode.env.clipboard.writeText(item.router.prefix) - vscode.window.showInformationMessage(`Copied: ${item.router.prefix}`) - } + "fastapi-vscode.openLocation", + (location: SourceLocation) => { + const uri = vscode.Uri.file(location.filePath) + const pos = new vscode.Position(location.line - 1, location.column) + vscode.window.showTextDocument(uri, { + selection: new vscode.Range(pos, pos), + }) + }, + ), + + vscode.commands.registerCommand( + "fastapi-vscode.goToDefinition", + ( + locations: vscode.Location[], + fromUri: vscode.Uri, + fromPosition: vscode.Position, + ) => { + vscode.commands.executeCommand( + "editor.action.goToLocations", + fromUri, + fromPosition, + locations, + locations.length === 1 ? "goto" : "peek", + "No matching route found", + ) }, ), ) } -export function deactivate() {} +export function deactivate() { + parserService?.dispose() + parserService = null + clearImportCache() +} diff --git a/src/providers/EndpointTreeProvider.ts b/src/providers/EndpointTreeProvider.ts index f7ce6e4..a734eaa 100644 --- a/src/providers/EndpointTreeProvider.ts +++ b/src/providers/EndpointTreeProvider.ts @@ -6,18 +6,39 @@ import { TreeItem, TreeItemCollapsibleState, } from "vscode" +import { stripLeadingDynamicSegments } from "../core/pathUtils" import type { AppDefinition, - EndpointTreeItem, + RouteDefinition, RouteMethod, -} from "../types/endpoint" + RouterDefinition, +} from "../core/types" + +export type EndpointTreeItem = + | { type: "workspace"; label: string; apps: AppDefinition[] } + | { type: "app"; app: AppDefinition } + | { type: "router"; router: RouterDefinition } + | { type: "route"; route: RouteDefinition } + | { type: "message"; text: string } type GroupingFunction = (apps: AppDefinition[]) => EndpointTreeItem[] -// Default grouping: apps directly at root level +/** Default grouping: apps directly at root level */ const defaultGrouping: GroupingFunction = (apps) => apps.map((app) => ({ type: "app" as const, app })) +/** Method icons for route display */ +const METHOD_ICONS: Record = { + GET: "arrow-right", + POST: "plus", + PUT: "edit", + DELETE: "trash", + PATCH: "pencil", + OPTIONS: "settings-gear", + HEAD: "eye", + WEBSOCKET: "broadcast", +} + export class EndpointTreeProvider implements TreeDataProvider { @@ -27,6 +48,8 @@ export class EndpointTreeProvider private apps: AppDefinition[] = [] private groupApps: GroupingFunction + private routersExpanded = false + private toggleCount = 0 constructor(apps: AppDefinition[] = [], groupApps?: GroupingFunction) { this.apps = apps @@ -42,58 +65,214 @@ export class EndpointTreeProvider } private getMethodIcon(method: RouteMethod): ThemeIcon { - switch (method) { - case "GET": - return new ThemeIcon("arrow-right") - case "POST": - return new ThemeIcon("plus") - case "PUT": - return new ThemeIcon("edit") - case "DELETE": - return new ThemeIcon("trash") - case "PATCH": - return new ThemeIcon("pencil") - case "OPTIONS": - return new ThemeIcon("settings-gear") - case "HEAD": - return new ThemeIcon("eye") - case "WEBSOCKET": - return new ThemeIcon("broadcast") + return new ThemeIcon(METHOD_ICONS[method]) + } + + /** + * Gets the display label for a router (used for sorting). + * Uses prefix (stripped), then tag, then filename as fallback. + */ + private getRouterSortKey(router: RouterDefinition): string { + const strippedPrefix = stripLeadingDynamicSegments(router.prefix) + if (strippedPrefix !== "/") { + return strippedPrefix.toLowerCase() + } + if (router.tags.length > 0) { + return router.tags[0].toLowerCase() + } + const fileName = router.location.filePath.split("/").pop() ?? "" + return fileName.replace(/\.py$/, "").toLowerCase() + } + + /** + * Gets the display path for a route (used for sorting). + */ + private getRouteSortKey(route: RouteDefinition): string { + const path = + stripLeadingDynamicSegments(route.path).replace(/^\//, "") || "/" + return `${route.method} ${path}`.toLowerCase() + } + + /** Counts total routes including nested children. */ + private getTotalRouteCount(router: RouterDefinition): number { + return ( + router.routes.length + + router.children.reduce( + (sum, child) => sum + this.getTotalRouteCount(child), + 0, + ) + ) + } + + /** + * Generic search through router tree. + * Returns the router that matches the predicate. + */ + private searchRouters( + predicate: (router: RouterDefinition) => boolean, + ): RouterDefinition | undefined { + const searchIn = ( + routers: RouterDefinition[], + ): RouterDefinition | undefined => { + for (const router of routers) { + if (predicate(router)) { + return router + } + const found = searchIn(router.children) + if (found) return found + } + return undefined + } + + for (const app of this.apps) { + const found = searchIn(app.routers) + if (found) return found + } + return undefined + } + + /** + * Finds the parent router if this router is nested. + */ + private findParentRouter( + target: RouterDefinition, + ): RouterDefinition | undefined { + return this.searchRouters((router) => router.children.includes(target)) + } + + /** + * Finds the router that contains this route. + */ + private findParentRouterForRoute( + target: RouteDefinition, + ): RouterDefinition | undefined { + return this.searchRouters((router) => router.routes.includes(target)) + } + + /** + * Calculates the relative path given a full path and a parent prefix. + * Returns the path relative to the parent, or the original if no meaningful parent. + */ + private getRelativePath(fullPath: string, parentPrefix: string): string { + if (parentPrefix === "/") { + return fullPath + } + if (fullPath.startsWith(`${parentPrefix}/`)) { + return fullPath.slice(parentPrefix.length) + } + if (fullPath.startsWith(parentPrefix)) { + return fullPath.slice(parentPrefix.length) || "/" + } + return fullPath + } + + /** + * Sorts and maps routers to tree items. + */ + private sortedRouterItems( + routers: RouterDefinition[], + ): { type: "router"; router: RouterDefinition }[] { + return routers + .map((router) => ({ type: "router" as const, router })) + .sort((a, b) => + this.getRouterSortKey(a.router).localeCompare( + this.getRouterSortKey(b.router), + ), + ) + } + + /** + * Sorts and maps routes to tree items. + */ + private sortedRouteItems( + routes: RouteDefinition[], + ): { type: "route"; route: RouteDefinition }[] { + return routes + .map((route) => ({ type: "route" as const, route })) + .sort((a, b) => + this.getRouteSortKey(a.route).localeCompare( + this.getRouteSortKey(b.route), + ), + ) + } + + getParent(element: EndpointTreeItem): EndpointTreeItem | undefined { + switch (element.type) { + case "message": + case "workspace": + // Root level items have no parent + return undefined + + case "app": { + // Check if apps are grouped under workspaces + const rootItems = this.groupApps(this.apps) + return rootItems.find( + (root) => + root.type === "workspace" && root.apps.includes(element.app), + ) + } + + case "router": { + // Check if router is nested under another router + const parentRouter = this.findParentRouter(element.router) + if (parentRouter) { + return { type: "router", router: parentRouter } + } + // Find which app contains this router at top level + const app = this.apps.find((a) => a.routers.includes(element.router)) + return app ? { type: "app", app } : undefined + } + + case "route": { + // Check if route belongs to a router (including nested routers) + const parentRouter = this.findParentRouterForRoute(element.route) + if (parentRouter) { + return { type: "router", router: parentRouter } + } + // Check if route is directly on an app + const app = this.apps.find((a) => a.routes.includes(element.route)) + return app ? { type: "app", app } : undefined + } } } getChildren(element?: EndpointTreeItem): EndpointTreeItem[] { if (!element) { - // Root level: use grouping function (may return workspaces or apps) + if (this.apps.length === 0) { + return [{ type: "message", text: "No FastAPI app found" }] + } return this.groupApps(this.apps) } switch (element.type) { case "workspace": - return element.apps.map((app) => ({ type: "app" as const, app })) - case "app": { - const routers = element.app.routers.map((router) => ({ - type: "router" as const, - router, - })) - const routes = element.app.routes.map((route) => ({ - type: "route" as const, - route, - })) - return [...routers, ...routes] - } + return element.apps + .map((app) => ({ type: "app" as const, app })) + .sort((a, b) => a.app.name.localeCompare(b.app.name)) + case "app": + return [ + ...this.sortedRouterItems(element.app.routers), + ...this.sortedRouteItems(element.app.routes), + ] case "router": - return element.router.routes.map((route) => ({ - type: "route" as const, - route, - })) + // Child routers first, then routes + return [ + ...this.sortedRouterItems(element.router.children), + ...this.sortedRouteItems(element.router.routes), + ] case "route": + case "message": return [] } } getTreeItem(element: EndpointTreeItem): TreeItem { switch (element.type) { + case "message": { + const item = new TreeItem(element.text) + item.iconPath = new ThemeIcon("info") + return item + } case "workspace": { const workspaceItem = new TreeItem( element.label, @@ -105,35 +284,90 @@ export class EndpointTreeProvider } case "app": { + // Use a more descriptive name when the variable name is generic (like "app") + // Include the parent directory to disambiguate multiple apps + let appLabel = element.app.name + if (appLabel === "app" || this.apps.length > 1) { + // Extract meaningful context from the file path + // e.g., "backend/app/main.py" -> "backend/app" + const pathParts = element.app.filePath.split("/") + const fileName = pathParts.pop() ?? "" + const parentDir = pathParts.pop() ?? "" + const grandParentDir = pathParts.pop() ?? "" + + if (parentDir && parentDir !== "src" && parentDir !== "app") { + appLabel = parentDir + } else if (grandParentDir) { + appLabel = `${grandParentDir}/${parentDir}` + } else { + appLabel = fileName.replace(/\.py$/, "") + } + } const appItem = new TreeItem( - element.app.name, + appLabel, TreeItemCollapsibleState.Expanded, ) appItem.iconPath = new ThemeIcon("root-folder") appItem.contextValue = "app" + appItem.description = element.app.name // Show the actual variable name as description return appItem } case "router": { - const routerItem = new TreeItem( + // Use prefix as label, showing relative path if nested under a parent + const strippedPrefix = stripLeadingDynamicSegments( element.router.prefix, - TreeItemCollapsibleState.Collapsed, ) + + const parentRouter = this.findParentRouter(element.router) + const parentPrefix = parentRouter + ? stripLeadingDynamicSegments(parentRouter.prefix) + : "/" + const displayPrefix = this.getRelativePath(strippedPrefix, parentPrefix) + + let routerLabel = displayPrefix !== "/" ? displayPrefix : "" + if (!routerLabel) { + // Fallback: use tag, then filename + if (element.router.tags.length > 0) { + routerLabel = `/${element.router.tags[0]}` + } else { + const parts = element.router.location.filePath.split("/") + const fileName = parts.pop()?.replace(/\.py$/, "") ?? "" + // Use parent directory for generic filenames + if (fileName === "router" || fileName === "routes") { + routerLabel = parts.pop() ?? fileName + } else { + routerLabel = fileName + } + } + } + const routerItem = new TreeItem( + routerLabel, + this.routersExpanded + ? TreeItemCollapsibleState.Expanded + : TreeItemCollapsibleState.Collapsed, + ) + // Unique id that changes with toggle to force VS Code to re-render + // Include file path to differentiate routers with same prefix from different files + routerItem.id = `router-${element.router.location.filePath}-${element.router.prefix}-${this.toggleCount}` routerItem.iconPath = new ThemeIcon("symbol-namespace") + const totalRoutes = this.getTotalRouteCount(element.router) routerItem.description = - element.router.routes.length !== 1 - ? `${element.router.routes.length} routes` - : "1 route" + totalRoutes !== 1 ? `${totalRoutes} routes` : "1 route" routerItem.contextValue = "router" return routerItem } case "route": { + // Only strip leading dynamic segments (like {settings.API_V1_STR}) + // Keep the full path otherwise for clarity + const displayPath = stripLeadingDynamicSegments(element.route.path) + const label = element.route.method === "WEBSOCKET" - ? element.route.path - : `${element.route.method} ${element.route.path}` + ? displayPath + : `${element.route.method} ${displayPath}` const routeItem = new TreeItem(label) routeItem.description = element.route.functionName @@ -143,9 +377,9 @@ export class EndpointTreeProvider `${element.route.method} ${element.route.path}\n\nFunction: ${element.route.functionName}\nFile: ${element.route.location.filePath}:${element.route.location.line}`, ) routeItem.command = { - command: "fastapi-vscode.goToEndpoint", + command: "fastapi-vscode.openLocation", title: "Go to Definition", - arguments: [element], + arguments: [element.route.location], } return routeItem } @@ -156,6 +390,12 @@ export class EndpointTreeProvider this._onDidChangeTreeData.fire(undefined) } + toggleRouters(): void { + this.routersExpanded = !this.routersExpanded + this.toggleCount++ + this.refresh() + } + dispose(): void { this._onDidChangeTreeData.dispose() } diff --git a/src/providers/TestCodeLensProvider.ts b/src/providers/TestCodeLensProvider.ts new file mode 100644 index 0000000..8ef2ccd --- /dev/null +++ b/src/providers/TestCodeLensProvider.ts @@ -0,0 +1,185 @@ +/** + * CodeLens provider for FastAPI test client HTTP calls. + * Shows "Go to route" links above test client method calls. + */ + +import { + CodeLens, + type CodeLensProvider, + EventEmitter, + Location, + Position, + Range, + type TextDocument, + Uri, +} from "vscode" +import type { Node } from "web-tree-sitter" +import { extractStringValue, findNodesByType } from "../core/extractors" +import { ROUTE_METHODS } from "../core/internal" +import type { Parser } from "../core/parser" +import { + pathMatchesEndpoint, + stripLeadingDynamicSegments, +} from "../core/pathUtils" +import type { + AppDefinition, + RouteDefinition, + RouterDefinition, + SourceLocation, +} from "../core/types" + +interface TestClientCall { + method: string + path: string + line: number + column: number +} + +export class TestCodeLensProvider implements CodeLensProvider { + private apps: AppDefinition[] = [] + private parser: Parser + private _onDidChangeCodeLenses = new EventEmitter() + readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event + + constructor(parser: Parser, apps: AppDefinition[]) { + this.parser = parser + this.apps = apps + } + + setApps(apps: AppDefinition[]): void { + this.apps = apps + this._onDidChangeCodeLenses.fire() + } + + provideCodeLenses(document: TextDocument): CodeLens[] { + const code = document.getText() + const tree = this.parser.parse(code) + if (!tree) { + return [] + } + + const testClientCalls = this.findTestClientCalls(tree.rootNode) + + const codeLenses: CodeLens[] = [] + + for (const call of testClientCalls) { + const matchingRoutes = this.findMatchingRoutes(call.path, call.method) + + if (matchingRoutes.length > 0) { + const range = new Range( + new Position(call.line, call.column), + new Position(call.line, call.column), + ) + + const methodUpper = call.method.toUpperCase() + const displayPath = stripLeadingDynamicSegments(call.path) + const locations = matchingRoutes.map( + (loc) => + new Location( + Uri.file(loc.filePath), + new Position(loc.line - 1, loc.column), + ), + ) + codeLenses.push( + new CodeLens(range, { + title: `Go to route: ${methodUpper} ${displayPath}`, + command: "fastapi-vscode.goToDefinition", + arguments: [ + locations, + document.uri, + new Position(call.line, call.column), + ], + }), + ) + } + } + + return codeLenses + } + + private findTestClientCalls(rootNode: Node): TestClientCall[] { + const calls: TestClientCall[] = [] + const callNodes = findNodesByType(rootNode, "call") + + for (const callNode of callNodes) { + const functionNode = callNode.childForFieldName("function") + if (!functionNode || functionNode.type !== "attribute") { + continue + } + + const methodNode = functionNode.childForFieldName("attribute") + if (!methodNode) { + continue + } + + const method = methodNode.text.toLowerCase() + if (!ROUTE_METHODS.has(method)) { + continue + } + + // Get the path argument (first argument) + const argumentsNode = callNode.childForFieldName("arguments") + if (!argumentsNode) { + continue + } + + const args = argumentsNode.namedChildren.filter( + (child) => child.type !== "comment", + ) + + if (args.length === 0) { + continue + } + + const pathArg = args[0] + // Only handle string literals for now + const path = extractStringValue(pathArg) + if (path === null) { + continue + } + + calls.push({ + method, + path, + line: callNode.startPosition.row, + column: callNode.startPosition.column, + }) + } + + return calls + } + + private findMatchingRoutes( + testPath: string, + testMethod: string, + ): SourceLocation[] { + const matches: SourceLocation[] = [] + + const collectRoutes = (routes: RouteDefinition[]) => { + for (const route of routes) { + if ( + route.method.toLowerCase() === testMethod.toLowerCase() && + pathMatchesEndpoint(testPath, route.path) + ) { + matches.push(route.location) + } + } + } + + const walkRouters = (routers: RouterDefinition[]) => { + for (const router of routers) { + collectRoutes(router.routes) + if (router.children) { + walkRouters(router.children) + } + } + } + + for (const app of this.apps) { + collectRoutes(app.routes) + walkRouters(app.routers) + } + + return matches + } +} diff --git a/src/providers/index.ts b/src/providers/index.ts deleted file mode 100644 index 336ce12..0000000 --- a/src/providers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/src/services/index.ts b/src/services/index.ts deleted file mode 100644 index 336ce12..0000000 --- a/src/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/src/test/core/analyzer.test.ts b/src/test/core/analyzer.test.ts new file mode 100644 index 0000000..75b8faa --- /dev/null +++ b/src/test/core/analyzer.test.ts @@ -0,0 +1,156 @@ +import * as assert from "node:assert" +import { analyzeFile, analyzeTree } from "../../core/analyzer" +import { Parser } from "../../core/parser" +import { fixtures, wasmPaths } from "../testUtils" + +suite("analyzer", () => { + let parser: Parser + + const parse = (code: string) => { + const tree = parser.parse(code) + assert.ok(tree, "Failed to parse code") + return tree + } + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + suite("analyzeTree", () => { + test("extracts routes from decorated functions", () => { + const code = ` +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/") +def list_items(): + pass + +@router.post("/") +def create_item(): + pass +` + const tree = parse(code) + const result = analyzeTree(tree, "/test/file.py") + + assert.strictEqual(result.routes.length, 2) + assert.strictEqual(result.routes[0].method, "get") + assert.strictEqual(result.routes[0].path, "/") + assert.strictEqual(result.routes[1].method, "post") + }) + + test("extracts routers from assignments", () => { + const code = ` +from fastapi import FastAPI, APIRouter + +app = FastAPI() +router = APIRouter(prefix="/api") +` + const tree = parse(code) + const result = analyzeTree(tree, "/test/file.py") + + assert.strictEqual(result.routers.length, 2) + assert.strictEqual(result.routers[0].variableName, "app") + assert.strictEqual(result.routers[0].type, "FastAPI") + assert.strictEqual(result.routers[1].variableName, "router") + assert.strictEqual(result.routers[1].type, "APIRouter") + assert.strictEqual(result.routers[1].prefix, "/api") + }) + + test("extracts include_router calls", () => { + const code = ` +app.include_router(users.router, prefix="/users") +app.include_router(items.router, prefix="/items") +` + const tree = parse(code) + const result = analyzeTree(tree, "/test/file.py") + + assert.strictEqual(result.includeRouters.length, 2) + assert.strictEqual(result.includeRouters[0].router, "users.router") + assert.strictEqual(result.includeRouters[0].prefix, "/users") + assert.strictEqual(result.includeRouters[1].router, "items.router") + assert.strictEqual(result.includeRouters[1].prefix, "/items") + }) + + test("extracts imports", () => { + const code = ` +from fastapi import FastAPI +from .routes import users, items +import os +` + const tree = parse(code) + const result = analyzeTree(tree, "/test/file.py") + + assert.strictEqual(result.imports.length, 3) + + const fastapiImport = result.imports.find( + (i) => i.modulePath === "fastapi", + ) + assert.ok(fastapiImport) + assert.deepStrictEqual(fastapiImport.names, ["FastAPI"]) + + const routesImport = result.imports.find((i) => i.modulePath === "routes") + assert.ok(routesImport) + assert.strictEqual(routesImport.isRelative, true) + }) + + test("sets filePath correctly", () => { + const code = "x = 1" + const tree = parse(code) + const result = analyzeTree(tree, "/custom/path.py") + + assert.strictEqual(result.filePath, "/custom/path.py") + }) + }) + + suite("analyzeFile", () => { + test("analyzes main.py fixture", () => { + const result = analyzeFile(fixtures.standard.mainPy, parser) + + assert.ok(result) + assert.strictEqual(result.filePath, fixtures.standard.mainPy) + + // Should find FastAPI app + const fastApiRouter = result.routers.find((r) => r.type === "FastAPI") + assert.ok(fastApiRouter) + assert.strictEqual(fastApiRouter.variableName, "app") + + // Should find include_router calls + assert.ok(result.includeRouters.length > 0) + + // Should find health check route + const healthRoute = result.routes.find((r) => r.path === "/health") + assert.ok(healthRoute) + assert.strictEqual(healthRoute.method, "get") + }) + + test("analyzes users.py fixture", () => { + const result = analyzeFile(fixtures.standard.usersPy, parser) + + assert.ok(result) + + // Should find APIRouter + const apiRouter = result.routers.find((r) => r.type === "APIRouter") + assert.ok(apiRouter) + + // Should find routes (users.py has 3 routes: list, get, create) + assert.ok(result.routes.length >= 3) + + // Check specific routes exist + const methods = result.routes.map((r) => r.method) + assert.ok(methods.includes("get")) + assert.ok(methods.includes("post")) + }) + + test("returns null for non-existent file", () => { + const result = analyzeFile("/nonexistent/file.py", parser) + assert.strictEqual(result, null) + }) + }) +}) diff --git a/src/test/core/extractors.test.ts b/src/test/core/extractors.test.ts new file mode 100644 index 0000000..c5b7ab0 --- /dev/null +++ b/src/test/core/extractors.test.ts @@ -0,0 +1,519 @@ +import * as assert from "node:assert" +import { + decoratorExtractor, + findNodesByType, + importExtractor, + includeRouterExtractor, + mountExtractor, + routerExtractor, +} from "../../core/extractors" +import { Parser } from "../../core/parser" +import { wasmPaths } from "../testUtils" + +suite("Extractors", () => { + let parser: Parser + + // Helper to parse code and assert tree is not null + const parse = (code: string) => { + const tree = parser.parse(code) + assert.ok(tree, "Failed to parse code") + return tree + } + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + suite("decoratorExtractor", () => { + test("extracts simple route decorator", () => { + const code = ` +@router.get("/users") +def list_users(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + assert.strictEqual(decoratedDefs.length, 1) + + const result = decoratorExtractor(decoratedDefs[0]) + assert.ok(result) + assert.strictEqual(result.object, "router") + assert.strictEqual(result.method, "get") + assert.strictEqual(result.path, "/users") + assert.strictEqual(result.function, "list_users") + }) + + test("extracts route with path parameter", () => { + const code = ` +@router.get("/users/{user_id}") +def get_user(user_id: int): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "/users/{user_id}") + }) + + test("extracts POST route", () => { + const code = ` +@app.post("/items") +def create_item(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.object, "app") + assert.strictEqual(result.method, "post") + assert.strictEqual(result.path, "/items") + }) + + test("extracts websocket route", () => { + const code = ` +@router.websocket("/ws") +def websocket_handler(websocket: WebSocket): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.method, "websocket") + assert.strictEqual(result.path, "/ws") + }) + + test("handles dynamic path with variable", () => { + const code = ` +@router.get(BASE_PATH) +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "{BASE_PATH}") + }) + + test("handles dynamic path with attribute", () => { + const code = ` +@router.get(settings.API_PREFIX) +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "{settings.API_PREFIX}") + }) + + test("handles path concatenation", () => { + const code = ` +@router.get(BASE + "/users") +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "{BASE}/users") + }) + + test("returns null for non-decorated definition", () => { + const code = ` +def regular_function(): + pass +` + const tree = parse(code) + const funcDefs = findNodesByType(tree.rootNode, "function_definition") + const result = decoratorExtractor(funcDefs[0]) + + assert.strictEqual(result, null) + }) + + test("includes line and column information", () => { + const code = ` +@router.get("/test") +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.line, 2) // 1-indexed + assert.strictEqual(result.column, 0) + }) + }) + + suite("routerExtractor", () => { + test("extracts FastAPI app instantiation", () => { + const code = "app = FastAPI()" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.variableName, "app") + assert.strictEqual(result.type, "FastAPI") + assert.strictEqual(result.prefix, "") + }) + + test("extracts APIRouter instantiation", () => { + const code = "router = APIRouter()" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.variableName, "router") + assert.strictEqual(result.type, "APIRouter") + }) + + test("extracts APIRouter with prefix", () => { + const code = `router = APIRouter(prefix="/users")` + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.prefix, "/users") + }) + + test("extracts APIRouter with tags", () => { + const code = `router = APIRouter(tags=["users", "admin"])` + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.deepStrictEqual(result.tags, ["users", "admin"]) + }) + + test("extracts APIRouter with prefix and tags", () => { + const code = `router = APIRouter(prefix="/api", tags=["api"])` + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.prefix, "/api") + assert.deepStrictEqual(result.tags, ["api"]) + }) + + test("handles dynamic prefix", () => { + const code = "router = APIRouter(prefix=settings.API_PREFIX)" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.prefix, "{settings.API_PREFIX}") + }) + + test("returns null for non-router assignment", () => { + const code = "x = 5" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.strictEqual(result, null) + }) + + test("returns null for other function call", () => { + const code = "result = some_function()" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.strictEqual(result, null) + }) + + test("extracts qualified fastapi.FastAPI() call", () => { + const code = "app = fastapi.FastAPI()" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.variableName, "app") + assert.strictEqual(result.type, "FastAPI") + }) + + test("extracts qualified fastapi.APIRouter() call", () => { + const code = "router = fastapi.APIRouter(prefix='/api')" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = routerExtractor(assignments[0]) + + assert.ok(result) + assert.strictEqual(result.variableName, "router") + assert.strictEqual(result.type, "APIRouter") + assert.strictEqual(result.prefix, "/api") + }) + }) + + suite("importExtractor", () => { + test("extracts simple import", () => { + const code = "import fastapi" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.strictEqual(result.modulePath, "fastapi") + assert.deepStrictEqual(result.names, ["fastapi"]) + assert.strictEqual(result.isRelative, false) + }) + + test("extracts from import", () => { + const code = "from fastapi import FastAPI" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_from_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.strictEqual(result.modulePath, "fastapi") + assert.deepStrictEqual(result.names, ["FastAPI"]) + assert.strictEqual(result.isRelative, false) + }) + + test("extracts relative import with single dot", () => { + const code = "from .routes import users" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_from_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.strictEqual(result.modulePath, "routes") + assert.strictEqual(result.isRelative, true) + assert.strictEqual(result.relativeDots, 1) + }) + + test("extracts relative import with double dot", () => { + const code = "from ..api import router" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_from_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.strictEqual(result.modulePath, "api") + assert.strictEqual(result.isRelative, true) + assert.strictEqual(result.relativeDots, 2) + }) + + test("extracts import with alias", () => { + const code = "from .users import router as users_router" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_from_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.deepStrictEqual(result.names, ["users_router"]) + assert.deepStrictEqual(result.namedImports, [ + { name: "router", alias: "users_router" }, + ]) + }) + + test("extracts multiple imports", () => { + const code = "from fastapi import FastAPI, APIRouter" + const tree = parse(code) + const imports = findNodesByType(tree.rootNode, "import_from_statement") + const result = importExtractor(imports[0]) + + assert.ok(result) + assert.ok(result.names.includes("FastAPI")) + assert.ok(result.names.includes("APIRouter")) + }) + + test("returns null for non-import node", () => { + const code = "x = 5" + const tree = parse(code) + const assignments = findNodesByType(tree.rootNode, "assignment") + const result = importExtractor(assignments[0]) + + assert.strictEqual(result, null) + }) + }) + + suite("includeRouterExtractor", () => { + test("extracts include_router call", () => { + const code = "app.include_router(users.router)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.ok(result) + assert.strictEqual(result.object, "app") + assert.strictEqual(result.router, "users.router") + assert.strictEqual(result.prefix, "") + }) + + test("extracts include_router with prefix", () => { + const code = `app.include_router(users.router, prefix="/users")` + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.ok(result) + assert.strictEqual(result.prefix, "/users") + }) + + test("extracts include_router with dynamic prefix", () => { + const code = "app.include_router(router, prefix=settings.PREFIX)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.ok(result) + assert.strictEqual(result.prefix, "{settings.PREFIX}") + }) + + test("extracts include_router with tags", () => { + const code = `app.include_router(router, tags=["users", "admin"])` + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.ok(result) + assert.deepStrictEqual(result.tags, ["users", "admin"]) + }) + + test("returns null for non-include_router call", () => { + const code = "app.some_method(arg)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.strictEqual(result, null) + }) + + test("returns null for function call (not method)", () => { + const code = "include_router(router)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = includeRouterExtractor(calls[0]) + + assert.strictEqual(result, null) + }) + }) + + suite("mountExtractor", () => { + test("extracts mount call", () => { + const code = `app.mount("/static", static_app)` + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = mountExtractor(calls[0]) + + assert.ok(result) + assert.strictEqual(result.object, "app") + assert.strictEqual(result.path, "/static") + assert.strictEqual(result.app, "static_app") + }) + + test("extracts mount with dynamic path", () => { + const code = "app.mount(settings.STATIC_PATH, static_app)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = mountExtractor(calls[0]) + + assert.ok(result) + assert.strictEqual(result.path, "{settings.STATIC_PATH}") + }) + + test("returns null for non-mount call", () => { + const code = "app.some_method(arg1, arg2)" + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = mountExtractor(calls[0]) + + assert.strictEqual(result, null) + }) + + test("returns null for mount with missing arguments", () => { + const code = `app.mount("/static")` + const tree = parse(code) + const calls = findNodesByType(tree.rootNode, "call") + const result = mountExtractor(calls[0]) + + assert.strictEqual(result, null) + }) + }) + + suite("decoratorExtractor path handling", () => { + test("handles concatenated strings", () => { + const code = ` +@router.get("/api" "/v1" "/users") +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "/api/v1/users") + }) + + test("handles function call as path", () => { + const code = ` +@router.get(get_path()) +def handler(): + pass +` + const tree = parse(code) + const decoratedDefs = findNodesByType( + tree.rootNode, + "decorated_definition", + ) + const result = decoratorExtractor(decoratedDefs[0]) + + assert.ok(result) + assert.strictEqual(result.path, "{get_path()}") + }) + }) +}) diff --git a/src/test/core/importResolver.test.ts b/src/test/core/importResolver.test.ts new file mode 100644 index 0000000..f5d4860 --- /dev/null +++ b/src/test/core/importResolver.test.ts @@ -0,0 +1,210 @@ +import * as assert from "node:assert" +import { join } from "node:path" +import { resolveImport, resolveNamedImport } from "../../core/importResolver" +import { Parser } from "../../core/parser" +import { fixtures, wasmPaths } from "../testUtils" + +const standardRoot = fixtures.standard.root + +suite("importResolver", () => { + let parser: Parser + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + suite("resolveImport", () => { + test("resolves relative import to .py file", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + const result = resolveImport( + { modulePath: "routes.users", isRelative: true, relativeDots: 1 }, + currentFile, + projectRoot, + ) + + assert.ok(result) + assert.ok(result.endsWith("users.py")) + }) + + test("resolves relative import to __init__.py", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + const result = resolveImport( + { modulePath: "routes", isRelative: true, relativeDots: 1 }, + currentFile, + projectRoot, + ) + + assert.ok(result) + assert.ok(result.endsWith("__init__.py")) + }) + + test("resolves double-dot relative import", () => { + // from .. import something (2 dots, no module name) + // From app/routes/users.py, this goes to parent package (app) + const currentFile = join(standardRoot, "app", "routes", "users.py") + const projectRoot = standardRoot + + const result = resolveImport( + { modulePath: "", isRelative: true, relativeDots: 2 }, + currentFile, + projectRoot, + ) + + // 2 dots from routes/users.py goes to app/ + assert.ok(result) + assert.ok(result.endsWith("app/__init__.py")) + }) + + test("resolves absolute import", () => { + const currentFile = join(standardRoot, "main.py") + const projectRoot = standardRoot + + const result = resolveImport( + { + modulePath: "app.routes.users", + isRelative: false, + relativeDots: 0, + }, + currentFile, + projectRoot, + ) + + assert.ok(result) + assert.ok(result.endsWith("users.py")) + }) + + test("returns null for non-existent module", () => { + const currentFile = join(standardRoot, "main.py") + const projectRoot = standardRoot + + const result = resolveImport( + { + modulePath: "nonexistent.module", + isRelative: false, + relativeDots: 0, + }, + currentFile, + projectRoot, + ) + + assert.strictEqual(result, null) + }) + }) + + suite("resolveNamedImport", () => { + test("resolves named import to .py file", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + const result = resolveNamedImport( + { + modulePath: "routes", + names: ["users"], + isRelative: true, + relativeDots: 1, + }, + currentFile, + projectRoot, + parser, + ) + + assert.ok(result) + assert.ok(result.endsWith("users.py")) + }) + + test("resolves re-exported name from __init__.py", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + // The __init__.py has: from .users import router as users_router + const result = resolveNamedImport( + { + modulePath: "routes", + names: ["users_router"], + isRelative: true, + relativeDots: 1, + }, + currentFile, + projectRoot, + parser, + ) + + assert.ok(result) + assert.ok(result.endsWith("users.py")) + }) + + test("falls back to base module for non-existent named import", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + const result = resolveNamedImport( + { + modulePath: "routes", + names: ["nonexistent"], + isRelative: true, + relativeDots: 1, + }, + currentFile, + projectRoot, + parser, + ) + + // Falls back to the base module when named import not found + assert.ok(result) + assert.ok( + result.endsWith("routes/__init__.py") || result.endsWith("routes.py"), + ) + }) + + test("resolves relative named import from namespace package (no __init__.py)", () => { + const currentFile = join(standardRoot, "app", "main.py") + const projectRoot = standardRoot + + // namespace_routes has no __init__.py, but api_routes.py exists + const result = resolveNamedImport( + { + modulePath: "namespace_routes", + names: ["api_routes"], + isRelative: true, + relativeDots: 1, + }, + currentFile, + projectRoot, + parser, + ) + + assert.ok(result) + assert.ok(result.endsWith("api_routes.py")) + }) + + test("resolves absolute named import from namespace package (no __init__.py)", () => { + const currentFile = join(standardRoot, "main.py") + const projectRoot = standardRoot + + // app.namespace_routes has no __init__.py, but api_routes.py exists + const result = resolveNamedImport( + { + modulePath: "app.namespace_routes", + names: ["api_routes"], + isRelative: false, + relativeDots: 0, + }, + currentFile, + projectRoot, + parser, + ) + + assert.ok(result) + assert.ok(result.endsWith("api_routes.py")) + }) + }) +}) diff --git a/src/test/core/parser.test.ts b/src/test/core/parser.test.ts new file mode 100644 index 0000000..aca2415 --- /dev/null +++ b/src/test/core/parser.test.ts @@ -0,0 +1,60 @@ +import * as assert from "node:assert" +import { Parser } from "../../core/parser" +import { wasmPaths } from "../testUtils" + +suite("parser", () => { + test("throws error if parse called before init", () => { + const parser = new Parser() + assert.throws(() => parser.parse("x = 1"), /not initialized/i) + }) + + test("parses Python code after init", async () => { + const parser = new Parser() + await parser.init(wasmPaths) + + const tree = parser.parse("x = 1") + assert.ok(tree) + assert.strictEqual(tree.rootNode.type, "module") + + parser.dispose() + }) + + test("double init is safe", async () => { + const parser = new Parser() + await parser.init(wasmPaths) + await parser.init(wasmPaths) // Should not throw + + const tree = parser.parse("y = 2") + assert.ok(tree) + + parser.dispose() + }) + + test("parses decorated function", async () => { + const parser = new Parser() + await parser.init(wasmPaths) + + const code = ` +@router.get("/users") +def get_users(): + pass +` + const tree = parser.parse(code) + assert.ok(tree) + + const decoratedDef = tree.rootNode.namedChildren.find( + (n) => n.type === "decorated_definition", + ) + assert.ok(decoratedDef) + + parser.dispose() + }) + + test("dispose is safe to call multiple times", async () => { + const parser = new Parser() + await parser.init(wasmPaths) + + parser.dispose() + parser.dispose() // Should not throw + }) +}) diff --git a/src/test/core/pathUtils.test.ts b/src/test/core/pathUtils.test.ts new file mode 100644 index 0000000..6d8b9f4 --- /dev/null +++ b/src/test/core/pathUtils.test.ts @@ -0,0 +1,257 @@ +import * as assert from "node:assert" +import { join } from "node:path" +import { + countSegments, + findProjectRoot, + getPathSegments, + isWithinDirectory, + pathMatchesEndpoint, + stripLeadingDynamicSegments, +} from "../../core/pathUtils" +import { fixtures } from "../testUtils" + +const standardRoot = fixtures.standard.root + +suite("pathUtils", () => { + suite("stripLeadingDynamicSegments", () => { + test("strips single dynamic segment", () => { + assert.strictEqual( + stripLeadingDynamicSegments("{settings.API_V1_STR}/users/{id}"), + "/users/{id}", + ) + }) + + test("strips multiple dynamic segments", () => { + assert.strictEqual( + stripLeadingDynamicSegments("{BASE}{VERSION}/api/items"), + "/api/items", + ) + }) + + test("leaves path parameters unchanged", () => { + assert.strictEqual( + stripLeadingDynamicSegments("/users/{id}/posts"), + "/users/{id}/posts", + ) + }) + + test("returns / for only dynamic segment", () => { + assert.strictEqual( + stripLeadingDynamicSegments("{settings.API_V1_STR}"), + "/", + ) + }) + + test("leaves static paths unchanged", () => { + assert.strictEqual( + stripLeadingDynamicSegments("/api/users"), + "/api/users", + ) + }) + + test("handles empty string", () => { + assert.strictEqual(stripLeadingDynamicSegments(""), "/") + }) + + test("handles root path", () => { + assert.strictEqual(stripLeadingDynamicSegments("/"), "/") + }) + }) + + suite("getPathSegments", () => { + test("gets first N segments", () => { + assert.strictEqual( + getPathSegments("/integrations/neon/foo", 2), + "/integrations/neon", + ) + }) + + test("gets single segment", () => { + assert.strictEqual(getPathSegments("/users/123/posts", 1), "/users") + }) + + test("returns full path if count exceeds segments", () => { + assert.strictEqual(getPathSegments("/a/b/c", 5), "/a/b/c") + }) + + test("returns full path if count equals segments", () => { + assert.strictEqual(getPathSegments("/a/b/c", 3), "/a/b/c") + }) + + test("handles root path", () => { + assert.strictEqual(getPathSegments("/", 1), "/") + }) + + test("handles zero count", () => { + assert.strictEqual(getPathSegments("/users/posts", 0), "/") + }) + }) + + suite("countSegments", () => { + test("counts multiple segments", () => { + assert.strictEqual(countSegments("/integrations/neon"), 2) + }) + + test("counts single segment", () => { + assert.strictEqual(countSegments("/users"), 1) + }) + + test("returns 0 for root path", () => { + assert.strictEqual(countSegments("/"), 0) + }) + + test("handles path without leading slash", () => { + assert.strictEqual(countSegments("users/posts"), 2) + }) + + test("handles empty string", () => { + assert.strictEqual(countSegments(""), 0) + }) + + test("ignores trailing slashes", () => { + assert.strictEqual(countSegments("/users/posts/"), 2) + }) + }) + + suite("isWithinDirectory", () => { + test("returns true for path inside directory", () => { + assert.strictEqual(isWithinDirectory("/foo/bar/baz", "/foo/bar"), true) + }) + + test("returns true for path equal to directory", () => { + assert.strictEqual(isWithinDirectory("/foo/bar", "/foo/bar"), true) + }) + + test("returns false for path outside directory", () => { + assert.strictEqual(isWithinDirectory("/foo/baz", "/foo/bar"), false) + }) + + test("returns false for sibling with similar prefix", () => { + // This is the key test - /foo/ba is NOT a parent of /foo/bar + assert.strictEqual(isWithinDirectory("/foo/bar", "/foo/ba"), false) + }) + + test("returns false for parent directory", () => { + assert.strictEqual(isWithinDirectory("/foo", "/foo/bar"), false) + }) + }) + + suite("findProjectRoot", () => { + test("returns entry dir when no __init__.py present", () => { + // main.py is at fixtures/standard/main.py, and fixtures/standard has no __init__.py + const mainPyPath = join(standardRoot, "main.py") + const result = findProjectRoot(mainPyPath, standardRoot) + + assert.strictEqual(result, standardRoot) + }) + + test("walks up to find project root from nested package", () => { + // users.py is in app/routes/users.py + // app has __init__.py, routes has __init__.py + // but fixtures/standard does not, so project root should be fixtures/standard + const usersPath = join(standardRoot, "app", "routes", "users.py") + const result = findProjectRoot(usersPath, standardRoot) + + assert.strictEqual(result, standardRoot) + }) + + test("returns workspace root when all dirs have __init__.py", () => { + // If we pretend the workspace root is app, it should return that + const usersPath = join(standardRoot, "app", "routes", "users.py") + const appRoot = join(standardRoot, "app") + const result = findProjectRoot(usersPath, appRoot) + + assert.strictEqual(result, appRoot) + }) + }) + + suite("pathMatchesEndpoint", () => { + test("matches exact static paths", () => { + assert.strictEqual(pathMatchesEndpoint("/items", "/items"), true) + assert.strictEqual(pathMatchesEndpoint("/api/users", "/api/users"), true) + }) + + test("matches path with single parameter", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123", "/items/{item_id}"), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/items/abc", "/items/{item_id}"), + true, + ) + }) + + test("matches path with multiple parameters", () => { + assert.strictEqual( + pathMatchesEndpoint( + "/users/abc/posts/456", + "/users/{user_id}/posts/{post_id}", + ), + true, + ) + }) + + test("rejects when segment count differs", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123/details", "/items/{item_id}"), + false, + ) + assert.strictEqual( + pathMatchesEndpoint("/items", "/items/{item_id}"), + false, + ) + }) + + test("rejects when static segments differ", () => { + assert.strictEqual( + pathMatchesEndpoint("/users/123", "/items/{item_id}"), + false, + ) + assert.strictEqual( + pathMatchesEndpoint("/api/v1/items", "/api/v2/items"), + false, + ) + }) + + test("handles trailing slashes", () => { + assert.strictEqual(pathMatchesEndpoint("/items/", "/items"), true) + assert.strictEqual(pathMatchesEndpoint("/items", "/items/"), true) + assert.strictEqual( + pathMatchesEndpoint("/items/123/", "/items/{id}"), + true, + ) + }) + + test("handles root path", () => { + assert.strictEqual(pathMatchesEndpoint("/", "/"), true) + assert.strictEqual(pathMatchesEndpoint("", ""), true) + }) + + test("rejects empty path against non-empty", () => { + assert.strictEqual( + pathMatchesEndpoint("/items/123", "/items/{item_id}"), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/items/", "/items/{item_id}"), + false, + ) + }) + + test("matches paths with dynamic prefix", () => { + // Dynamic prefixes like {settings.API_V1_STR} match any segment (same as path params) + assert.strictEqual( + pathMatchesEndpoint( + "/v1/items/123", + "{settings.API_V1_STR}/items/{item_id}", + ), + true, + ) + assert.strictEqual( + pathMatchesEndpoint("/api/v2/users", "{BASE}/users"), + false, // segment count differs + ) + }) + }) +}) diff --git a/src/test/core/routerResolver.test.ts b/src/test/core/routerResolver.test.ts new file mode 100644 index 0000000..8486c57 --- /dev/null +++ b/src/test/core/routerResolver.test.ts @@ -0,0 +1,188 @@ +import * as assert from "node:assert" +import { Parser } from "../../core/parser" +import { buildRouterGraph } from "../../core/routerResolver" +import { fixtures, fixturesPath, wasmPaths } from "../testUtils" + +suite("routerResolver", () => { + let parser: Parser + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + suite("buildRouterGraph", () => { + test("builds graph from main.py entry point", () => { + const result = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + assert.strictEqual(result.type, "FastAPI") + assert.strictEqual(result.variableName, "app") + assert.strictEqual(result.filePath, fixtures.standard.mainPy) + }) + + test("includes direct routes on app", () => { + const result = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + // app/main.py has @app.get("/health") + const healthRoute = result.routes.find((r) => r.path === "/health") + assert.ok(healthRoute) + assert.strictEqual(healthRoute.method, "get") + assert.strictEqual(healthRoute.function, "health") + }) + + test("follows include_router to child routers", () => { + const result = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + // app/main.py includes users and items routers + assert.ok( + result.children.length >= 2, + "Should have at least 2 child routers", + ) + }) + + test("captures prefix from router definition", () => { + const result = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + // users.router has prefix="/users" in its definition + const usersChild = result.children.find( + (c) => c.router.prefix === "/users", + ) + assert.ok(usersChild, "Should have child with /users prefix") + }) + + test("returns null for non-existent file", () => { + const result = buildRouterGraph( + "/nonexistent/file.py", + parser, + fixturesPath, + ) + assert.strictEqual(result, null) + }) + + test("returns null for file without FastAPI/APIRouter", () => { + const result = buildRouterGraph( + fixtures.standard.initPy, + parser, + fixtures.standard.root, + ) + + // __init__.py has no FastAPI or APIRouter + assert.strictEqual(result, null) + }) + + test("builds graph from APIRouter file", () => { + const result = buildRouterGraph( + fixtures.standard.usersPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + assert.strictEqual(result.type, "APIRouter") + assert.strictEqual(result.variableName, "router") + + // Should have routes (users.py has 3 routes: list, get, create) + assert.ok(result.routes.length >= 3) + }) + + test("includes line numbers for routes", () => { + const result = buildRouterGraph( + fixtures.standard.usersPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + for (const route of result.routes) { + assert.ok(route.line > 0, "Route should have valid line number") + assert.ok(route.column >= 0, "Route should have valid column number") + } + }) + + test("includes router location info", () => { + const result = buildRouterGraph( + fixtures.standard.usersPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + assert.strictEqual(result.filePath, fixtures.standard.usersPy) + assert.ok(result.line > 0) + assert.ok(result.column >= 0) + }) + + test("follows __init__.py re-exports to actual router file", () => { + // Use reexport fixture which has integrations/__init__.py re-exporting from router.py + const result = buildRouterGraph( + fixtures.reexport.initPy, + parser, + fixtures.reexport.root, + ) + + assert.ok(result, "Should find router via re-export") + assert.strictEqual(result.type, "APIRouter") + assert.strictEqual(result.variableName, "router") + + // Should point to router.py, not __init__.py + assert.ok( + result.filePath.endsWith("router.py"), + `Expected filePath to end with router.py, got ${result.filePath}`, + ) + + // Should have the routes defined in router.py (3 routes: github, slack, webhook) + assert.ok(result.routes.length >= 3, "Should have routes from router.py") + const githubRoute = result.routes.find((r) => r.path === "/github") + assert.ok(githubRoute, "Should find github route") + }) + + test("includes router when following include_router chain", () => { + const result = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + + assert.ok(result) + + // app/main.py includes users.router and items.router + assert.ok(result.children.length >= 2, "Should have child routers") + + // Find the users router child + const usersChild = result.children.find( + (c) => c.router.prefix === "/users", + ) + assert.ok(usersChild, "Should have users router child") + + // users router should have routes + assert.ok( + usersChild.router.routes.length >= 3, + "users router should have routes", + ) + }) + }) +}) diff --git a/src/test/core/transformer.test.ts b/src/test/core/transformer.test.ts new file mode 100644 index 0000000..2f7132d --- /dev/null +++ b/src/test/core/transformer.test.ts @@ -0,0 +1,276 @@ +import * as assert from "node:assert" +import { Parser } from "../../core/parser" +import { buildRouterGraph } from "../../core/routerResolver" +import { routerNodeToAppDefinition } from "../../core/transformer" +import { fixtures, wasmPaths } from "../testUtils" + +suite("transformer", () => { + let parser: Parser + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + suite("routerNodeToAppDefinition", () => { + test("transforms router graph to AppDefinition", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + assert.ok(result) + assert.strictEqual(result.name, "app") + assert.strictEqual(result.filePath, fixtures.standard.mainPy) + assert.strictEqual(result.workspaceFolder, "/workspace") + }) + + test("includes direct routes on app", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + // app/main.py has @app.get("/health") + const healthRoute = result.routes.find((r) => r.path === "/health") + assert.ok(healthRoute) + assert.strictEqual(healthRoute.method, "GET") + assert.strictEqual(healthRoute.functionName, "health") + }) + + test("flattens nested routers", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + // Should have routers from the include chain + assert.ok(result.routers.length > 0) + }) + + test("computes full path with prefixes", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + // The users router should have prefix="/users" from its definition + const usersRouter = result.routers.find((r) => + r.prefix.includes("/users"), + ) + assert.ok(usersRouter, "Should have users router") + assert.strictEqual( + usersRouter.prefix, + "/users", + "Users router should have /users prefix", + ) + }) + + test("normalizes HTTP methods to uppercase", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + for (const route of result.routes) { + assert.strictEqual( + route.method, + route.method.toUpperCase(), + "Method should be uppercase", + ) + } + + for (const router of result.routers) { + for (const route of router.routes) { + assert.strictEqual( + route.method, + route.method.toUpperCase(), + "Method should be uppercase", + ) + } + } + }) + + test("includes location info for routes", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + for (const route of result.routes) { + assert.ok(route.location.filePath) + assert.ok(route.location.line > 0) + assert.ok(route.location.column >= 0) + } + }) + + test("includes location info for routers", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + for (const router of result.routers) { + assert.ok(router.location.filePath) + assert.ok(router.location.line > 0) + assert.ok(router.location.column >= 0) + } + }) + + test("includes tags from routers", () => { + const routerNode = buildRouterGraph( + fixtures.standard.usersPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + // users.py has: router = APIRouter(prefix="/users", tags=["users"]) + assert.ok(routerNode.tags.includes("users")) + }) + + test("skips routers with no routes or children", () => { + const routerNode = buildRouterGraph( + fixtures.standard.mainPy, + parser, + fixtures.standard.root, + ) + assert.ok(routerNode) + + const result = routerNodeToAppDefinition(routerNode, "/workspace") + + // All routers in result should have at least one route OR children + // (synthetic group routers may have only children) + const checkRouter = (router: (typeof result.routers)[0]) => { + assert.ok( + router.routes.length > 0 || router.children.length > 0, + `Router ${router.name} should have routes or children`, + ) + for (const child of router.children) { + checkRouter(child) + } + } + for (const router of result.routers) { + checkRouter(router) + } + }) + + test("merges routers with same prefix from different files", () => { + // Routers with the same prefix should be merged for cleaner display + const mockRouterNode = { + type: "FastAPI" as const, + variableName: "app", + prefix: "", + tags: [], + routes: [], + filePath: "/test/main.py", + line: 1, + column: 0, + children: [ + { + prefix: "/api/v1", + tags: [], + router: { + type: "APIRouter" as const, + variableName: "login_router", + prefix: "", + tags: ["login"], + routes: [ + { + method: "post", + path: "/login", + function: "login", + line: 10, + column: 0, + }, + ], + filePath: "/test/login.py", + line: 5, + column: 0, + children: [], + }, + }, + { + prefix: "/api/v1", + tags: [], + router: { + type: "APIRouter" as const, + variableName: "utils_router", + prefix: "", + tags: ["utils"], + routes: [ + { + method: "get", + path: "/health", + function: "health", + line: 20, + column: 0, + }, + ], + filePath: "/test/utils.py", + line: 5, + column: 0, + children: [], + }, + }, + ], + } + + const result = routerNodeToAppDefinition(mockRouterNode, "/workspace") + + // Should have 1 merged router with routes from both files + assert.strictEqual( + result.routers.length, + 1, + "Should merge routers with same prefix", + ) + + const mergedRouter = result.routers[0] + assert.strictEqual( + mergedRouter.routes.length, + 2, + "Merged router should have both routes", + ) + assert.ok( + mergedRouter.routes.some((r) => r.functionName === "login"), + "Should have login route", + ) + assert.ok( + mergedRouter.routes.some((r) => r.functionName === "health"), + "Should have health route", + ) + }) + }) +}) diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 702e5ab..4bab7b8 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -3,11 +3,11 @@ import * as vscode from "vscode" suite("Extension Test Suite", () => { test("Extension should be present", () => { - assert.ok(vscode.extensions.getExtension("FastAPI Labs.fastapi-vscode")) + assert.ok(vscode.extensions.getExtension("FastAPILabs.fastapi-vscode")) }) test("Extension should activate", async () => { - const ext = vscode.extensions.getExtension("FastAPI Labs.fastapi-vscode") + const ext = vscode.extensions.getExtension("FastAPILabs.fastapi-vscode") assert.ok(ext) await ext.activate() assert.strictEqual(ext.isActive, true) diff --git a/src/test/fixtures/flat/main.py b/src/test/fixtures/flat/main.py new file mode 100644 index 0000000..247b186 --- /dev/null +++ b/src/test/fixtures/flat/main.py @@ -0,0 +1,12 @@ +from fastapi import FastAPI + +from routes import router + +app = FastAPI(title="Flat Layout") + +app.include_router(router) + + +@app.get("/") +def root(): + return {"message": "Hello from flat layout"} diff --git a/src/test/fixtures/flat/routes.py b/src/test/fixtures/flat/routes.py new file mode 100644 index 0000000..5c026fb --- /dev/null +++ b/src/test/fixtures/flat/routes.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/api", tags=["api"]) + + +@router.get("/users") +def list_users(): + return [{"id": 1, "name": "Alice"}] + + +@router.get("/items") +def list_items(): + return [{"id": 1, "name": "Widget"}] diff --git a/src/test/fixtures/mockEndpointData.ts b/src/test/fixtures/mockEndpointData.ts index fd699ef..0b6c0a8 100644 --- a/src/test/fixtures/mockEndpointData.ts +++ b/src/test/fixtures/mockEndpointData.ts @@ -1,4 +1,5 @@ -import type { AppDefinition, EndpointTreeItem } from "../../types/endpoint" +import type { AppDefinition } from "../../core/types" +import type { EndpointTreeItem } from "../../providers/EndpointTreeProvider" export const mockApps: AppDefinition[] = [ { @@ -9,6 +10,7 @@ export const mockApps: AppDefinition[] = [ { name: "users_router", prefix: "/api/v1/users", + tags: ["users"], location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", line: 5, @@ -17,7 +19,7 @@ export const mockApps: AppDefinition[] = [ routes: [ { method: "GET", - path: "/", + path: "/api/v1/users/", functionName: "list_users", location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", @@ -27,7 +29,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "POST", - path: "/", + path: "/api/v1/users/", functionName: "create_user", location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", @@ -37,7 +39,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "GET", - path: "/{user_id}", + path: "/api/v1/users/{user_id}", functionName: "get_user", location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", @@ -47,7 +49,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "PUT", - path: "/{user_id}", + path: "/api/v1/users/{user_id}", functionName: "update_user", location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", @@ -57,7 +59,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "DELETE", - path: "/{user_id}", + path: "/api/v1/users/{user_id}", functionName: "delete_user", location: { filePath: "/Users/dev/ecommerce-api/app/routers/users.py", @@ -66,10 +68,12 @@ export const mockApps: AppDefinition[] = [ }, }, ], + children: [], }, { name: "items_router", prefix: "/api/v1/items", + tags: ["items"], location: { filePath: "/Users/dev/ecommerce-api/app/routers/items.py", line: 5, @@ -78,7 +82,7 @@ export const mockApps: AppDefinition[] = [ routes: [ { method: "GET", - path: "/", + path: "/api/v1/items/", functionName: "list_items", location: { filePath: "/Users/dev/ecommerce-api/app/routers/items.py", @@ -88,7 +92,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "POST", - path: "/", + path: "/api/v1/items/", functionName: "create_item", location: { filePath: "/Users/dev/ecommerce-api/app/routers/items.py", @@ -98,7 +102,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "GET", - path: "/{item_id}", + path: "/api/v1/items/{item_id}", functionName: "get_item", location: { filePath: "/Users/dev/ecommerce-api/app/routers/items.py", @@ -107,10 +111,12 @@ export const mockApps: AppDefinition[] = [ }, }, ], + children: [], }, { name: "ws_router", prefix: "/ws", + tags: ["websocket"], location: { filePath: "/Users/dev/ecommerce-api/app/routers/websocket.py", line: 5, @@ -119,7 +125,7 @@ export const mockApps: AppDefinition[] = [ routes: [ { method: "WEBSOCKET", - path: "/chat", + path: "/ws/chat", functionName: "websocket_chat", location: { filePath: "/Users/dev/ecommerce-api/app/routers/websocket.py", @@ -129,7 +135,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "WEBSOCKET", - path: "/notifications", + path: "/ws/notifications", functionName: "websocket_notifications", location: { filePath: "/Users/dev/ecommerce-api/app/routers/websocket.py", @@ -138,6 +144,7 @@ export const mockApps: AppDefinition[] = [ }, }, ], + children: [], }, ], routes: [ @@ -199,6 +206,7 @@ export const mockApps: AppDefinition[] = [ { name: "metrics_router", prefix: "/api/metrics", + tags: ["metrics"], location: { filePath: "/Users/dev/analytics-service/src/routers/metrics.py", line: 8, @@ -207,7 +215,7 @@ export const mockApps: AppDefinition[] = [ routes: [ { method: "GET", - path: "/daily", + path: "/api/metrics/daily", functionName: "get_daily_metrics", location: { filePath: "/Users/dev/analytics-service/src/routers/metrics.py", @@ -217,7 +225,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "GET", - path: "/weekly", + path: "/api/metrics/weekly", functionName: "get_weekly_metrics", location: { filePath: "/Users/dev/analytics-service/src/routers/metrics.py", @@ -227,7 +235,7 @@ export const mockApps: AppDefinition[] = [ }, { method: "POST", - path: "/export", + path: "/api/metrics/export", functionName: "export_metrics", location: { filePath: "/Users/dev/analytics-service/src/routers/metrics.py", @@ -236,6 +244,7 @@ export const mockApps: AppDefinition[] = [ }, }, ], + children: [], }, ], routes: [ diff --git a/src/test/fixtures/namespace/app/__init__.py b/src/test/fixtures/namespace/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/fixtures/namespace/app/main.py b/src/test/fixtures/namespace/app/main.py new file mode 100644 index 0000000..df4140d --- /dev/null +++ b/src/test/fixtures/namespace/app/main.py @@ -0,0 +1,14 @@ +from fastapi import FastAPI + +# Import from namespace package (no __init__.py in namespace_routes) +from .namespace_routes import items, users + +app = FastAPI(title="Namespace Package Layout") + +app.include_router(users.router) +app.include_router(items.router) + + +@app.get("/") +def root(): + return {"message": "Hello from namespace package layout"} diff --git a/src/test/fixtures/namespace/app/namespace_routes/items.py b/src/test/fixtures/namespace/app/namespace_routes/items.py new file mode 100644 index 0000000..9ef3cb7 --- /dev/null +++ b/src/test/fixtures/namespace/app/namespace_routes/items.py @@ -0,0 +1,9 @@ +# Note: namespace_routes has NO __init__.py (namespace package) +from fastapi import APIRouter + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("/") +def list_items(): + return [{"id": 1, "name": "Widget"}] diff --git a/src/test/fixtures/namespace/app/namespace_routes/users.py b/src/test/fixtures/namespace/app/namespace_routes/users.py new file mode 100644 index 0000000..2a6402b --- /dev/null +++ b/src/test/fixtures/namespace/app/namespace_routes/users.py @@ -0,0 +1,14 @@ +# Note: namespace_routes has NO __init__.py (namespace package) +from fastapi import APIRouter + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/") +def list_users(): + return [{"id": 1, "name": "Alice"}] + + +@router.get("/{user_id}") +def get_user(user_id: int): + return {"id": user_id, "name": "Alice"} diff --git a/src/test/fixtures/reexport/app/__init__.py b/src/test/fixtures/reexport/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/fixtures/reexport/app/integrations/__init__.py b/src/test/fixtures/reexport/app/integrations/__init__.py new file mode 100644 index 0000000..0193e94 --- /dev/null +++ b/src/test/fixtures/reexport/app/integrations/__init__.py @@ -0,0 +1,2 @@ +# Re-export the router from the actual implementation file +from .router import router as router diff --git a/src/test/fixtures/reexport/app/integrations/router.py b/src/test/fixtures/reexport/app/integrations/router.py new file mode 100644 index 0000000..5af9d05 --- /dev/null +++ b/src/test/fixtures/reexport/app/integrations/router.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/integrations", tags=["integrations"]) + + +@router.get("/github") +def github_integration(): + return {"provider": "github", "status": "connected"} + + +@router.get("/slack") +def slack_integration(): + return {"provider": "slack", "status": "connected"} + + +@router.post("/webhook") +def webhook(): + return {"received": True} diff --git a/src/test/fixtures/reexport/app/main.py b/src/test/fixtures/reexport/app/main.py new file mode 100644 index 0000000..6612ce4 --- /dev/null +++ b/src/test/fixtures/reexport/app/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +# Import router that is re-exported from __init__.py +from .integrations import router + +app = FastAPI(title="Re-export Layout") + +app.include_router(router) + + +@app.get("/") +def root(): + return {"message": "Hello from re-export layout"} diff --git a/src/test/fixtures/standard/app/__init__.py b/src/test/fixtures/standard/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/test/fixtures/standard/app/main.py b/src/test/fixtures/standard/app/main.py new file mode 100644 index 0000000..13739fa --- /dev/null +++ b/src/test/fixtures/standard/app/main.py @@ -0,0 +1,18 @@ +from fastapi import FastAPI + +from .routes import items, users + +app = FastAPI(title="Standard Package Layout") + +app.include_router(users.router) +app.include_router(items.router) + + +@app.get("/") +def root(): + return {"message": "Hello from standard package layout"} + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/src/test/fixtures/standard/app/namespace_routes/api_routes.py b/src/test/fixtures/standard/app/namespace_routes/api_routes.py new file mode 100644 index 0000000..05cac27 --- /dev/null +++ b/src/test/fixtures/standard/app/namespace_routes/api_routes.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/items") +def list_items(): + pass diff --git a/src/test/fixtures/standard/app/routes/__init__.py b/src/test/fixtures/standard/app/routes/__init__.py new file mode 100644 index 0000000..f37351e --- /dev/null +++ b/src/test/fixtures/standard/app/routes/__init__.py @@ -0,0 +1,3 @@ +# Re-exports for convenient imports +from .users import router as users_router +from .items import router as items_router diff --git a/src/test/fixtures/standard/app/routes/items.py b/src/test/fixtures/standard/app/routes/items.py new file mode 100644 index 0000000..534dc0d --- /dev/null +++ b/src/test/fixtures/standard/app/routes/items.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("/") +def list_items(): + return [{"id": 1, "name": "Widget"}, {"id": 2, "name": "Gadget"}] + + +@router.get("/{item_id}") +def get_item(item_id: int): + return {"id": item_id, "name": "Widget"} diff --git a/src/test/fixtures/standard/app/routes/users.py b/src/test/fixtures/standard/app/routes/users.py new file mode 100644 index 0000000..cb84f70 --- /dev/null +++ b/src/test/fixtures/standard/app/routes/users.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/") +def list_users(): + return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] + + +@router.get("/{user_id}") +def get_user(user_id: int): + return {"id": user_id, "name": "Alice"} + + +@router.post("/") +def create_user(): + return {"id": 3, "name": "Charlie"} diff --git a/src/test/fixtures/standard/main.py b/src/test/fixtures/standard/main.py new file mode 100644 index 0000000..58bf8e5 --- /dev/null +++ b/src/test/fixtures/standard/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from app.main import app as sub_app + +app = FastAPI(title="Root App") + + +@app.get("/health") +def health_check(): + return {"status": "ok"} + + +app.mount("/v1", sub_app) diff --git a/src/test/layouts.test.ts b/src/test/layouts.test.ts new file mode 100644 index 0000000..f589c4c --- /dev/null +++ b/src/test/layouts.test.ts @@ -0,0 +1,190 @@ +import * as assert from "node:assert" +import { Parser } from "../core/parser" +import { findProjectRoot } from "../core/pathUtils" +import { buildRouterGraph } from "../core/routerResolver" +import { routerNodeToAppDefinition } from "../core/transformer" +import type { + AppDefinition, + RouteDefinition, + RouterDefinition, +} from "../core/types" +import { fixtures, wasmPaths } from "./testUtils" + +/** Collects all routes from an AppDefinition (direct routes + routes from all routers) */ +function collectAllRoutes(appDef: AppDefinition): RouteDefinition[] { + const collectFromRouter = (router: RouterDefinition): RouteDefinition[] => [ + ...router.routes, + ...router.children.flatMap(collectFromRouter), + ] + + return [...appDef.routes, ...appDef.routers.flatMap(collectFromRouter)] +} + +suite("Project Layouts", () => { + let parser: Parser + + suiteSetup(async () => { + parser = new Parser() + await parser.init(wasmPaths) + }) + + suiteTeardown(() => { + parser.dispose() + }) + + test("standard: discovers routes from package layout", () => { + const projectRoot = findProjectRoot( + fixtures.standard.mainPy, + fixtures.standard.root, + ) + + const graph = buildRouterGraph( + fixtures.standard.mainPy, + parser, + projectRoot, + ) + assert.ok(graph, "Should find FastAPI app") + + const appDef = routerNodeToAppDefinition(graph, fixtures.standard.root) + const allRoutes = collectAllRoutes(appDef) + + // Should have: GET /, GET /health, GET /users/, GET /users/{user_id}, POST /users/, GET /items/, GET /items/{item_id} + assert.strictEqual( + allRoutes.length, + 7, + `Expected 7 routes, got ${allRoutes.length}`, + ) + + const paths = allRoutes.map((r) => `${r.method} ${r.path}`) + assert.ok( + paths.some((p) => p === "GET /"), + "Should have GET /", + ) + assert.ok( + paths.some((p) => p === "GET /users/"), + "Should have GET /users/", + ) + assert.ok( + paths.some((p) => p === "GET /users/{user_id}"), + "Should have GET /users/{user_id}", + ) + assert.ok( + paths.some((p) => p === "GET /items/"), + "Should have GET /items/", + ) + }) + + test("flat: discovers routes from flat layout", () => { + const projectRoot = findProjectRoot( + fixtures.flat.mainPy, + fixtures.flat.root, + ) + + const graph = buildRouterGraph(fixtures.flat.mainPy, parser, projectRoot) + assert.ok(graph, "Should find FastAPI app") + + const appDef = routerNodeToAppDefinition(graph, fixtures.flat.root) + const allRoutes = collectAllRoutes(appDef) + + // Should have: GET /, GET /api/users, GET /api/items + assert.strictEqual( + allRoutes.length, + 3, + `Expected 3 routes, got ${allRoutes.length}`, + ) + + const paths = allRoutes.map((r) => `${r.method} ${r.path}`) + assert.ok( + paths.some((p) => p === "GET /"), + "Should have GET /", + ) + assert.ok( + paths.some((p) => p === "GET /api/users"), + "Should have GET /api/users", + ) + assert.ok( + paths.some((p) => p === "GET /api/items"), + "Should have GET /api/items", + ) + }) + + test("namespace: discovers routes from namespace package (no __init__.py)", () => { + const projectRoot = findProjectRoot( + fixtures.namespace.mainPy, + fixtures.namespace.root, + ) + + const graph = buildRouterGraph( + fixtures.namespace.mainPy, + parser, + projectRoot, + ) + assert.ok(graph, "Should find FastAPI app") + + const appDef = routerNodeToAppDefinition(graph, fixtures.namespace.root) + const allRoutes = collectAllRoutes(appDef) + + // Should have: GET /, GET /users/, GET /users/{user_id}, GET /items/ + assert.strictEqual( + allRoutes.length, + 4, + `Expected 4 routes, got ${allRoutes.length}`, + ) + + const paths = allRoutes.map((r) => `${r.method} ${r.path}`) + assert.ok( + paths.some((p) => p === "GET /"), + "Should have GET /", + ) + assert.ok( + paths.some((p) => p === "GET /users/"), + "Should have GET /users/", + ) + assert.ok( + paths.some((p) => p === "GET /items/"), + "Should have GET /items/", + ) + }) + + test("reexport: discovers routes from __init__.py re-exports", () => { + const projectRoot = findProjectRoot( + fixtures.reexport.mainPy, + fixtures.reexport.root, + ) + + const graph = buildRouterGraph( + fixtures.reexport.mainPy, + parser, + projectRoot, + ) + assert.ok(graph, "Should find FastAPI app") + + const appDef = routerNodeToAppDefinition(graph, fixtures.reexport.root) + const allRoutes = collectAllRoutes(appDef) + + // Should have: GET /, GET /integrations/github, GET /integrations/slack, POST /integrations/webhook + assert.strictEqual( + allRoutes.length, + 4, + `Expected 4 routes, got ${allRoutes.length}`, + ) + + const paths = allRoutes.map((r) => `${r.method} ${r.path}`) + assert.ok( + paths.some((p) => p === "GET /"), + "Should have GET /", + ) + assert.ok( + paths.some((p) => p === "GET /integrations/github"), + "Should have GET /integrations/github", + ) + assert.ok( + paths.some((p) => p === "GET /integrations/slack"), + "Should have GET /integrations/slack", + ) + assert.ok( + paths.some((p) => p === "POST /integrations/webhook"), + "Should have POST /integrations/webhook", + ) + }) +}) diff --git a/src/test/EndpointTreeProvider.test.ts b/src/test/providers/EndpointTreeProvider.test.ts similarity index 92% rename from src/test/EndpointTreeProvider.test.ts rename to src/test/providers/EndpointTreeProvider.test.ts index 9bfd719..c2ffb0a 100644 --- a/src/test/EndpointTreeProvider.test.ts +++ b/src/test/providers/EndpointTreeProvider.test.ts @@ -1,6 +1,6 @@ import * as assert from "node:assert" -import { EndpointTreeProvider } from "../providers/EndpointTreeProvider" -import { mockApps } from "./fixtures/mockEndpointData" +import { EndpointTreeProvider } from "../../providers/EndpointTreeProvider" +import { mockApps } from "../fixtures/mockEndpointData" suite("EndpointTreeProvider", () => { let provider: EndpointTreeProvider @@ -145,7 +145,7 @@ suite("EndpointTreeProvider", () => { assert.ok(treeItem.command, "Route should have a command") assert.strictEqual( treeItem.command?.command, - "fastapi-vscode.goToEndpoint", + "fastapi-vscode.openLocation", ) } }) @@ -176,10 +176,14 @@ suite("EndpointTreeProvider", () => { } }) - test("getChildren returns empty array when no apps", () => { + test("getChildren returns message when no apps", () => { const emptyProvider = new EndpointTreeProvider([]) const roots = emptyProvider.getChildren() - assert.strictEqual(roots.length, 0, "Should return empty array") + assert.strictEqual(roots.length, 1, "Should return one message item") + assert.strictEqual(roots[0].type, "message") + if (roots[0].type === "message") { + assert.strictEqual(roots[0].text, "No FastAPI app found") + } }) test("getTreeItem sets contextValue for app", () => { diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts new file mode 100644 index 0000000..03a4f6c --- /dev/null +++ b/src/test/testUtils.ts @@ -0,0 +1,38 @@ +import { join } from "node:path" + +declare const __DIST_ROOT__: string + +export const wasmDir = join(__DIST_ROOT__, "wasm") +export const wasmPaths = { + core: join(wasmDir, "web-tree-sitter.wasm"), + python: join(wasmDir, "tree-sitter-python.wasm"), +} + +export const fixturesPath = join(__DIST_ROOT__, "..", "src", "test", "fixtures") +export const fixtures = { + standard: { + root: join(fixturesPath, "standard"), + mainPy: join(fixturesPath, "standard", "app", "main.py"), + usersPy: join(fixturesPath, "standard", "app", "routes", "users.py"), + initPy: join(fixturesPath, "standard", "app", "__init__.py"), + }, + flat: { + root: join(fixturesPath, "flat"), + mainPy: join(fixturesPath, "flat", "main.py"), + }, + namespace: { + root: join(fixturesPath, "namespace"), + mainPy: join(fixturesPath, "namespace", "app", "main.py"), + }, + reexport: { + root: join(fixturesPath, "reexport"), + mainPy: join(fixturesPath, "reexport", "app", "main.py"), + initPy: join( + fixturesPath, + "reexport", + "app", + "integrations", + "__init__.py", + ), + }, +} diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 336ce12..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export {}