@xynogen/pix-runtime 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,8 @@
38
38
  "./os": "./src/os.ts",
39
39
  "./audio": "./src/audio.ts",
40
40
  "./safe-path": "./src/safe-path.ts",
41
- "./which": "./src/which.ts"
41
+ "./which": "./src/which.ts",
42
+ "./ast": "./src/ast/index.ts"
42
43
  },
43
44
  "keywords": [
44
45
  "pi",
@@ -63,9 +64,18 @@
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@earendil-works/pi-coding-agent": "*",
66
- "@earendil-works/pi-tui": "*"
67
+ "@earendil-works/pi-tui": "*",
68
+ "@ast-grep/napi": "^0.45.0"
67
69
  },
68
70
  "dependencies": {
69
71
  "xxhash-wasm": "1.1.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@ast-grep/napi": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@ast-grep/napi": "^0.45.0"
70
80
  }
71
81
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * engine.ts — the lazy @ast-grep/napi loader and language map.
3
+ *
4
+ * The native addon loads on first tool call, never at extension registration.
5
+ * The addon bundles six grammars: ts, tsx, js, jsx, css, html. We map file
6
+ * extensions to language aliases. Non-bundled languages use grammars.ts, which
7
+ * loads cached grammar packages or installs them only after user consent.
8
+ *
9
+ * Loader adapted from pi-lens `clients/deps/ast-grep-napi.ts` (MIT). See
10
+ * packages/pix-diagnostics/LICENSE.pi-lens.
11
+ *
12
+ * `@ast-grep/napi` is an optional peer: pix-runtime never forces the native addon on
13
+ * every install. Packages that parse (pix-astgrep, pix-graph) depend on it; without it
14
+ * `loadEngine()` returns undefined and callers degrade.
15
+ * ponytail: resolution assumes npm hoists napi next to pix-runtime (the flat Pi install).
16
+ */
17
+
18
+ import { createRequire } from "node:module";
19
+ import { extname } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+
22
+ // The typed shape of the addon accessor we use (parse → root → findAll).
23
+ export interface SgEdit {
24
+ startPos: number;
25
+ endPos: number;
26
+ insertedText: string;
27
+ }
28
+ export interface SgNode {
29
+ text(): string;
30
+ kind(): string;
31
+ range(): { start: SgPos; end: SgPos };
32
+ findAll(matcher: string | { rule: Record<string, unknown> }): SgNode[];
33
+ children(): SgNode[];
34
+ isNamed(): boolean;
35
+ /** Child stored under a tree-sitter field name (`name`, `body`, `function`, …). */
36
+ field(name: string): SgNode | null;
37
+ replace(text: string): SgEdit;
38
+ commitEdits(edits: SgEdit[]): string;
39
+ getMatch(name: string): SgNode | null;
40
+ getMultipleMatches(name: string): SgNode[];
41
+ }
42
+ interface SgPos {
43
+ line: number;
44
+ column: number;
45
+ index: number;
46
+ }
47
+ interface SgRoot {
48
+ root(): SgNode;
49
+ }
50
+ interface LangApi {
51
+ parse(source: string): SgRoot;
52
+ }
53
+ interface AstGrepNapi {
54
+ ts: LangApi;
55
+ tsx: LangApi;
56
+ js: LangApi;
57
+ jsx: LangApi;
58
+ css: LangApi;
59
+ html: LangApi;
60
+ /** Top-level parse for a dynamically registered language, keyed by alias. */
61
+ parse(lang: string, source: string): SgRoot;
62
+ registerDynamicLanguage(langs: Record<string, unknown>): void;
63
+ }
64
+
65
+ const _require = createRequire(import.meta.url);
66
+ let cached: AstGrepNapi | undefined;
67
+ let loadPromise: Promise<AstGrepNapi | undefined> | undefined;
68
+
69
+ /** Load the native addon once. Returns `undefined` when it cannot load. */
70
+ export async function loadEngine(): Promise<AstGrepNapi | undefined> {
71
+ if (cached) return cached;
72
+ if (loadPromise) return loadPromise;
73
+ loadPromise = (async () => {
74
+ try {
75
+ const entry = _require.resolve("@ast-grep/napi");
76
+ // SAFETY: the addon has no bundled ESM types; the accessors we use
77
+ // (ts/tsx/js/jsx/css/html .parse) are verified at runtime by tests.
78
+ const mod = (await import(pathToFileURL(entry).href)) as unknown as AstGrepNapi;
79
+ cached = mod;
80
+ return mod;
81
+ } catch {
82
+ return undefined;
83
+ } finally {
84
+ loadPromise = undefined;
85
+ }
86
+ })();
87
+ return loadPromise;
88
+ }
89
+
90
+ /** The six grammars the napi addon parses in-process without any package. */
91
+ export const LANGUAGES = ["typescript", "tsx", "javascript", "jsx", "css", "html"] as const;
92
+ export type Language = (typeof LANGUAGES)[number];
93
+ const BUNDLED = new Set<string>(LANGUAGES);
94
+
95
+ /** True when napi parses this alias in-process (no `@ast-grep/lang-*` needed). */
96
+ export function isBundled(lang: string): lang is Language {
97
+ return BUNDLED.has(lang);
98
+ }
99
+
100
+ /**
101
+ * Supported ast-grep language aliases. The six bundled ones parse in-process;
102
+ * the rest have published `@ast-grep/lang-*` packages (see grammars.ts).
103
+ * This intentionally excludes languages without an installable grammar.
104
+ */
105
+ export const ALL_LANGUAGES = [
106
+ "typescript",
107
+ "tsx",
108
+ "javascript",
109
+ "jsx",
110
+ "css",
111
+ "html",
112
+ "python",
113
+ "go",
114
+ "rust",
115
+ "java",
116
+ "c",
117
+ "cpp",
118
+ "csharp",
119
+ "ruby",
120
+ "php",
121
+ "swift",
122
+ "kotlin",
123
+ "scala",
124
+ "lua",
125
+ "elixir",
126
+ "json",
127
+ "yaml",
128
+ "bash",
129
+ "haskell",
130
+ ] as const;
131
+ export type AnyLanguage = (typeof ALL_LANGUAGES)[number];
132
+
133
+ // File extension → language alias, from the ast-grep language table.
134
+ const EXT_TO_LANG: Record<string, AnyLanguage> = {
135
+ ".ts": "typescript",
136
+ ".mts": "typescript",
137
+ ".cts": "typescript",
138
+ ".tsx": "tsx",
139
+ ".js": "javascript",
140
+ ".mjs": "javascript",
141
+ ".cjs": "javascript",
142
+ ".jsx": "jsx",
143
+ ".css": "css",
144
+ ".html": "html",
145
+ ".htm": "html",
146
+ ".xhtml": "html",
147
+ ".py": "python",
148
+ ".pyi": "python",
149
+ ".go": "go",
150
+ ".rs": "rust",
151
+ ".java": "java",
152
+ ".c": "c",
153
+ ".h": "c",
154
+ ".cc": "cpp",
155
+ ".cpp": "cpp",
156
+ ".cxx": "cpp",
157
+ ".hpp": "cpp",
158
+ ".hh": "cpp",
159
+ ".cu": "cpp",
160
+ ".cs": "csharp",
161
+ ".rb": "ruby",
162
+ ".gemspec": "ruby",
163
+ ".php": "php",
164
+ ".swift": "swift",
165
+ ".kt": "kotlin",
166
+ ".kts": "kotlin",
167
+ ".scala": "scala",
168
+ ".sc": "scala",
169
+ ".sbt": "scala",
170
+ ".lua": "lua",
171
+ ".ex": "elixir",
172
+ ".exs": "elixir",
173
+ ".json": "json",
174
+ ".yml": "yaml",
175
+ ".yaml": "yaml",
176
+ ".sh": "bash",
177
+ ".bash": "bash",
178
+ ".zsh": "bash",
179
+ ".ksh": "bash",
180
+ ".hs": "haskell",
181
+ };
182
+
183
+ /** Map a file path to any ast-grep language alias, or `undefined`. */
184
+ export function languageForFile(path: string): AnyLanguage | undefined {
185
+ return EXT_TO_LANG[extname(path).toLowerCase()];
186
+ }
187
+
188
+ /** Get the addon accessor for a language, or `undefined` when unsupported. */
189
+ export function accessorFor(engine: AstGrepNapi, lang: Language): LangApi {
190
+ switch (lang) {
191
+ case "typescript":
192
+ return engine.ts;
193
+ case "tsx":
194
+ return engine.tsx;
195
+ case "javascript":
196
+ return engine.js;
197
+ case "jsx":
198
+ return engine.jsx;
199
+ case "css":
200
+ return engine.css;
201
+ case "html":
202
+ return engine.html;
203
+ default:
204
+ throw new Error(`unsupported language: ${lang as string}`);
205
+ }
206
+ }
207
+
208
+ /** Parse source into a root node for one bundled language. */
209
+ export function parse(engine: AstGrepNapi, lang: Language, source: string): SgNode {
210
+ return accessorFor(engine, lang).parse(source).root();
211
+ }
212
+
213
+ /**
214
+ * Parse source for any language alias, including a dynamically registered one.
215
+ * The caller must have run `ensureLanguage(lang)` first for non-bundled ones.
216
+ */
217
+ export function parseDynamic(engine: AstGrepNapi, lang: string, source: string): SgNode {
218
+ return engine.parse(lang, source).root();
219
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * grammars.ts — lazy, package-owned multi-language grammar loading.
3
+ *
4
+ * The napi addon bundles six grammars (see engine.ts). Every other ast-grep
5
+ * language ships as its own `@ast-grep/lang-<x>` npm package. This module loads
6
+ * one on demand:
7
+ *
8
+ * 1. If napi already parses it (the six), no grammar is needed.
9
+ * 2. Else try to import `@ast-grep/lang-<x>` from the pix cache, then from
10
+ * the resolvable node_modules.
11
+ * 3. If absent, report `needs-install` with the exact package name. The tool
12
+ * asks the user once, then this module installs it INTO the pix cache
13
+ * (never the agent, never a global write). A later call finds it cached.
14
+ *
15
+ * The cache lives at `<agentDir>/pix-astgrep/grammars`. We install with the
16
+ * user's package manager into that private prefix, so a read-only global pix
17
+ * install still works.
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import { mkdir } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { join } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { runTool } from "../exec.ts";
26
+ import { agentDir } from "../paths.ts";
27
+ import { isBundled, loadEngine, parse, parseDynamic, type SgNode } from "./engine.ts";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
31
+
32
+ /** Canonical language alias → its `@ast-grep/lang-*` package name. */
33
+ const LANG_PACKAGE: Record<string, string> = {
34
+ python: "@ast-grep/lang-python",
35
+ go: "@ast-grep/lang-go",
36
+ rust: "@ast-grep/lang-rust",
37
+ java: "@ast-grep/lang-java",
38
+ c: "@ast-grep/lang-c",
39
+ cpp: "@ast-grep/lang-cpp",
40
+ csharp: "@ast-grep/lang-csharp",
41
+ ruby: "@ast-grep/lang-ruby",
42
+ php: "@ast-grep/lang-php",
43
+ swift: "@ast-grep/lang-swift",
44
+ kotlin: "@ast-grep/lang-kotlin",
45
+ scala: "@ast-grep/lang-scala",
46
+ lua: "@ast-grep/lang-lua",
47
+ elixir: "@ast-grep/lang-elixir",
48
+ json: "@ast-grep/lang-json",
49
+ yaml: "@ast-grep/lang-yaml",
50
+ bash: "@ast-grep/lang-bash",
51
+ haskell: "@ast-grep/lang-haskell",
52
+ };
53
+
54
+ /** Languages that need a `@ast-grep/lang-*` package (not the napi six). */
55
+ export function packageForLanguage(lang: string): string | undefined {
56
+ return LANG_PACKAGE[lang];
57
+ }
58
+
59
+ /**
60
+ * Cache root for installed grammars: `<agentDir>/pix-astgrep/grammars`. The path predates the
61
+ * move into pix-runtime; it stays so grammars users already installed keep working.
62
+ */
63
+ export function grammarCacheDir(): string {
64
+ return join(agentDir(), "pix-astgrep", "grammars");
65
+ }
66
+
67
+ // ponytail: napi registers once per process. Load all cached grammars together;
68
+ // adding a language afterward needs an agent restart, not a silently ignored registration.
69
+ const stateKey = Symbol.for("pix-astgrep.grammars");
70
+ const globals = globalThis as typeof globalThis & {
71
+ [stateKey]?: { registered: Set<string>; declined: Set<string>; registration?: Promise<void> };
72
+ };
73
+ globals[stateKey] ??= { registered: new Set(), declined: new Set() };
74
+ const state = globals[stateKey];
75
+ const { registered, declined } = state;
76
+
77
+ export type GrammarResult =
78
+ | { kind: "ready" }
79
+ | { kind: "napi" }
80
+ | { kind: "needs-install"; pkg: string }
81
+ | { kind: "declined"; pkg: string }
82
+ | { kind: "unsupported" }
83
+ | { kind: "error"; message: string };
84
+
85
+ /** Import a grammar module from the cache prefix, then this package. */
86
+ async function importGrammar(pkg: string): Promise<unknown | undefined> {
87
+ const cached = join(grammarCacheDir(), "node_modules", pkg);
88
+ const candidates = existsSync(cached) ? [cached, pkg] : [pkg];
89
+ for (const spec of candidates) {
90
+ let entry: string;
91
+ try {
92
+ entry = require.resolve(spec);
93
+ } catch (err) {
94
+ if ((err as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") continue;
95
+ throw err;
96
+ }
97
+ const mod = (await import(pathToFileURL(entry).href)) as { default?: unknown };
98
+ return mod.default ?? mod;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Make a language usable. Returns `ready` when registered, `napi` when the
105
+ * addon already covers it, `needs-install` with the package to install, or
106
+ * `unsupported` for an unknown alias.
107
+ */
108
+ export async function ensureLanguage(lang: string): Promise<GrammarResult> {
109
+ const engine = await loadEngine();
110
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
111
+
112
+ // The six bundled grammars never need a package.
113
+ if (isBundled(lang)) {
114
+ return { kind: "napi" };
115
+ }
116
+ const pkg = packageForLanguage(lang);
117
+ if (!pkg) return { kind: "unsupported" };
118
+ if (registered.has(lang)) return { kind: "ready" };
119
+
120
+ try {
121
+ const grammar = await importGrammar(pkg);
122
+ if (!grammar) return { kind: "needs-install", pkg };
123
+ state.registration ??= (async () => {
124
+ const grammars: Record<string, unknown> = {};
125
+ for (const [alias, name] of Object.entries(LANG_PACKAGE)) {
126
+ const loaded = await importGrammar(name);
127
+ if (loaded) grammars[alias] = loaded;
128
+ }
129
+ engine.registerDynamicLanguage(grammars);
130
+ for (const alias of Object.keys(grammars)) registered.add(alias);
131
+ })();
132
+ await state.registration;
133
+ return registered.has(lang)
134
+ ? { kind: "ready" }
135
+ : {
136
+ kind: "error",
137
+ message: `${pkg} is installed, but napi's language registry is already initialized. Restart the agent to load newly installed grammars.`,
138
+ };
139
+ } catch (err) {
140
+ return { kind: "error", message: errorText(err) };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Install a `@ast-grep/lang-*` package into the pix cache prefix. This runs a
146
+ * network install — the caller must get user consent first. It never touches a
147
+ * global path or this package's own node_modules.
148
+ */
149
+ export async function installGrammar(pkg: string): Promise<{ ok: boolean; message?: string }> {
150
+ const dir = grammarCacheDir();
151
+ try {
152
+ await mkdir(dir, { recursive: true });
153
+ // A minimal package.json so npm installs into this prefix cleanly.
154
+ const pkgJson = join(dir, "package.json");
155
+ if (!existsSync(pkgJson)) {
156
+ const { writeFile } = await import("node:fs/promises");
157
+ await writeFile(pkgJson, JSON.stringify({ name: "pix-astgrep-grammars", private: true }));
158
+ }
159
+ const result = await runTool("npm", ["install", "--save", "--prefix", dir, pkg], {
160
+ cwd: dir,
161
+ timeoutMs: 120_000,
162
+ });
163
+ if (result.code !== 0) {
164
+ return {
165
+ ok: false,
166
+ message: result.timedOut
167
+ ? "grammar installation timed out"
168
+ : result.stderr || `npm exited ${result.code}`,
169
+ };
170
+ }
171
+ return { ok: true };
172
+ } catch (err) {
173
+ return { ok: false, message: errorText(err) };
174
+ }
175
+ }
176
+
177
+ export type ParseOutcome = { kind: "ok"; root: SgNode } | GrammarResult;
178
+
179
+ /** A one-time consent callback: return true to install `pkg`. */
180
+ export type ConsentFn = (pkg: string) => Promise<boolean>;
181
+
182
+ /**
183
+ * Parse one file's source for any language alias, installing a missing grammar
184
+ * with consent. The bundled six always parse. A non-bundled language is loaded
185
+ * from the pix cache. When absent, `consent(pkg)` decides whether to install it
186
+ * INTO the cache (once per language). A declined language is remembered so the
187
+ * caller is not asked again this session.
188
+ */
189
+ export async function parseWithGrammar(
190
+ lang: string,
191
+ source: string,
192
+ consent?: ConsentFn,
193
+ ): Promise<ParseOutcome> {
194
+ const engine = await loadEngine();
195
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
196
+
197
+ try {
198
+ if (isBundled(lang)) return { kind: "ok", root: parse(engine, lang, source) };
199
+ if (declined.has(lang)) {
200
+ return { kind: "declined", pkg: packageForLanguage(lang) ?? lang };
201
+ }
202
+
203
+ let ready = await ensureLanguage(lang);
204
+ if (ready.kind === "needs-install") {
205
+ if (!consent) return ready;
206
+ if (!(await consent(ready.pkg))) {
207
+ declined.add(lang);
208
+ return { kind: "declined", pkg: ready.pkg };
209
+ }
210
+ const installed = await installGrammar(ready.pkg);
211
+ if (!installed.ok) {
212
+ return { kind: "error", message: installed.message ?? `failed to install ${ready.pkg}` };
213
+ }
214
+ ready = await ensureLanguage(lang);
215
+ }
216
+ if (ready.kind !== "ready") return ready;
217
+ return { kind: "ok", root: parseDynamic(engine, lang, source) };
218
+ } catch (err) {
219
+ return { kind: "error", message: errorText(err) };
220
+ }
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * pix-runtime/ast — shared ast-grep parsing: the lazy napi loader, the file → language map,
3
+ * and consent-gated `@ast-grep/lang-*` grammar loading. UI-free; the install prompt lives in
4
+ * `@xynogen/pix-pretty/grammar-consent`.
5
+ */
6
+ export {
7
+ ALL_LANGUAGES,
8
+ type AnyLanguage,
9
+ isBundled,
10
+ LANGUAGES,
11
+ type Language,
12
+ languageForFile,
13
+ loadEngine,
14
+ parse,
15
+ parseDynamic,
16
+ type SgEdit,
17
+ type SgNode,
18
+ } from "./engine.ts";
19
+ export {
20
+ type ConsentFn,
21
+ ensureLanguage,
22
+ type GrammarResult,
23
+ grammarCacheDir,
24
+ installGrammar,
25
+ type ParseOutcome,
26
+ packageForLanguage,
27
+ parseWithGrammar,
28
+ } from "./grammars.ts";