diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c03e2..452315c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,12 @@ jobs: # at release time. - name: Check the Homebrew formula renders run: node homebrew/prepare.mjs --version 0.0.0 --dry-run + # The VS Code extension ships its own copy of the platform table (the .vsix + # can't read npm's at install time). Assert it still matches so the third + # hardcoded copy can't drift from the source of truth. This job's + # release-please branch skip (see header) covers this step too. + - name: Check the extension platform table matches + run: node editors/vscode/check-platforms.mjs - name: Build run: cargo build --locked # One debug binary stands in for all four release assets: this checks the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eef69e1..f77a093 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,7 +226,11 @@ jobs: git push publish-extension: - needs: release-please + # Needs `build`: the extension downloads its version-pinned binary from the + # release as the primary path now, so publishing it before `build` finishes + # uploading the `glslint-` assets would 404 a fresh install in that + # window. + needs: [release-please, build] if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index c6fb8b0..77c0f52 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -3,5 +3,6 @@ .vscodeignore icon.svg package-lock.json +check-platforms.mjs **/.DS_Store **/*.map diff --git a/editors/vscode/README.md b/editors/vscode/README.md index ad51ddb..a1d99a1 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -4,7 +4,7 @@ A thin LSP client that runs the `glslint` binary in `lsp` mode and surfaces its ## Setup -The extension resolves the `glslint` binary in this order: an explicit `glslint.path` setting → a local install (`~/.cargo/bin/glslint`, then PATH) → otherwise it **downloads** the prebuilt binary for your platform from this repo's GitHub Release and caches it in the extension's storage. So the only hard requirement is `glslangValidator` (glslint shells out to it): +The extension manages its own `glslint` binary and keeps it on the extension's exact version, so you never have to remember to update it. It resolves the binary like so: an explicit `glslint.path` setting (a dev override, used verbatim) → an auto-detected local install (`~/.cargo/bin/glslint`, then PATH) **only if its version matches the extension** → otherwise it **downloads** the version-matched prebuilt binary for your platform from this repo's GitHub Release and caches it in the extension's storage (upgrading automatically whenever the extension updates). A stale `cargo install glslint` is therefore ignored rather than silently serving an old LSP. So the only hard requirement is `glslangValidator` (glslint shells out to it): ```sh brew install glslang @@ -27,7 +27,7 @@ cd editors/vscode && npm install - **Dev host (fastest):** open the `editors/vscode` folder in VS Code/Cursor and press `F5`. That launches an Extension Development Host; open your `deck-wind-layer` folder in it and open a shader (e.g. `src/shaders/draw.vert.glsl`). - **Install for real:** `npx @vscode/vsce package` here, then install the resulting `.vsix` (`code --install-extension glslint-.vsix`). -If you didn't `cargo install` (e.g. you want the debug binary), point the setting at it: +If you didn't `cargo install` (e.g. you want the debug binary), point the setting at it — `glslint.path` is the escape hatch for local builds and is always used verbatim, so it bypasses the version check (you get a one-time warning, not a fallback, if it differs from the extension's version): ```json { "glslint.path": "/absolute/path/to/glslint/target/debug/glslint" } ``` diff --git a/editors/vscode/check-platforms.mjs b/editors/vscode/check-platforms.mjs new file mode 100644 index 0000000..33feaea --- /dev/null +++ b/editors/vscode/check-platforms.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// Drift guard for the extension's platform table. +// +// The extension ships its own copy of the platform table +// (editors/vscode/targets.json) because the packaged .vsix can't read npm's copy +// at install time. This asserts that copy still names the same Rust target for +// every platform as the single source of truth, npm/glslint/platforms.json, so +// the three hardcoded tables (npm, homebrew-via-npm, and the extension) can never +// disagree. +// +// node editors/vscode/check-platforms.mjs +// +// CI runs it on every PR (see .github/workflows/ci.yml). It writes nothing and +// exits non-zero on drift. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_DIR = path.dirname(path.dirname(HERE)); +const CANONICAL = path.join(REPO_DIR, 'npm', 'glslint', 'platforms.json'); +const LOCAL = path.join(HERE, 'targets.json'); + +const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8')); + +// npm/glslint/platforms.json is `{ key: { os, cpu, target } }`; the extension +// only needs `{ key: target }`. Reduce the canonical table to that mapping and +// compare it order-independently. +const canonical = Object.fromEntries( + Object.entries(readJson(CANONICAL)).map(([key, { target }]) => [key, target]), +); +const local = readJson(LOCAL); + +const normalize = (map) => JSON.stringify(Object.entries(map).sort()); +if (normalize(canonical) !== normalize(local)) { + console.error( + 'check-platforms: editors/vscode/targets.json has drifted from the platform\n' + + 'table in npm/glslint/platforms.json. Update the extension copy to match.\n' + + ` npm/glslint/platforms.json: ${JSON.stringify(canonical)}\n` + + ` editors/vscode/targets.json: ${JSON.stringify(local)}`, + ); + process.exit(1); +} +console.log('check-platforms: extension platform table matches npm/glslint/platforms.json'); diff --git a/editors/vscode/extension.js b/editors/vscode/extension.js index 3cc4686..22c7ed4 100644 --- a/editors/vscode/extension.js +++ b/editors/vscode/extension.js @@ -2,9 +2,16 @@ // diagnostics/hover/completion/etc. for GLSL documents. Works in VS Code and // Cursor (same extension API). // -// Binary resolution, in order: an explicit `glslint.path` setting; a locally -// installed binary (~/.cargo/bin or PATH); else a prebuilt binary downloaded once -// from this repo's GitHub Release and cached in the extension's storage. +// Binary resolution keeps the LSP pinned to this extension's exact version. +// An explicit `glslint.path` is a dev override, honored verbatim (a version +// mismatch only warns). Otherwise an auto-detected binary (~/.cargo/bin or PATH) +// is used ONLY when its `--version` matches this extension — so a stale +// `cargo install glslint` can never silently shadow the pinned LSP. With no +// matching local binary, the extension downloads its own version-pinned binary +// from this repo's GitHub Release and caches it; that download is now the normal +// path and upgrades automatically on every extension update. If the download +// fails (offline/404) it falls back to the best local binary it can find, +// mismatch and all — a slightly-stale LSP beats none. const { workspace, window, ProgressLocation } = require("vscode"); const { LanguageClient } = require("vscode-languageclient/node"); @@ -12,38 +19,57 @@ const os = require("os"); const path = require("path"); const fs = require("fs"); const https = require("https"); +const cp = require("child_process"); const REPO = "johncarmack1984/glslint"; +const pkg = require("./package.json"); // Binary version to download: this extension's own version (release-please keeps // package.json in lockstep with the crate), prefixed with `v` for the release tag. -const VERSION = `v${require("./package.json").version}`; +const VERSION = `v${pkg.version}`; + +// node `platform-arch` -> the Rust target triple in the release asset names. A +// local copy the packaged .vsix carries (it can't read npm's copy at install +// time); a CI drift guard (check-platforms.mjs) pins it to the single source of +// truth, npm/glslint/platforms.json. +const TARGETS = require("./targets.json"); let client; /// node platform/arch -> the Rust target triple used in the release asset names. function rustTarget() { - if (process.platform === "darwin") { - return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"; - } - if (process.platform === "linux" && process.arch === "x64") return "x86_64-unknown-linux-gnu"; - if (process.platform === "win32" && process.arch === "x64") return "x86_64-pc-windows-msvc"; - return null; + return TARGETS[`${process.platform}-${process.arch}`] || null; } function exeName() { return process.platform === "win32" ? "glslint.exe" : "glslint"; } -/// A locally installed binary, if any: explicit setting, then ~/.cargo/bin, then PATH. -function localBinary() { - const configured = workspace.getConfiguration("glslint").get("path"); - if (configured) return configured; +/// Run ` --version` and return the reported semver (`glslint `, see +/// src/main.rs), or null if it can't be run or the output doesn't parse. Used to +/// gate auto-detected binaries so only an exact version match is accepted. +function binaryVersion(cmd) { + try { + const res = cp.spawnSync(cmd, ["--version"], { timeout: 3000, encoding: "utf8" }); + if (res.error || res.status !== 0 || !res.stdout) return null; + const m = res.stdout.trim().match(/^glslint (\S+)$/); + return m ? m[1] : null; + } catch { + return null; + } +} + +/// Auto-detected candidate binaries, best-first: ~/.cargo/bin, then each PATH dir. +/// Excludes the explicit `glslint.path` setting, which is handled separately. +function localCandidates() { + const candidates = []; const cargoBin = path.join(os.homedir(), ".cargo", "bin", exeName()); - if (fs.existsSync(cargoBin)) return cargoBin; + if (fs.existsSync(cargoBin)) candidates.push(cargoBin); for (const dir of (process.env.PATH || "").split(path.delimiter)) { - if (dir && fs.existsSync(path.join(dir, exeName()))) return path.join(dir, exeName()); + if (!dir) continue; + const p = path.join(dir, exeName()); + if (fs.existsSync(p)) candidates.push(p); } - return null; + return candidates; } function download(url, dest) { @@ -88,16 +114,69 @@ async function downloadBinary(context) { return dest; } +/// Delete cached downloads from previous extension versions; only VERSION's dir +/// is kept, so the cache can't grow unbounded across updates. Best-effort. +function pruneOldDownloads(context) { + const root = context.globalStorageUri.fsPath; + let entries; + try { + entries = fs.readdirSync(root); + } catch { + return; // storage dir not created yet — nothing cached + } + for (const name of entries) { + // Only touch our own version-tagged dirs (`v…`); never anything else + // that might live under globalStorage. + if (name === VERSION || !/^v\d/.test(name)) continue; + try { + fs.rmSync(path.join(root, name), { recursive: true, force: true }); + } catch { + // ignore — a stale dir that won't delete isn't worth failing activation + } + } +} + +/// Resolve the `glslint` command to launch. Returns { command, explicit } where +/// `explicit` marks a user-set `glslint.path` (checked for version match after +/// the LSP starts, not here). Throws only when nothing is usable, letting the +/// caller fall back to `glslint` on PATH. async function resolveCommand(context) { - const local = localBinary(); - if (local) return local; - return downloadBinary(context); // throws if unavailable; caller falls back + // 1. Explicit override: honored verbatim. Its version is checked post-start + // via the LSP handshake (serverInfo.version) — no extra process spawn. + const configured = workspace.getConfiguration("glslint").get("path"); + if (configured) return { command: configured, explicit: true }; + + // 2. An auto-detected binary is used only when its version matches this + // extension, so a stale install can never shadow the pinned LSP. + const candidates = localCandidates(); + for (const cand of candidates) { + if (binaryVersion(cand) === pkg.version) return { command: cand, explicit: false }; + } + + // 3. No match: download this extension's pinned binary (the normal path). + try { + return { command: await downloadBinary(context), explicit: false }; + } catch (err) { + // 4. Download failed (offline/404): a slightly-stale local LSP beats none. + if (candidates.length) { + window.showWarningMessage( + `glslint: couldn't download ${VERSION} (${err.message}); using ${candidates[0]}, ` + + `which may be a different version.`, + ); + return { command: candidates[0], explicit: false }; + } + throw err; // caller falls back to `glslint` on PATH + } } async function activate(context) { + // Tidy caches from older extension versions on every activation (cheap). + pruneOldDownloads(context); + let command = "glslint"; + let explicit = false; try { - command = await resolveCommand(context); + ({ command, explicit } = await resolveCommand(context)); } catch (err) { window.showWarningMessage(`glslint: ${err.message}. Falling back to \`glslint\` on PATH.`); } @@ -117,12 +196,27 @@ async function activate(context) { }; client = new LanguageClient("glslint", "glslint", serverOptions, clientOptions); - client.start().catch((err) => { - window.showErrorMessage( - `glslint: couldn't start "${command} lsp". Install it with \`cargo install --path .\` ` + - `in the glslint repo, or set "glslint.path". ${err}`, - ); - }); + client + .start() + .then(() => { + // Only an explicit `glslint.path` can be a mismatched version at this point + // (auto-detected binaries are version-gated before start, downloads are + // pinned). serverInfo.version comes from the LSP handshake — no extra spawn. + if (!explicit) return; + const serverVersion = client.initializeResult?.serverInfo?.version; + if (serverVersion && serverVersion !== pkg.version) { + window.showWarningMessage( + `glslint.path points at glslint ${serverVersion}, but this extension expects ` + + `${pkg.version} — diagnostics may differ. Clear "glslint.path" to use the managed binary.`, + ); + } + }) + .catch((err) => { + window.showErrorMessage( + `glslint: couldn't start "${command} lsp". Install it with \`cargo install --path .\` ` + + `in the glslint repo, or set "glslint.path". ${err}`, + ); + }); } function deactivate() { diff --git a/editors/vscode/package.json b/editors/vscode/package.json index f5b7ad0..1e80cfb 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -76,7 +76,7 @@ "glslint.path": { "type": "string", "default": "", - "description": "Path to the glslint executable. Leave empty to auto-resolve (~/.cargo/bin/glslint from `cargo install --path .`, then `glslint` on PATH). Set an absolute path to override." + "description": "Path to the glslint executable — a dev override, used verbatim (if its version differs from the extension you get a warning, not a fallback). Leave empty to auto-resolve: a local ~/.cargo/bin/glslint or `glslint` on PATH is used only when its version matches the extension; otherwise the extension downloads and manages its own version-matched binary." } } } diff --git a/editors/vscode/targets.json b/editors/vscode/targets.json new file mode 100644 index 0000000..93c56b5 --- /dev/null +++ b/editors/vscode/targets.json @@ -0,0 +1,6 @@ +{ + "darwin-arm64": "aarch64-apple-darwin", + "darwin-x64": "x86_64-apple-darwin", + "linux-x64": "x86_64-unknown-linux-gnu", + "win32-x64": "x86_64-pc-windows-msvc" +}