# glslint
A GLSL checker and language server for the shaders web toolkits actually ship. Stock GLSL tools choke on these files because they aren't standalone translation units: WebGL has no `#include`, so libraries like luma.gl/deck.gl, maplibre-gl-js, and ShaderToy each assemble shaders in JS at build time — a raw `.glsl` references UBO instances (`wind.*`), deck builtins (`project_position_to_clipspace`), maplibre's `#pragma maplibre:` properties and shared `projectTile`/`_prelude` library, or ShaderToy's implicit `iTime`, none of which are in the file. glslint reconstructs the complete unit — discovering the project's shared library and expanding its dialect — validates it with the Khronos glslangValidator reference compiler, and maps diagnostics back to the original file and line.
The core is ecosystem-agnostic and works on an unfamiliar project with **zero config**; ecosystem specifics (maplibre's pragmas, ShaderToy's uniforms) live entirely in data — bundled [presets](presets/) and a project's own `glslint.toml`, which share one schema. See [Shader dialects & presets](#shader-dialects--presets).
## 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` needs no config: it detects the shader's ecosystem from a bundled [preset](#shader-dialects--presets) (or a project's `glslint.toml`) — including deck.gl's `project32` builtins — and discovers the project's shared library (any sibling `.glsl` with no `main`). A project config file, `glslint.toml` (or the legacy `glsl-lsp.toml`), is walked up for and additionally wires luma's per-shader module bindings. 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 & presets
A `.glsl` file in these toolkits is incomplete in one of four ways, and glslint's core handles the first three **with no ecosystem knowledge at all**:
1. **Implicit globals / a shared library** — functions and uniforms the build concatenates ahead of every shader (maplibre's `projectTile`, `unpack_mix_color`, `u_projection_matrix`; luma UBO fragments). The universal signal is structural: a file with `main()` is a *shader*; a file without is a *library*. glslint discovers the no-`main` siblings and splices them in verbatim, each line mapped to its own file, so a diagnostic there lands on the library, not the shader — no filename baked into the linter. Mutually-exclusive variants (mercator vs globe both define `projectTile`) are deduped by function name.
2. **External module references** — declarations in sibling fragments wired per-shader (luma/deck UBOs, via `[[module]]` bindings in `glsl-lsp.toml`, or auto-derived from `new Model({ modules })`), or pulled in with the standard `#include` directive (see below).
3. **Injected globals** — deck builtins, resolved from `node_modules` as signature stubs.
The fourth is an **in-file directive DSL** — a `#pragma` an ecosystem's build expands into declarations, which has no GLSL standard and glslint cannot infer. maplibre is the canonical case: `#pragma maplibre: define lowp float opacity` declares `opacity`, so stock glslang — which no-ops the unknown pragma — reports a false `'opacity' : undeclared identifier`. That one thing needs a **preset**.
### Presets
A preset is ecosystem knowledge as **data**, not code. glslint ships presets for maplibre/mapbox, ShaderToy, and deck.gl in [`presets/`](presets/), compiled in and applied automatically when their `[detect]` rules match. The linter core contains none of it — even deck's `project32` stub prelude lives in [`presets/deck.toml`](presets/deck.toml) (the real signatures are still resolved from `node_modules` for hover/go-to-definition). A preset declares:
- **when it applies** (`[detect]`: a source substring like `#pragma maplibre:`, or a sibling file like `_prelude.vertex.glsl` — so even a pragma-free maplibre shader is recognized);
- **the `#pragma` transform** (`[[expand]]` rules);
- and, for speed, the explicit shared-**`library`** files and injected **`defines`** so the core can skip discovery.
A project configures glslint by dropping a **`glslint.toml`** at its root — using the *identical schema*. So defining a private ecosystem and shipping a bundled preset are the same act; the bundled files are examples to copy. The schema is published at [`schema/glslint.schema.json`](schema/glslint.schema.json). `glslint.toml` is also the unified config file for luma's `[[module]]`/`[[shader]]` bindings (the legacy `glsl-lsp.toml` name still works).
```toml
# glslint.toml — teach glslint a house #pragma DSL
name = "myengine"
[detect]
source_contains = ["#pragma myfx:"]
[[expand]]
pragma = "myfx"
verb = "slot" # omit to match the namespace alone
args = ["type", "name"]
emit = "uniform {type} {name};" # `{name}` in `u_{name}` → `u_opacity`; or per-stage vertex/fragment
```
Adding the next ecosystem is a preset file — never a fork. (You can still select or pin a bundled preset explicitly, or disable detection, via `[dialect]` `preset`/`auto` in `glsl-lsp.toml`.)
### Injected `#define`s
maplibre's build injects one macro from JS at draw time — hillshade's `NUM_ILLUMINATION_SOURCES`, whose value is a runtime array length. No static tool can know the value, and linting doesn't need it (it's an array size); glslint just needs the symbol to resolve. The maplibre preset lists it in `defines`, and glslint supplies an `#ifndef`-guarded default so it type-checks — the real build's value still wins when present. A preset can instead set `discover_defines = true` to find such macros by scanning the project's JS/TS for `#define NAME ${…}` (the interpolated value is the tell); it's off by default because the scan walks the repo, and only names the project *actually* injects are defaulted, so a macro typo is still flagged.
With this, all 67 of maplibre-gl-js's own shaders validate with **zero config** — the shaders that lit up in the editor (`line.vertex.glsl`, `hillshade.fragment.glsl`) draw no error, and every projection/library function resolves from the project's real source.
### Includes
The one cross-file mechanism GLSL actually standardizes is `#include`, via the `GL_GOOGLE_include_directive` / `GL_ARB_shading_language_include` extensions. glslang recognizes them, but since glslint pipes source over stdin, glslang has no base directory to resolve against. So glslint resolves includes itself: it splices each `#include "file"` / `#include <file>` in place (relative to the including file, recursively, breaking cycles), mapping every spliced line back to its own file — so an error inside an included file lands there, not at the `#include` site. Resolution is lenient (it doesn't require the `#extension` line), and a shader that uses `#include` opts out of no-`main` library discovery, since it has declared its dependencies explicitly.
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.
## 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, then the dialect prelude and injected-`#define` defaults (when a preset applies), the discovered/declared shared library, 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, 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.
- `discover.rs`: the ecosystem-agnostic core — reads a project's ground truth rather than encoding it. **Shared-library discovery**: any sibling `.glsl` with no `main` is a library the build concatenates in; they're spliced (variant-deduped by function name), so `projectTile` &c. resolve on an unfamiliar project with no filename baked in. **Injected-define discovery** (opt-in): scans the project's JS/TS for build-injected `#define NAME ${…}` macros (the interpolated value is the signature that separates them from a shader's own literal `#define`), memoized per repo root.
- `include.rs`: resolves the standard `#include "file"` / `#include <file>` directives (`GL_GOOGLE_include_directive`) that glslang can't over stdin — splices each file recursively (relative to the including file, cycle-broken), mapping spliced lines to their own file. A shader that uses `#include` opts out of library discovery.
- `preset.rs`: parses shader-ecosystem knowledge from TOML — bundled [`presets/*.toml`](presets/) compiled in, and a project's own `glslint.toml`, using one schema ([`schema/glslint.schema.json`](schema/glslint.schema.json), `deny_unknown_fields`). Detection iterates every preset's `[detect]` (project presets shadow bundled), so adding an ecosystem is a data file, not Rust. All maplibre/ShaderToy specifics live here, none in the core.
- `dialect.rs`: the runtime model a preset parses into (per-stage preludes, an epilogue, `[[expand]]` rules, library files, injected-define names) plus the expansion engine that rewrites a `#pragma <ns>: <verb> <args…>` line into GLSL by binding tokens to `args` and substituting `{name}` placeholders. Ecosystem-free — it's driven entirely by the data `preset.rs` supplies.
- `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.