pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
252 lines (251 loc) • 10.4 kB
JavaScript
/**
* Downloads tree-sitter WASM grammars, each verified against the committed
* provenance manifest (`scripts/grammars.lock.json`: package, version, per-grammar
* sha256) and stamped with a `<grammar>.wasm.json` sidecar. On a version bump or a
* hash mismatch the stale file is re-downloaded instead of skipped — closing the
* old skip-if-exists hole where an ABI-mismatched grammar could silently persist
* against the pinned `web-tree-sitter` (#177).
*
* Modes:
* node download-grammars.js # all grammars → web-tree-sitter/grammars
* node download-grammars.js --core --dest grammars # core set → ./grammars (used by `prepare`)
* node download-grammars.js --write-manifest # regenerate grammars.lock.json from the CDN
*
* Source: tree-sitter-wasms on unpkg (mirrors the npm registry artifacts).
*
* Progress goes to stderr — stdout must stay clean so `npm pack --silent` (which
* runs this via `prepare`) captures only the tarball name (#376/#380).
*/
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
// The pinned tree-sitter-wasms release. Bump this, run `--write-manifest`, and
// commit the regenerated grammars.lock.json to move to a new grammar set.
const TREE_SITTER_WASMS_VERSION = "0.1.13";
const PACKAGE = "tree-sitter-wasms";
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const MANIFEST_PATH = join(SCRIPT_DIR, "grammars.lock.json");
export const SOURCE_OVERRIDES = {
"tree-sitter-lua.wasm": {
package: "@tree-sitter-grammars/tree-sitter-lua",
version: "0.4.1",
url: "https://unpkg.com/@tree-sitter-grammars/tree-sitter-lua@0.4.1/tree-sitter-lua.wasm",
},
"tree-sitter-yaml.wasm": {
package: "@tree-sitter-grammars/tree-sitter-yaml",
version: "0.7.1",
url: "https://unpkg.com/@tree-sitter-grammars/tree-sitter-yaml@0.7.1/tree-sitter-yaml.wasm",
},
};
/** The package a grammar's sidecar records (override or the global aggregator). */
export function expectedPackage(filename, manifest) {
return manifest.overrides?.[filename]?.package ?? SOURCE_OVERRIDES[filename]?.package ?? manifest.package;
}
/** The version a grammar's sidecar records (override or the global aggregator). */
export function expectedVersion(filename, manifest) {
return manifest.overrides?.[filename]?.version ?? SOURCE_OVERRIDES[filename]?.version ?? manifest.version;
}
export const GRAMMARS = [
// Core typed languages
"tree-sitter-typescript.wasm",
"tree-sitter-tsx.wasm",
"tree-sitter-javascript.wasm",
"tree-sitter-python.wasm",
"tree-sitter-rust.wasm",
"tree-sitter-go.wasm",
"tree-sitter-java.wasm",
"tree-sitter-kotlin.wasm",
"tree-sitter-dart.wasm",
"tree-sitter-c.wasm",
"tree-sitter-cpp.wasm",
"tree-sitter-elixir.wasm",
"tree-sitter-ruby.wasm",
// Additional languages with dispatch runners, formatters, or LSP support
"tree-sitter-bash.wasm",
"tree-sitter-c_sharp.wasm",
"tree-sitter-css.wasm",
"tree-sitter-html.wasm",
"tree-sitter-json.wasm",
"tree-sitter-lua.wasm",
"tree-sitter-ocaml.wasm",
"tree-sitter-php.wasm",
"tree-sitter-swift.wasm",
"tree-sitter-toml.wasm",
"tree-sitter-vue.wasm",
"tree-sitter-yaml.wasm",
"tree-sitter-zig.wasm",
];
// The core set bundled into the tarball (via `prepare` → `grammars/`, shipped in
// `files[]`) so the common languages parse offline on every package manager. The
// long tail stays lazy-fetched at runtime. ~8.6MB uncompressed; ts/tsx dominate.
export const CORE = [
"tree-sitter-typescript.wasm",
"tree-sitter-tsx.wasm",
"tree-sitter-javascript.wasm",
"tree-sitter-python.wasm",
"tree-sitter-go.wasm",
"tree-sitter-rust.wasm",
"tree-sitter-json.wasm",
"tree-sitter-yaml.wasm",
"tree-sitter-bash.wasm",
"tree-sitter-html.wasm",
"tree-sitter-css.wasm",
"tree-sitter-java.wasm",
];
/** `sha256:<hex>` digest of a buffer, matching the manifest/sidecar format. */
export function sha256(buf) {
return `sha256:${createHash("sha256").update(buf).digest("hex")}`;
}
/** Read the committed provenance manifest (`scripts/grammars.lock.json`). */
export function loadManifest() {
return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8"));
}
/** Sidecar path for a grammar wasm file: `<grammar>.wasm.json`. */
export function sidecarPathFor(wasmPath) {
return `${wasmPath}.json`;
}
/**
* Whether `filename` must be (re)downloaded into `destDir`: true if the grammar
* or its sidecar is missing, the sidecar's version differs from the manifest, or
* the sidecar's recorded hash differs from the expected one. Pure and cheap (it
* trusts the sidecar's recorded hash — the CI provenance guard re-hashes the
* bytes for the full integrity check).
*/
export function needsDownload(destDir, filename, manifest) {
const wasm = join(destDir, filename);
const sidecar = sidecarPathFor(wasm);
if (!existsSync(wasm) || !existsSync(sidecar))
return true;
let meta;
try {
meta = JSON.parse(readFileSync(sidecar, "utf-8"));
}
catch {
return true;
}
if (meta.version !== expectedVersion(filename, manifest))
return true;
const expected = manifest.grammars[filename];
if (expected && meta.sha256 !== expected)
return true;
return false;
}
function findGrammarsDir() {
const pkgRoot = dirname(SCRIPT_DIR);
// Prefer local node_modules next to this package.
return join(pkgRoot, "node_modules", "web-tree-sitter", "grammars");
}
function baseUrl(version) {
return `https://unpkg.com/${PACKAGE}@${version}/out`;
}
/** Fetch URL for a grammar: its source override if any, else the aggregator. */
function grammarUrl(version, filename) {
return SOURCE_OVERRIDES[filename]?.url ?? `${baseUrl(version)}/${filename}`;
}
async function fetchGrammar(version, filename) {
const res = await fetch(grammarUrl(version, filename));
if (!res.ok)
throw new Error(`HTTP ${res.status} fetching ${filename}`);
return Buffer.from(await res.arrayBuffer());
}
/**
* Download `filename` into `destDir` (if it isn't already verified), checking its
* bytes against the manifest and writing a provenance sidecar. Throws on an
* integrity mismatch — a grammar whose bytes don't match the pinned manifest is
* never written (guards CDN corruption / tampering / ABI drift).
*/
async function downloadGrammar(destDir, filename, manifest) {
if (!needsDownload(destDir, filename, manifest)) {
console.error(` skip ${filename} (verified)`);
return;
}
const buf = await fetchGrammar(manifest.version, filename);
const actual = sha256(buf);
const expected = manifest.grammars[filename];
if (expected && actual !== expected) {
throw new Error(`integrity mismatch for ${filename}: expected ${expected}, got ${actual}`);
}
const wasm = join(destDir, filename);
writeFileSync(wasm, buf);
const sidecar = {
npmPackage: expectedPackage(filename, manifest),
version: expectedVersion(filename, manifest),
sha256: actual,
};
writeFileSync(sidecarPathFor(wasm), `${JSON.stringify(sidecar, null, 2)}\n`);
console.error(` ok ${filename}`);
}
function parseArgs(argv) {
const out = {
core: false,
dest: undefined,
writeManifest: false,
};
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--core")
out.core = true;
else if (argv[i] === "--write-manifest")
out.writeManifest = true;
else if (argv[i] === "--dest")
out.dest = argv[++i];
}
return out;
}
/** Regenerate grammars.lock.json by fetching every grammar and hashing it. */
async function regenerateManifest() {
console.error(`Regenerating manifest from ${PACKAGE}@${TREE_SITTER_WASMS_VERSION} …`);
const grammars = {};
for (const g of GRAMMARS) {
grammars[g] = sha256(await fetchGrammar(TREE_SITTER_WASMS_VERSION, g));
console.error(` hashed ${g}`);
}
const sorted = Object.fromEntries(Object.keys(grammars)
.sort()
.map((k) => [k, grammars[k]]));
const manifest = {
package: PACKAGE,
version: TREE_SITTER_WASMS_VERSION,
grammars: sorted,
...(Object.keys(SOURCE_OVERRIDES).length ? { overrides: SOURCE_OVERRIDES } : {}),
};
writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`);
console.error(`Wrote ${MANIFEST_PATH} (${GRAMMARS.length} grammars).`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.writeManifest)
return regenerateManifest();
const manifest = loadManifest();
// `--dest` is relative to cwd (used by `prepare` to bundle into `./grammars/`);
// default is the installed web-tree-sitter/grammars dir.
const grammarsDir = args.dest
? join(process.cwd(), args.dest)
: findGrammarsDir();
const list = args.core ? CORE : GRAMMARS;
if (!existsSync(grammarsDir)) {
mkdirSync(grammarsDir, { recursive: true });
}
console.error(`Downloading ${args.core ? "core" : "all"} tree-sitter grammars (${list.length}) → ${grammarsDir}`);
const results = await Promise.allSettled(list.map((g) => downloadGrammar(grammarsDir, g, manifest)));
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
for (const f of failed) {
console.warn(" warn ", f.reason?.message);
}
console.warn(`${failed.length} grammar(s) failed — tree-sitter analysis may be unavailable.`);
}
else {
console.error("All grammars downloaded successfully.");
}
}
// Only run when invoked directly (not when imported by the provenance check or
// tests) — the import guard keeps the exported helpers side-effect-free.
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
// Never fail the install — tree-sitter is optional.
console.warn("Warning: grammar download failed:", err.message);
process.exit(0);
});
}