pi-read-map
Advanced tools
+49
| # Changelog | ||
| All notable changes to this project will be documented in this file. | ||
| ## [1.2.0] - 2026-02-14 | ||
| ### Added | ||
| - **Clojure mapper** — tree-sitter-based parser for `.clj`, `.cljs`, `.cljc`, and `.edn` files. Extracts `ns`, `defn`, `defn-`, `def`, `defonce`, `defmacro`, `defmulti`, `defmethod`, `defprotocol`, `defrecord`, and `deftype` forms with docstrings, signatures, modifiers, and protocol method children. Supports reader conditionals (`#?`) with per-platform annotations. Contributed by [Baishampayan Ghose](https://github.com/ghoseb). ([#2](https://github.com/Whamp/pi-read-map/pull/2)) | ||
| - Clojure demo asset (`clojure/core.clj` from the official Clojure repo) | ||
| ### Changed | ||
| - Tree-sitter tests use `describe.runIf` for conditional execution | ||
| ## [1.1.0] - 2026-02-10 | ||
| ### Added | ||
| - Docstring extraction (`FileSymbol.docstring`) across all mappers | ||
| - Export flag (`FileSymbol.isExported`) across all mappers | ||
| - Required imports (`FileMap.imports`) across all mappers | ||
| - Skipped read recovery — detects reads cancelled by steering queue and re-issues them | ||
| - JSONL session-aware maps for pi session files | ||
| - Directory read handling with EISDIR error and `ls` fallback | ||
| ### Fixed | ||
| - Symbol duplication in file map output | ||
| - oxlint warnings and errors resolved across codebase | ||
| ### Changed | ||
| - Test helpers refactored; `models.ts` renamed to `constants.ts` | ||
| ## [1.0.0] - 2026-02-09 | ||
| Initial release. | ||
| ### Added | ||
| - Structural file maps for large files (>2,000 lines or >50 KB) | ||
| - 14 language mappers: TypeScript, JavaScript, Python, Go, Rust, C, C++, SQL, JSON, JSONL, YAML, TOML, CSV, Markdown | ||
| - Budget-aware formatting with progressive detail reduction (10 KB full → 100 KB truncated) | ||
| - In-memory caching by file path and modification time | ||
| - Fallback chain: language mapper → universal-ctags → grep | ||
| - Custom `file-map` messages delivered after `tool_result` events | ||
| - E2E test infrastructure via tmux | ||
| - Demo assets from 10 major open-source projects |
| import { readFile, stat } from "node:fs/promises"; | ||
| /** | ||
| * Clojure mapper using tree-sitter for AST extraction. | ||
| * | ||
| * The tree-sitter-clojure grammar parses at the S-expression level: | ||
| * all forms are `list_lit` nodes. We identify def forms by matching | ||
| * the first `sym_lit` child against known Clojure special forms. | ||
| */ | ||
| import { createRequire } from "node:module"; | ||
| import type { FileMap, FileSymbol } from "../types.js"; | ||
| import { DetailLevel, SymbolKind } from "../enums.js"; | ||
| type SyntaxNode = import("tree-sitter").SyntaxNode; | ||
| /** | ||
| * Def forms we recognize and how they map to symbol kinds. | ||
| */ | ||
| const DEF_FORMS: Record< | ||
| string, | ||
| { kind: SymbolKind; isPrivate?: boolean; hasChildren?: boolean } | ||
| > = { | ||
| defn: { kind: SymbolKind.Function }, | ||
| "defn-": { kind: SymbolKind.Function, isPrivate: true }, | ||
| def: { kind: SymbolKind.Variable }, | ||
| defonce: { kind: SymbolKind.Variable }, | ||
| defmacro: { kind: SymbolKind.Function }, | ||
| defmulti: { kind: SymbolKind.Function }, | ||
| defmethod: { kind: SymbolKind.Method }, | ||
| defprotocol: { kind: SymbolKind.Interface, hasChildren: true }, | ||
| defrecord: { kind: SymbolKind.Class }, | ||
| deftype: { kind: SymbolKind.Class }, | ||
| }; | ||
| // Lazy-loaded parser | ||
| let parser: import("tree-sitter") | null = null; | ||
| let parserInitialized = false; | ||
| function ensureWritableTypeProperty(parserCtor: unknown): void { | ||
| const syntaxNode = (parserCtor as { SyntaxNode?: { prototype?: object } }) | ||
| .SyntaxNode; | ||
| const proto = syntaxNode?.prototype; | ||
| if (!proto) { | ||
| return; | ||
| } | ||
| const desc = Object.getOwnPropertyDescriptor(proto, "type"); | ||
| if (!desc || desc.set) { | ||
| return; | ||
| } | ||
| Object.defineProperty(proto, "type", { ...desc, set: () => {} }); | ||
| } | ||
| function getParser(): import("tree-sitter") | null { | ||
| if (parserInitialized) { | ||
| return parser; | ||
| } | ||
| parserInitialized = true; | ||
| const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; | ||
| if (isBun) { | ||
| return null; | ||
| } | ||
| try { | ||
| const require = createRequire(import.meta.url); | ||
| const ParserCtor = require("tree-sitter") as typeof import("tree-sitter"); | ||
| const Clojure = | ||
| require("tree-sitter-clojure") as import("tree-sitter").Language; | ||
| ensureWritableTypeProperty(ParserCtor); | ||
| parser = new ParserCtor(); | ||
| parser.setLanguage(Clojure); | ||
| return parser; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function getNodeText(node: SyntaxNode, source: string): string { | ||
| return source.slice(node.startIndex, node.endIndex); | ||
| } | ||
| /** | ||
| * Get the text of a sym_lit's sym_name child. | ||
| */ | ||
| function getSymName(node: SyntaxNode, source: string): string | null { | ||
| if (node.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| const nameNode = node.namedChildren.find((c) => c.type === "sym_name"); | ||
| if (!nameNode) { | ||
| return null; | ||
| } | ||
| return getNodeText(nameNode, source); | ||
| } | ||
| /** | ||
| * Check if a sym_lit has ^:private metadata. | ||
| */ | ||
| function hasPrivateMeta(node: SyntaxNode): boolean { | ||
| return node.namedChildren.some( | ||
| (c) => | ||
| c.type === "meta_lit" && | ||
| c.namedChildren.some((v) => v.type === "kwd_lit" && v.text === ":private") | ||
| ); | ||
| } | ||
| /** | ||
| * Extract the string content from a str_lit node (strips surrounding quotes). | ||
| */ | ||
| function extractString(node: SyntaxNode, source: string): string { | ||
| const text = getNodeText(node, source); | ||
| return text.slice(1, -1); | ||
| } | ||
| /** | ||
| * Extract the named values from a list_lit (skipping gaps/whitespace). | ||
| */ | ||
| function getValueChildren(node: SyntaxNode): SyntaxNode[] { | ||
| return node.namedChildren.filter( | ||
| (c) => c.type !== "comment" && c.type !== "dis_expr" | ||
| ); | ||
| } | ||
| /** | ||
| * Extract protocol method signatures from a defprotocol body. | ||
| */ | ||
| function extractProtocolMethods( | ||
| children: SyntaxNode[], | ||
| source: string | ||
| ): FileSymbol[] { | ||
| const methods: FileSymbol[] = []; | ||
| for (const child of children) { | ||
| if (child.type !== "list_lit") { | ||
| continue; | ||
| } | ||
| const values = getValueChildren(child); | ||
| const [firstValue] = values; | ||
| if (!firstValue || firstValue.type !== "sym_lit") { | ||
| continue; | ||
| } | ||
| const name = getSymName(firstValue, source); | ||
| if (!name) { | ||
| continue; | ||
| } | ||
| const paramsNode = values.find((v) => v.type === "vec_lit"); | ||
| const params = paramsNode ? getNodeText(paramsNode, source) : ""; | ||
| const signature = params ? `(${name} ${params})` : `(${name})`; | ||
| const docNode = values.find( | ||
| (v, i) => | ||
| v.type === "str_lit" && i > values.indexOf(paramsNode ?? firstValue) | ||
| ); | ||
| methods.push({ | ||
| name, | ||
| kind: SymbolKind.Method, | ||
| startLine: child.startPosition.row + 1, | ||
| endLine: child.endPosition.row + 1, | ||
| signature, | ||
| ...(docNode ? { docstring: extractString(docNode, source) } : {}), | ||
| }); | ||
| } | ||
| return methods; | ||
| } | ||
| /** | ||
| * Extract a defmethod form into an ExtractedDef-like result. | ||
| */ | ||
| function extractDefmethod( | ||
| node: SyntaxNode, | ||
| values: SyntaxNode[], | ||
| source: string | ||
| ): FileSymbol | null { | ||
| const [, nameNode, dispatchNode] = values; | ||
| if (!nameNode || nameNode.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| const multiName = getSymName(nameNode, source); | ||
| if (!multiName) { | ||
| return null; | ||
| } | ||
| const dispatchVal = dispatchNode | ||
| ? getNodeText(dispatchNode, source) | ||
| : "unknown"; | ||
| const paramsNode = values.find((v) => v.type === "vec_lit"); | ||
| const params = paramsNode ? getNodeText(paramsNode, source) : ""; | ||
| const signature = `(defmethod ${multiName} ${dispatchVal} ${params})`; | ||
| return { | ||
| name: `${multiName} ${dispatchVal}`, | ||
| kind: SymbolKind.Method, | ||
| startLine: node.startPosition.row + 1, | ||
| endLine: node.endPosition.row + 1, | ||
| signature, | ||
| isExported: true, | ||
| }; | ||
| } | ||
| /** | ||
| * Extract docstring from values after the name. | ||
| * | ||
| * For function-like forms (defn, defmacro, defprotocol, defmulti): | ||
| * docstring is the first str_lit that appears before any vec_lit or list_lit. | ||
| * | ||
| * For value forms (def, defonce): | ||
| * a str_lit is a docstring only if another value follows it. | ||
| * e.g. (def x "doc" 42) → docstring="doc", but (def x "val") → no docstring. | ||
| */ | ||
| function extractDocstring( | ||
| restValues: SyntaxNode[], | ||
| source: string, | ||
| isValueForm: boolean | ||
| ): string | undefined { | ||
| const firstStr = restValues.find((v) => v.type === "str_lit"); | ||
| if (!firstStr) { | ||
| return undefined; | ||
| } | ||
| const firstVec = restValues.find((v) => v.type === "vec_lit"); | ||
| const firstList = restValues.find((v) => v.type === "list_lit"); | ||
| const strIdx = restValues.indexOf(firstStr); | ||
| const vecIdx = firstVec ? restValues.indexOf(firstVec) : Infinity; | ||
| const listIdx = firstList ? restValues.indexOf(firstList) : Infinity; | ||
| if (strIdx >= vecIdx || strIdx >= listIdx) { | ||
| return undefined; | ||
| } | ||
| // For def/defonce: the string is a docstring only if a value follows it | ||
| if (isValueForm) { | ||
| const hasValueAfter = restValues.some( | ||
| (v, i) => i > strIdx && v.type !== "comment" && v.type !== "dis_expr" | ||
| ); | ||
| if (!hasValueAfter) { | ||
| return undefined; | ||
| } | ||
| } | ||
| return extractString(firstStr, source); | ||
| } | ||
| /** | ||
| * Build signature for function-like forms (defn, defn-, defmacro). | ||
| */ | ||
| function buildFnSignature( | ||
| formName: string, | ||
| name: string, | ||
| restValues: SyntaxNode[], | ||
| source: string | ||
| ): string { | ||
| const firstVec = restValues.find((v) => v.type === "vec_lit"); | ||
| if (firstVec) { | ||
| return `(${formName} ${name} ${getNodeText(firstVec, source)})`; | ||
| } | ||
| // Multi-arity: look for list_lit children starting with vec_lit | ||
| const arities = restValues.filter( | ||
| (v) => | ||
| v.type === "list_lit" && | ||
| getValueChildren(v).some((c) => c.type === "vec_lit") | ||
| ); | ||
| if (arities.length > 0) { | ||
| const arityStrs = arities.map((a) => { | ||
| const vec = getValueChildren(a).find((c) => c.type === "vec_lit"); | ||
| return vec ? getNodeText(vec, source) : "[]"; | ||
| }); | ||
| return `(${formName} ${name} ${arityStrs.join(" ")})`; | ||
| } | ||
| return `(${formName} ${name})`; | ||
| } | ||
| /** | ||
| * Build signature for non-function def forms. | ||
| */ | ||
| function buildDefSignature( | ||
| formName: string, | ||
| name: string, | ||
| restValues: SyntaxNode[], | ||
| source: string | ||
| ): string { | ||
| if (formName === "defmulti") { | ||
| const dispatchNode = restValues.find((v) => v.type !== "str_lit"); | ||
| const dispatch = dispatchNode ? getNodeText(dispatchNode, source) : ""; | ||
| return dispatch ? `(defmulti ${name} ${dispatch})` : `(defmulti ${name})`; | ||
| } | ||
| if (formName === "defprotocol") { | ||
| return `(defprotocol ${name})`; | ||
| } | ||
| if (formName === "defrecord" || formName === "deftype") { | ||
| const firstVec = restValues.find((v) => v.type === "vec_lit"); | ||
| return firstVec | ||
| ? `(${formName} ${name} ${getNodeText(firstVec, source)})` | ||
| : `(${formName} ${name})`; | ||
| } | ||
| // def, defonce | ||
| return `(${formName} ${name})`; | ||
| } | ||
| const FN_FORMS = new Set(["defn", "defn-", "defmacro"]); | ||
| /** | ||
| * Try to extract a def form from a list_lit node. | ||
| */ | ||
| function extractDef(node: SyntaxNode, source: string): FileSymbol | null { | ||
| if (node.type !== "list_lit") { | ||
| return null; | ||
| } | ||
| const values = getValueChildren(node); | ||
| if (values.length < 2) { | ||
| return null; | ||
| } | ||
| const [formNode, nameNode] = values; | ||
| if (!formNode || formNode.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| const formName = getSymName(formNode, source); | ||
| if (!formName) { | ||
| return null; | ||
| } | ||
| const defInfo = DEF_FORMS[formName]; | ||
| if (!defInfo) { | ||
| return null; | ||
| } | ||
| // defmethod has unique structure | ||
| if (formName === "defmethod") { | ||
| return extractDefmethod(node, values, source); | ||
| } | ||
| if (!nameNode || nameNode.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| const name = getSymName(nameNode, source); | ||
| if (!name) { | ||
| return null; | ||
| } | ||
| const isPrivate = defInfo.isPrivate === true || hasPrivateMeta(nameNode); | ||
| const modifiers: string[] = []; | ||
| if (isPrivate) { | ||
| modifiers.push("private"); | ||
| } | ||
| if (formName === "defmacro") { | ||
| modifiers.push("macro"); | ||
| } | ||
| const restValues = values.slice(2); | ||
| const isValueForm = formName === "def" || formName === "defonce"; | ||
| const docstring = extractDocstring(restValues, source, isValueForm); | ||
| const signature = FN_FORMS.has(formName) | ||
| ? buildFnSignature(formName, name, restValues, source) | ||
| : buildDefSignature(formName, name, restValues, source); | ||
| // Only defprotocol has extractable method children. | ||
| // defrecord/deftype inline protocol methods are not yet extracted. | ||
| let children: FileSymbol[] | undefined; | ||
| if (defInfo.hasChildren && formName === "defprotocol") { | ||
| const extracted = extractProtocolMethods(restValues, source); | ||
| children = extracted.length > 0 ? extracted : undefined; | ||
| } | ||
| const symbol: FileSymbol = { | ||
| name, | ||
| kind: defInfo.kind, | ||
| startLine: node.startPosition.row + 1, | ||
| endLine: node.endPosition.row + 1, | ||
| isExported: !isPrivate, | ||
| }; | ||
| if (signature) { | ||
| symbol.signature = signature; | ||
| } | ||
| if (docstring) { | ||
| symbol.docstring = docstring; | ||
| } | ||
| if (modifiers.length > 0) { | ||
| symbol.modifiers = modifiers; | ||
| } | ||
| if (children) { | ||
| symbol.children = children; | ||
| } | ||
| return symbol; | ||
| } | ||
| /** | ||
| * Extract ns form for namespace and imports. | ||
| */ | ||
| function extractNs( | ||
| node: SyntaxNode, | ||
| source: string | ||
| ): { namespace: string; imports: string[]; docstring?: string } | null { | ||
| if (node.type !== "list_lit") { | ||
| return null; | ||
| } | ||
| const values = getValueChildren(node); | ||
| if (values.length < 2) { | ||
| return null; | ||
| } | ||
| const [formNode, nameNode, docNode] = values; | ||
| if (!formNode || formNode.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| if (getSymName(formNode, source) !== "ns") { | ||
| return null; | ||
| } | ||
| if (!nameNode || nameNode.type !== "sym_lit") { | ||
| return null; | ||
| } | ||
| const namespace = getSymName(nameNode, source); | ||
| if (!namespace) { | ||
| return null; | ||
| } | ||
| // Docstring at index 2 | ||
| let docstring: string | undefined; | ||
| if (docNode?.type === "str_lit") { | ||
| docstring = extractString(docNode, source); | ||
| } | ||
| // Extract :require and :import clauses | ||
| const imports: string[] = []; | ||
| for (const child of values) { | ||
| if (child.type !== "list_lit") { | ||
| continue; | ||
| } | ||
| const listValues = getValueChildren(child); | ||
| const [kwd] = listValues; | ||
| if (!kwd || kwd.type !== "kwd_lit") { | ||
| continue; | ||
| } | ||
| const kwdText = getNodeText(kwd, source); | ||
| if (kwdText === ":require" || kwdText === ":import") { | ||
| for (const spec of listValues.slice(1)) { | ||
| // Unwrap reader conditionals inside require/import: | ||
| // #?(:clj [clojure.java.io] :cljs [cljs.reader]) | ||
| // → each platform's specs get added with a platform tag | ||
| if ( | ||
| spec.type === "read_cond_lit" || | ||
| spec.type === "splicing_read_cond_lit" | ||
| ) { | ||
| const rcChildren = getValueChildren(spec); | ||
| let platform: string | undefined; | ||
| for (const rc of rcChildren) { | ||
| if (rc.type === "kwd_lit") { | ||
| platform = getNodeText(rc, source); | ||
| continue; | ||
| } | ||
| if (platform) { | ||
| imports.push(`${getNodeText(rc, source)} ${platform}`); | ||
| platform = undefined; | ||
| } | ||
| } | ||
| } else { | ||
| imports.push(getNodeText(spec, source)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return { namespace, imports, docstring }; | ||
| } | ||
| /** | ||
| * Extract def forms from a reader conditional (#? or #?@). | ||
| * | ||
| * Reader conditionals contain kwd_lit/form pairs like: | ||
| * #?(:clj (defn foo [x] ...) :cljs (defn foo [x] ...)) | ||
| * | ||
| * We extract defs from all platform branches, annotating each with | ||
| * its platform keyword as a modifier. When the same name appears in | ||
| * multiple branches, all variants are included — the map consumer | ||
| * sees the full picture. | ||
| */ | ||
| function extractReaderConditionalDefs( | ||
| node: SyntaxNode, | ||
| source: string | ||
| ): FileSymbol[] { | ||
| const results: FileSymbol[] = []; | ||
| const children = getValueChildren(node); | ||
| // Children alternate: kwd_lit, form, kwd_lit, form, ... | ||
| let currentPlatform: string | undefined; | ||
| for (const child of children) { | ||
| if (child.type === "kwd_lit") { | ||
| currentPlatform = getNodeText(child, source); | ||
| continue; | ||
| } | ||
| if (child.type === "list_lit") { | ||
| const def = extractDef(child, source); | ||
| if (def && currentPlatform) { | ||
| const platformName = currentPlatform.replace(/^:/, ""); | ||
| const platformMod = `platform-${platformName}`; | ||
| def.modifiers = def.modifiers | ||
| ? [...def.modifiers, platformMod] | ||
| : [platformMod]; | ||
| results.push(def); | ||
| } | ||
| } | ||
| } | ||
| return results; | ||
| } | ||
| /** | ||
| * Generate a file map for Clojure files using tree-sitter. | ||
| */ | ||
| export async function clojureMapper( | ||
| filePath: string, | ||
| signal?: AbortSignal | ||
| ): Promise<FileMap | null> { | ||
| try { | ||
| const p = getParser(); | ||
| if (!p) { | ||
| return null; | ||
| } | ||
| const stats = await stat(filePath); | ||
| const totalBytes = stats.size; | ||
| const content = await readFile(filePath, "utf8"); | ||
| if (signal?.aborted) { | ||
| return null; | ||
| } | ||
| let tree: import("tree-sitter").Tree; | ||
| try { | ||
| tree = p.parse(content); | ||
| } catch { | ||
| return null; | ||
| } | ||
| const symbols: FileSymbol[] = []; | ||
| const imports: string[] = []; | ||
| for (const child of tree.rootNode.namedChildren) { | ||
| // Reader conditionals: extract defs from all platform branches | ||
| if ( | ||
| child.type === "read_cond_lit" || | ||
| child.type === "splicing_read_cond_lit" | ||
| ) { | ||
| const condDefs = extractReaderConditionalDefs(child, content); | ||
| symbols.push(...condDefs); | ||
| continue; | ||
| } | ||
| if (child.type !== "list_lit") { | ||
| continue; | ||
| } | ||
| const ns = extractNs(child, content); | ||
| if (ns) { | ||
| imports.push(...ns.imports); | ||
| symbols.push({ | ||
| name: ns.namespace, | ||
| kind: SymbolKind.Namespace, | ||
| startLine: child.startPosition.row + 1, | ||
| endLine: child.endPosition.row + 1, | ||
| signature: `(ns ${ns.namespace})`, | ||
| ...(ns.docstring ? { docstring: ns.docstring } : {}), | ||
| isExported: true, | ||
| }); | ||
| continue; | ||
| } | ||
| const def = extractDef(child, content); | ||
| if (def) { | ||
| symbols.push(def); | ||
| } | ||
| } | ||
| if (symbols.length === 0) { | ||
| return null; | ||
| } | ||
| const totalLines = content.split("\n").length; | ||
| return { | ||
| path: filePath, | ||
| totalLines, | ||
| totalBytes, | ||
| language: "Clojure", | ||
| symbols, | ||
| imports, | ||
| detailLevel: DetailLevel.Full, | ||
| }; | ||
| } catch (error) { | ||
| if (signal?.aborted) { | ||
| return null; | ||
| } | ||
| console.error(`Clojure mapper failed: ${error}`); | ||
| return null; | ||
| } | ||
| } |
+9
-6
| # AGENTS.md | ||
| > Last updated: 2026-02-10 | ||
| > Last updated: 2026-02-14 | ||
| Pi extension that augments the built-in `read` tool with structural file maps for large files (>2,000 lines or >50 KB). Intercepts `read` calls, generates symbol maps via language-specific parsers, and sends them as separate `file-map` messages after the tool result. | ||
| ## Commands (verified 2026-02-10) | ||
| ## Commands (verified 2026-02-14) | ||
@@ -33,6 +33,7 @@ | Command | Purpose | ~Time | | ||
| ├── constants.ts → THRESHOLDS: lines, bytes, budget tiers | ||
| └── mappers/ → One mapper per language (16 total) | ||
| └── mappers/ → One mapper per language (17 total) | ||
| ├── typescript.ts → ts-morph (handles TS + JS) | ||
| ├── rust.ts → tree-sitter-rust | ||
| ├── cpp.ts → tree-sitter-cpp (C++ and .h files) | ||
| ├── clojure.ts → tree-sitter-clojure (.clj, .cljs, .cljc, .edn) | ||
| ├── python.ts → subprocess: scripts/python_outline.py | ||
@@ -62,3 +63,3 @@ ├── go.ts → subprocess: scripts/go_outline.go | ||
| ├── benchmarks/ → Mapper performance benchmarks | ||
| └── helpers/ → Test utilities (pi-runner, constants, models) | ||
| └── helpers/ → Test utilities (pi-runner, constants, tree-sitter) | ||
@@ -91,2 +92,3 @@ docs/ | ||
| | Complex mapper | `src/mappers/typescript.ts` | ts-morph AST walk, nested symbols, modifiers | | ||
| | Tree-sitter mapper | `src/mappers/clojure.ts` | tree-sitter AST walk, reader conditionals, platform modifiers | | ||
| | Subprocess mapper | `src/mappers/python.ts` | Calls external script, parses JSON output | | ||
@@ -131,4 +133,5 @@ | Unit test | `tests/unit/mappers/csv.test.ts` | Fixture-based, edge cases, null returns | | ||
| - `tree-sitter` pinned to 0.22.4 due to peer dependency conflicts (see `docs/todo/upgrade-tree-sitter-0.26.md`) | ||
| - `tree-sitter-clojure` pinned to commit SHA from `github:ghoseb/tree-sitter-clojure` (third-party fork) | ||
| - Go outline script auto-compiles on first use; compiled binary checked in at `scripts/go_outline` | ||
| - Phase 1-4 of implementation plan complete; remaining TODOs in `docs/todo/` | ||
| - Phase 1-5 of implementation plan complete; remaining TODOs in `docs/todo/` | ||
@@ -158,3 +161,3 @@ | Docstrings / JSDoc | `FileSymbol.docstring?: string` | First-line summary of doc comments | | ||
| - **Linting:** oxlint + oxfmt | ||
| - **Parsing:** ts-morph, tree-sitter, regex, subprocess (Python/Go/jq) | ||
| - **Parsing:** ts-morph, tree-sitter (rust, cpp, clojure), regex, subprocess (Python/Go/jq) | ||
| - **Framework:** pi extension API (`@mariozechner/pi-coding-agent`) |
+2
-1
| { | ||
| "name": "pi-read-map", | ||
| "version": "1.1.0", | ||
| "version": "1.2.0", | ||
| "description": "Pi extension that adds structural file maps for large files", | ||
@@ -47,2 +47,3 @@ "type": "module", | ||
| "tree-sitter": "0.22.4", | ||
| "tree-sitter-clojure": "github:ghoseb/tree-sitter-clojure#78928e6", | ||
| "tree-sitter-cpp": "0.23.4", | ||
@@ -49,0 +50,0 @@ "tree-sitter-rust": "0.23.3", |
+7
-1
@@ -26,3 +26,3 @@ # pi-read-map | ||
| - **Generates structural maps** showing symbols, classes, functions, and their exact line ranges | ||
| - **Supports 16 languages** through specialized parsers: TypeScript, JavaScript, Python, Go, Rust, C, C++, SQL, JSON, JSONL, YAML, TOML, CSV, Markdown | ||
| - **Supports 17 languages** through specialized parsers: TypeScript, JavaScript, Python, Go, Rust, C, C++, Clojure, ClojureScript, SQL, JSON, JSONL, YAML, TOML, CSV, Markdown, EDN | ||
| - **Extracts structural outlines** — functions, classes, and their line ranges — typically under 1% of file size | ||
@@ -134,2 +134,3 @@ - **Enforces budgets** through progressive detail reduction (10 KB full → 15 KB compact → 20 KB minimal → 50 KB outline → 100 KB hard cap) | ||
| ├── cpp.ts # tree-sitter for C/C++ | ||
| ├── clojure.ts # tree-sitter for Clojure/ClojureScript/EDN | ||
| ├── c.ts # Regex patterns | ||
@@ -178,2 +179,3 @@ ├── sql.ts # Regex | ||
| - `tree-sitter-rust` - Rust parsing | ||
| - `tree-sitter-clojure` - Clojure parsing | ||
@@ -190,4 +192,8 @@ **System tools (optional):** | ||
| ### Contributors | ||
| - [Baishampayan Ghose](https://github.com/ghoseb) — Clojure tree-sitter mapper and [tree-sitter-clojure](https://github.com/ghoseb/tree-sitter-clojure) grammar | ||
| ## License | ||
| MIT |
@@ -41,2 +41,8 @@ import { extname } from "node:path"; | ||
| // Clojure | ||
| ".clj": { id: "clojure", name: "Clojure" }, | ||
| ".cljs": { id: "clojure", name: "ClojureScript" }, | ||
| ".cljc": { id: "clojure", name: "Clojure" }, | ||
| ".edn": { id: "clojure", name: "EDN" }, | ||
| // SQL | ||
@@ -43,0 +49,0 @@ ".sql": { id: "sql", name: "SQL" }, |
+4
-0
@@ -6,2 +6,3 @@ import type { FileMap, MapOptions } from "./types.js"; | ||
| import { cMapper } from "./mappers/c.js"; | ||
| import { clojureMapper } from "./mappers/clojure.js"; | ||
| import { cppMapper } from "./mappers/cpp.js"; | ||
@@ -61,2 +62,5 @@ import { csvMapper } from "./mappers/csv.js"; | ||
| csv: csvMapper, | ||
| // Phase 5: Clojure tree-sitter | ||
| clojure: clojureMapper, | ||
| }; | ||
@@ -63,0 +67,0 @@ |
+31
-4
@@ -22,6 +22,8 @@ /** | ||
| /** | ||
| * Map ctags kind letters to SymbolKind. | ||
| * Map ctags kind identifiers to SymbolKind. | ||
| * Handles both single-letter kinds (legacy format) and full-word kinds (JSON format). | ||
| * See: https://docs.ctags.io/en/latest/man/ctags.1.html | ||
| */ | ||
| const CTAGS_KIND_MAP: Record<string, SymbolKind> = { | ||
| // Single-letter kinds (legacy/traditional format) | ||
| c: SymbolKind.Class, | ||
@@ -48,3 +50,3 @@ d: SymbolKind.Constant, // macro definition | ||
| T: SymbolKind.Type, // type | ||
| // Language-specific mappings | ||
| // Language-specific single-letter mappings | ||
| a: SymbolKind.Type, // alias | ||
@@ -58,2 +60,26 @@ b: SymbolKind.Variable, // block (Ruby) | ||
| z: SymbolKind.Property, // parameter | ||
| // Full-word kinds (JSON output format) | ||
| class: SymbolKind.Class, | ||
| enum: SymbolKind.Enum, | ||
| enumerator: SymbolKind.Enum, | ||
| function: SymbolKind.Function, | ||
| interface: SymbolKind.Interface, | ||
| macro: SymbolKind.Constant, | ||
| member: SymbolKind.Property, | ||
| method: SymbolKind.Method, | ||
| module: SymbolKind.Module, | ||
| namespace: SymbolKind.Namespace, | ||
| package: SymbolKind.Module, | ||
| property: SymbolKind.Property, | ||
| struct: SymbolKind.Struct, | ||
| type: SymbolKind.Type, | ||
| typedef: SymbolKind.Type, | ||
| union: SymbolKind.Type, | ||
| variable: SymbolKind.Variable, | ||
| field: SymbolKind.Property, | ||
| constant: SymbolKind.Constant, | ||
| prototype: SymbolKind.Function, | ||
| alias: SymbolKind.Type, | ||
| trait: SymbolKind.Interface, | ||
| }; | ||
@@ -155,5 +181,6 @@ | ||
| // Run ctags with JSON output | ||
| // Run ctags with JSON output and line numbers | ||
| // --output-format=json requires Universal Ctags 5.9+ | ||
| const cmd = `ctags --output-format=json -f - "${filePath}" 2>/dev/null`; | ||
| // --fields=+n ensures line numbers are included in JSON output | ||
| const cmd = `ctags --output-format=json --fields=+n -f - "${filePath}" 2>/dev/null`; | ||
@@ -160,0 +187,0 @@ let stdout: string; |
GitHub dependency
Supply chain riskContains a dependency which resolves to a GitHub URL. Dependencies fetched from GitHub specifiers are not immutable can be used to inject untrusted code or reduce the likelihood of a reproducible install.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
1584307
1.3%36
5.88%6783
9.23%196
3.16%5
25%1
Infinity%23
9.52%