Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<target>` 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
Expand Down
1 change: 1 addition & 0 deletions editors/vscode/.vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
.vscodeignore
icon.svg
package-lock.json
check-platforms.mjs
**/.DS_Store
**/*.map
4 changes: 2 additions & 2 deletions editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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-<version>.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" }
```
Expand Down
45 changes: 45 additions & 0 deletions editors/vscode/check-platforms.mjs
Original file line number Diff line number Diff line change
@@ -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');
148 changes: 121 additions & 27 deletions editors/vscode/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,74 @@
// 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");
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 `<cmd> --version` and return the reported semver (`glslint <semver>`, 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) {
Expand Down Expand Up @@ -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<major>…`); 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.`);
}
Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions editors/vscode/targets.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading