glslint 0.7.0

A luma.gl/deck.gl-aware GLSL checker and language server
glslint-0.7.0 is not a library.

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:

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. 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

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 and lives in the 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/.

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

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:

# 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 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:

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:

[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:

[[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/. 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 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. windUniformswindUniforms.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

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 or Apache-2.0, at your option.