glslint 0.7.0

A luma.gl/deck.gl-aware GLSL checker and language server
# glslint

A luma.gl / deck.gl-aware GLSL checker and language server. Stock GLSL tools choke on these shaders because they aren't standalone translation units: they reference UBO instances (`wind.*`, `blit.*`) declared in separate module fragments and deck builtins (`project_position_to_clipspace`) injected at link time. glslint assembles the modules + deck stubs into a complete unit, validates it with the Khronos glslangValidator reference compiler, and maps diagnostics back to the original file and line.

## Requirements

glslint shells out to `glslangValidator` (the Khronos GLSL reference compiler). It is not bundled, so install it once:

```sh
brew install glslang            # macOS (provides glslangValidator and the newer `glslang`)
sudo apt install glslang-tools  # Debian / Ubuntu
```

On Windows it ships with the [Vulkan SDK](https://vulkan.lunarg.com/). glslint finds it on `PATH` (trying `glslangValidator`, then `glslang`); set `GLSLINT_GLSLANG` to point at a specific binary. When it is missing, glslint reports the install command for the platform it is running on rather than a bare "not found".

## Install

```sh
brew tap johncarmack1984/tap             # macOS / Linux, once per machine
brew install glslint                     # prebuilt binary + glslang
cargo install glslint                    # Rust toolchain
npm install --save-dev @glslint/cli      # prebuilt binary, no Rust toolchain
```

Tapping is a one-time step; after it, the bare `glslint` name resolves to this formula (it isn't in homebrew-core, so there's nothing to collide with) and `brew upgrade glslint` tracks new releases. `brew install johncarmack1984/tap/glslint` is the same thing in one command — it auto-taps, and the bare name works from then on. The formula installs a prebuilt binary and pulls in `glslang` as a dependency, so `glslangValidator` is there without the separate step above. It covers macOS arm64/x64 and Linux x64 (Homebrew has no Windows). The formula is generated by [`homebrew/prepare.mjs`](homebrew/prepare.mjs) and lives in the [johncarmack1984/homebrew-tap](https://github.com/johncarmack1984/homebrew-tap) tap.

The npm package pulls a prebuilt binary through a per-platform optional dependency (`@glslint/darwin-arm64` and friends), so a deck.gl project downloads one binary and needs no Rust. It installs a plain `glslint` command, so the scope only appears at install time. Prebuilt for macOS arm64 and x64, Linux x64, and Windows x64; anything else uses the cargo route. The packaging lives in [`npm/`](npm/).

The unscoped npm name `glslint` is unavailable: npm's typosquatting filter rejects it as too close to `glslify`, `eslint`, `tslint`, and `dtslint`. Hence the scope.

## Usage

```sh
cargo build
./target/debug/glslint check path/to/shader.frag.glsl   # one-shot, exit 1 on errors
./target/debug/glslint check 'src/**/*.ts'               # lint shaders embedded in JS/TS
./target/debug/glslint lsp                               # language server over stdio
```

`check` resolves config by walking up for a `glsl-lsp.toml`; with none, it uses a built-in deck `project32` prelude and auto-discovers sibling `*Uniforms.glsl` module fragments next to the target. A `.ts`/`.tsx`/`.js`/`.jsx` argument is scanned for GLSL embedded in tagged template literals instead of being treated as one shader (see below).

The faithful form mirrors luma's own model: name the modules once, then bind each shader to the modules it actually uses (the `new Model({modules: [...]})` call in JS). Each shader then gets exactly its modules, so referencing a uniform block the shader doesn't have is flagged instead of silently resolved:

```toml
# glsl-lsp.toml
[[module]]
name = "windUniforms"
source = "src/shaders/windUniforms.glsl"
types  = "src/modules.ts"  # optional: cross-check the UBO block vs JS uniformTypes

[[module]]
name = "project32"
builtin = true            # deck project32 (baked-in stub for now)

[[shader]]
match   = "draw.*.glsl"   # first matching binding wins
modules = ["project32", "windUniforms"]

[[shader]]
match   = "blit.*.glsl"
modules = ["blitUniforms"]
```

Without `[[shader]]` bindings it accepts a legacy global list (`preludes` / `modules` / `builtin_prelude`) applied to every shader. With **no `glsl-lsp.toml` at all**, it first tries to auto-derive each shader's modules from the project's JS/TS `new Model({ modules })` calls (see `derive.rs`), and only falls back to zero-config sibling discovery if that finds nothing. Independently, the shader's own text is sniffed for a [dialect](#shader-dialects) signature (maplibre pragmas, a ShaderToy `mainImage`), so those ecosystems validate with no config too.

## Shaders inside JS/TS

deck.gl/luma.gl's other common shape is a shader written inline next to its `new Model(...)` call as a tagged template, rather than imported from a `.glsl` file with `?raw`. glslint lints those in place. Both the `glsl`…`` tag and the editor-marker form `/* glsl */ `…`` are recognized in `.ts`/`.tsx`/`.js`/`.jsx`/`.mts`/`.cts`/`.mjs`/`.cjs` files:

```ts
const fs = glsl`#version 300 es
precision highp float;
out vec4 fragColor;
void main() { fragColor = vec4(nope, 1.0); }
`;
// error lands on the .ts file: draw.ts:4:32: 'nope' : undeclared identifier
```

Each template's diagnostics — the glslang errors and the source lints alike — are mapped back to the exact **line and column in the host `.ts` file**, so an error on the fourth line of the shader lands on the fourth line of the shader *as written in the TypeScript*. Multiple templates per file are handled independently, the stage is inferred from the shader's text and its binding name (a `gl_Position` write ⇒ vertex; a `main` with neither ⇒ fragment; no `main` ⇒ a chunk checked syntax-only), and when the shader is assigned to a binding that a `new Model({ modules })` call wires up, its luma modules are resolved and injected the same way `derive.rs` does for `?raw` imports.

**Interpolations.** A template containing a `${…}` interpolation isn't a complete translation unit — the injected value can declare symbols the shader uses, or use ones it declares, and glslint can't see either. Validating a shader it can't faithfully reconstruct would risk reporting errors that are really the tool's fault, so such a template is checked with the **source-level lints only** (those match the author's own tokens and don't need a complete unit) and gets one informational `note`. Templates with no interpolation are fully validated. The `${…}` is blanked to spaces — newlines preserved — purely so the lints' line/column math stays exact.

## Shader dialects

A `.glsl` file is incomplete in exactly three ways, and glslint handles all three. Two are covered above: **implicit globals** injected at link time (deck builtins, via the prelude) and **external module references** whose declarations live in sibling fragments (luma/deck UBOs, via `[[module]]` bindings). The third is an **in-file directive DSL** — pragmas an ecosystem's own build step expands into declarations before any compiler runs. maplibre-gl-js is the canonical case: `#pragma maplibre: define lowp float opacity` declares `opacity` and `#pragma maplibre: initialize lowp float opacity` binds it into `main`, so stock glslang — which treats an unknown `#pragma` as a no-op — reports a false `'opacity' : undeclared identifier` on every such shader.

A **dialect** models one ecosystem as data, not code: an optional per-stage prelude (implicit globals), an optional epilogue (a wrapper `main`), and a set of expansion rules that rewrite a directive into GLSL. Built-in presets ship for `maplibre`/`mapbox` (the define/initialize DSL) and `shadertoy` (the implicit `iTime`/`iResolution`/`iChannel*` uniforms plus a `main` that drives `mainImage`). With **no config**, the source is sniffed for a signature (`#pragma maplibre:`, a `mainImage` entry point) so an unconfigured checkout just works; select or pin one explicitly, or turn detection off, in `glsl-lsp.toml`:

```toml
[dialect]
preset = "maplibre"   # "maplibre" | "shadertoy" | "none";  omit to auto-detect
# auto = false          # disable signature sniffing when no preset is set
```

Adding the next ecosystem is a preset or a few config lines — never a fork. A project declares its own directive rules inline; each `[[dialect.expand]]` matches `#pragma <pragma>: <verb> <args…>`, binds the whitespace tokens after the verb to `args`, and substitutes them into the emitted GLSL (`{name}` in `u_{name}` becomes `u_opacity`). A `[dialect].prelude` string composes with the preset's own:

```toml
[[dialect.expand]]
pragma = "myfx"
verb   = "slot"           # omit to match the namespace alone
args   = ["type", "name"]
emit   = "uniform {type} {name};"   # or per-stage: vertex = "…", fragment = "…"
```

**Shared shader library.** A dialect also resolves the *first* kind of incompleteness — implicit globals — from an ecosystem's real source rather than a hand-written stub. maplibre shaders lean on a shared library its build concatenates ahead of every shader: `projectTile`/`projectLineThickness` (projection), `unpack_mix_color`/`get_elevation` (helpers), and globals like `u_projection_matrix` and `PI`. The maplibre dialect discovers those sibling files — `_prelude.<stage>.glsl` and `_projection_mercator.<stage>.glsl` — next to the shader and splices them in verbatim, each line mapped to its own file so a diagnostic there lands on the library, not the shader. Because the files are real, self-contained GLSL (unlike deck's, whose bodies interpolate JS constants and must be reduced to signature stubs), no extraction is needed. Mercator is the canonical projection; its `projectTile` signatures match globe's, so they satisfy a globe shader too.

That shared library is also the detection signal for maplibre's **pragma-free** shaders. Many shaders (`background`, `clipping_mask`, `depth`, …) use no data-driven property, so they carry no `#pragma maplibre:` line — but they still call `projectTile` and write `fragColor` from that library. So, exactly as deck is recognized by its package on disk, a shader sitting next to `_prelude.*.glsl` is detected as maplibre even with no pragma. Across maplibre-gl-js's own shader suite this resolves 66 of 67 shaders with zero config. The lone holdout is the one class no static tool can infer: a **build-time `#define` injected from JS** (hillshade's `NUM_ILLUMINATION_SOURCES`, whose value is a runtime array length). Give it a default and it validates — `prelude = "#ifndef NUM_ILLUMINATION_SOURCES\n#define NUM_ILLUMINATION_SOURCES 2\n#endif"` under `[dialect]` — and the `#ifndef` guard means the real build's value still wins when it's present.

The load-bearing principle: glslint doesn't replicate an ecosystem's runtime semantics — it only makes every symbol resolve with the right **type**, so glslang stops false-erroring while a real type error (assigning a `vec4` property to a `float`) still surfaces on the author's own line. maplibre's expansion mirrors what its `shaders.ts` emits; glslint never defines `HAS_UNIFORM_u_*`, so glslang's preprocessor takes the data-driven branch — the one that exercises the most symbols. (Auto-detection covers stage-named files, `*.fs`/`*.frag`, and embedded shaders with an entry point; a bare-`.glsl` ShaderToy snippet with no stage in its name is still wrapped syntax-only.)

## Editor integration

A minimal VS Code / Cursor extension lives in [`editors/vscode/`](editors/vscode/). It's a thin LSP client that launches `glslint lsp` and shows its diagnostics on GLSL files — and on shaders embedded in `.ts`/`.js` tagged templates, mapped live to the TypeScript line as you type. See that folder's README to run it (`F5` dev host, or `vsce package`). Point `glslint.path` at the built binary (e.g. `target/debug/glslint`) if it isn't on the editor's PATH.

## How it works

- `assemble.rs`: hoists the target's own `#version` to the top, injects default precision (so the deck prelude's `float`/`vec*` are well-formed before the shader's own `precision` line), then the dialect prelude (when one applies) and the prelude + module blocks, recording a per-line map back to the originals. Source is otherwise passed through **verbatim** — the one exception is dialect directive expansion (below), where each rewritten line keeps the loc of the pragma it came from, so a diagnostic inside the expansion still points at the author's `#pragma`. Stage is inferred from the filename (`*.vert.glsl` / `*.frag.glsl` / `*.comp.glsl`); bare module fragments are wrapped in a dummy shell for syntax-only checking.
- `dialect.rs`: models an ecosystem's shader conventions (maplibre's define/initialize DSL, its shared `_prelude`/`_projection_*` library, ShaderToy's implicit uniforms) as data — a prelude, an epilogue, directive-expansion rules, and sibling library files to splice in — so the deck/luma assembler stays generic and a new ecosystem is a preset or a few `[[dialect.expand]]` lines, not a fork. Resolves the active dialect from an explicit `preset`, else auto-detection (a source-signature sniff *or* the shared library found next to the shader on disk), else none; layers project-local rules and prelude on top. See the [Shader dialects](#shader-dialects) section.
- `check.rs`: runs `glslangValidator --stdin -S <stage>` over the assembled unit, parses its `ERROR: 0:LINE:` / `WARNING:` output, collapses glslang's per-line error cascades to the root cause, and translates each line back to the original file:line via the map (refining the column from the offending token when glslang names one). **This mapping is the hard part and it works**: errors land on `draw.vert.glsl:4`, and an error inside an injected module lands on `windUniforms.glsl:3`, not the assembled unit.
- `lints.rs`: opinionated, zero-false-positive rules (currently: GLSL ES 1.00 builtins/qualifiers removed in `#version 300 es`). Runs alongside the validator, so a `varying` declaration draws both the raw glslang error and a friendlier migration hint.
- `drift.rs`: when a module declares a `types` JS file (see config), cross-checks its GLSL UBO block against that file's `uniformTypes` and warns on drift (a member on one side only, or a type mismatch). luma keeps these two in sync by hand; nothing else sees both at once. Conservative: silent unless it can confidently read both sides.
- `symbols.rs`: a line-based symbol scanner over the assembled unit (UBO/interface blocks, top-level `uniform`/`in`/`out`, function definitions, deck builtins), each symbol carrying its original `Loc`. Powers hover, go-to-definition, completion, and the document outline, including the cross-module jump: `wind.uMin` in `draw.vert.glsl` resolves to its declaration in `windUniforms.glsl`.
- `deck.rs`: resolves deck.gl's `project` builtins from `node_modules` instead of a hand-written stub. deck ships the module GLSL as a JS template whose bodies interpolate constants and depend on a `geometry`/`project` UBO, so the bodies can't be spliced; but the function *signatures* are clean GLSL. So it extracts the real signatures, generates empty-body stubs so any project function validates (no dependency graph needed), and records each declaration site, so hover shows the true signature and go-to-definition jumps into the deck source. Falls back to a baked-in 4-function stub when deck isn't installed.
- `derive.rs`: when there's no `glsl-lsp.toml`, recovers each shader's module bindings the way luma already encodes them: by reading the project's `new Model({ vs, fs, modules })` calls. It finds the shader's `?raw` import, the `Model` call that references it, and follows each module identifier to its GLSL source (a local module's `vs:` import, e.g. `windUniforms` → `windUniforms.glsl`) or to the deck builtins (a package import like `project32`). A heuristic scan, not a JS parser: handles multi-line imports, conservative enough to fall back to sibling discovery when it can't confidently resolve. The payoff: deck-wind-layer's shaders validate with their faithful per-shader modules and **no config file**. `blit` bound to `blitUniforms` only, `draw` to `project32` + `windUniforms`. It also exposes `derive_for_binding`, which resolves modules for an embedded shader whose binding comes from a `const vs = glsl`…`` rather than a `?raw` import.
- `embed.rs`: extracts GLSL from JS/TS tagged template literals (`glsl`…`` and `/* glsl */ `…``) so a `.ts`/`.js` file can be checked in place. A heuristic scan like `derive.rs`, it tracks strings/comments so a backtick in a `"…"` string — or `glsl` inside a longer identifier — isn't mistaken for a shader, and it records, per line of reconstructed GLSL, where that line begins in the host file. That per-line host map is what `check.rs` composes with the assembler's own map so a shader error lands on the right `.ts` line **and column** (the assembler maps the shader → its snippet line; `embed` offsets the snippet line → the template's span). Line continuations (`\`+newline) are applied and `${…}` interpolations are blanked to spaces (newlines preserved); a template with any interpolation is checked lints-only, since its translation unit is incomplete.
- `lsp.rs`: tower-lsp; `publishDiagnostics` on open/change/save, plus hover, go-to-definition, completion (`wind.` → member list), and document symbols, filtered to the edited document. It runs for JS/TS documents too, publishing the embedded-shader diagnostics (the GLSL-only symbol features stay on `.glsl` files). Edits are debounced and the (subprocess-spawning) check runs off the async runtime, with a per-document generation guard so a slow check can't clobber a newer edit.

## Why glslangValidator

The validation engine was originally **naga** (`front::glsl`), chosen for in-process Rust with no external dependency. Spikes proved naga's GLSL frontend is a **Vulkan-GLSL** frontend, not a WebGL/OpenGL one:

| Construct | naga `front::glsl` | glslangValidator |
|-----------|--------------------|------------------|
| `#version 300 es` | rejected (accepts only `440/450/460 core`) | **OK** |
| `precision highp float;` | rejected | **OK** |
| `layout(std140) uniform {…}` w/o `binding` | rejected (requires `binding=`) | **OK** |
| combined `sampler2D u;` decl | rejected (wants separate `texture2D` + `sampler`) | **OK** |
| **`sampler2D` as a function parameter** | **rejected**: `Expected RightParen` | **OK** |

That last row was the blocker: deck-wind-layer's two main shaders use

```glsl
vec2 windAt(sampler2D windTex, vec2 pos) { … texture(windTex, pos) … }
```

which naga can't express, and supporting it would have meant rewriting function signatures, internal `texture()` calls, and every call site: scope-aware compiler work. glslangValidator is the Khronos ES reference compiler: it validates `#version 300 es` + combined samplers authoritatively, with **zero source transforms**. The cost is an external binary on `PATH`; the assembler, the diagnostic mapping, the CLI, the LSP, and the lints are all backend-independent and carried over unchanged.

### glslang quirks worth knowing

- All diagnostics go to **stdout** (not stderr), as `ERROR: 0:LINE: 'token' : message`. The `0` is the source-string index (always 0 for our single stdin unit); there's no column, so glslint derives one from the named token.
- glslang does **not** stop at the first error. For a semantic failure it emits the root cause and then a string of *derived* errors (sometimes exact duplicates) on the same source line, and it inlines whole type definitions into some messages (an entire `uniform block{...}` for a bad UBO-member access). glslint therefore keeps the **first diagnostic per source line** (glslang emits the root cause first) and truncates over-long messages. The `compilation terminated` line (a *parse*-phase cascade only) and the `N compilation errors` summary are filtered out separately.
- GLSL ES fragment shaders have **no default `float` precision**, so the assembler injects `precision highp float; precision highp int;` right after `#version`; re-declaring them later (as the shaders do) is legal.

## License

Dual-licensed under either [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.