UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

149 lines (148 loc) • 7.23 kB
import * as path from "node:path"; import { Type } from "../clients/deps/typebox.js"; import { compactRenderResult } from "./render-compact.js"; import { LANGUAGES } from "./shared.js"; // Cap the wire payload so an outline over a large directory can't flood context. const MAX_FILES = 50; const MAX_ITEMS_PER_FILE = 200; /** Attach ready `read` args (1-based) to each item/member from its 0-based range. */ function withReadHandles(filePath, item) { const { members, ...rest } = item; const offset = item.range.start.line + 1; const limit = Math.max(1, item.range.end.line - item.range.start.line + 1); return { ...rest, read: { path: filePath, offset, limit }, ...(members ? { members: members.map((m) => withReadHandles(filePath, m)) } : {}), }; } function errorResult(text) { return { content: [{ type: "text", text }], isError: true, details: {}, }; } export function createAstGrepOutlineTool(astGrepClient) { return { name: "ast_grep_outline", label: "AST-Grep Outline", description: "Syntax-only code structure (symbols, imports, exports, and members) via " + "`ast-grep outline`. Fast, local, no index/LSP/cross-file semantics — useful " + "for languages or structures where pi-lens's own extractor is weak, or for a " + "raw second opinion.\n\n" + "Prefer module_report for pi-lens-aware navigation (who-uses-this, " + "complexity/fanout, recommendedReads, blast radius, callback handles). Use " + "ast_grep_outline when you want the syntax tree's own view of a file or a " + "whole directory.\n\n" + "Returns JSON: per file, `items[]` with name/symbolType/signature/range, " + "`isExported`/`isImport`, nested `members[]` (with `isPublic`), and ready " + "`read` args on every entry. NOTE: structure only — an outline is NOT a read " + "of a symbol's body (use read_symbol/read_enclosing for that).", promptSnippet: "Syntax-only code outline via ast-grep (no index/LSP); module_report is the richer default", renderResult: compactRenderResult(({ details, isError, text }) => { if (isError) { return `ast_grep_outline — ${text.split("\n")[0] ?? "error"}`; } const files = details?.files ?? 0; const items = details?.items ?? 0; const trunc = details?.truncatedFiles ? " (truncated)" : ""; return `ast_grep_outline — ${items} symbols across ${files} file${files === 1 ? "" : "s"}${trunc}`; }), parameters: Type.Object({ paths: Type.Array(Type.String(), { minItems: 1, maxItems: 100, description: "Files or directories to outline (workspace-relative or absolute).", }), lang: Type.Optional(Type.String({ enum: [...LANGUAGES], description: "Restrict to one language. Required for stdin; for paths, ast-grep infers per file when omitted.", })), items: Type.Optional(Type.String({ enum: ["auto", "structure", "exports", "imports", "all"], description: "Which top-level items to include. auto = `structure` for a file, `exports` for a directory (default).", })), view: Type.Optional(Type.String({ enum: ["auto", "names", "signatures", "digest", "expanded"], description: "Text presentation level for the underlying outline; `expanded` shows members.", })), type: Type.Optional(Type.Array(Type.String(), { description: 'Keep only these symbol types, e.g. ["class","function"].', })), match: Type.Optional(Type.String({ description: "Keep only top-level items whose name matches this regex.", })), pubMembers: Type.Optional(Type.Boolean({ description: "Show only public members in member views.", })), globs: Type.Optional(Type.Array(Type.String(), { description: 'Include/exclude file globs for directory input, e.g. ["*.ts","!**/dist/**"].', })), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const cwd = ctx.cwd || "."; const rawPaths = (params.paths ?? []).filter((p) => typeof p === "string" && p.trim().length > 0); if (rawPaths.length === 0) return errorResult("paths is required"); // Resolve relative paths against the workspace; pass absolute paths to the // CLI (execFile-style args — no shell interpolation). const absPaths = rawPaths.map((p) => path.isAbsolute(p) ? p : path.resolve(cwd, p)); if (!(await astGrepClient.ensureAvailable())) { return errorResult("ast-grep CLI not found. Install: npm i -D @ast-grep/cli"); } let result; try { result = await astGrepClient.outline(absPaths, { lang: params.lang, items: params.items, view: params.view, types: params.type, match: params.match, pubMembers: params.pubMembers, globs: params.globs, }); } catch (err) { return errorResult(`Error: ${err instanceof Error ? err.message : String(err)}`); } if (result.error) return errorResult(`Error: ${result.error}`); const files = (result.output ?? []).slice(0, MAX_FILES); const truncatedFiles = (result.output?.length ?? 0) > MAX_FILES; const outline = files.map((file) => ({ path: file.path, language: file.language, items: file.items .slice(0, MAX_ITEMS_PER_FILE) .map((item) => withReadHandles(file.path, item)), ...(file.items.length > MAX_ITEMS_PER_FILE ? { truncatedItems: file.items.length - MAX_ITEMS_PER_FILE } : {}), })); const totalItems = files.reduce((n, f) => n + f.items.length, 0); if (outline.length === 0) { return { content: [ { type: "text", text: "No outline items found (syntax-only — try module_report, or check lang/paths).", }, ], details: { files: 0, items: 0 }, }; } return { content: [{ type: "text", text: JSON.stringify({ outline }) }], details: { files: outline.length, items: totalItems, syntaxOnly: true, ...(truncatedFiles ? { truncatedFiles: true } : {}), }, }; }, }; }