glslint 0.8.0

A GLSL checker and language server for WebGL shader toolkits (luma.gl/deck.gl, maplibre, ShaderToy) and any project
glslint-0.8.0 is not a library.

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 and a project's own glslint.toml, which share one schema. See Shader dialects & presets.

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 needs no config: it detects the shader's ecosystem from a bundled preset (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:

# 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 & 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/, 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 (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. glslint.toml is also the unified config file for luma's [[module]]/[[shader]] bindings (the legacy glsl-lsp.toml name still works).

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

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/. 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 compiled in, and a project's own glslint.toml, using one schema (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. 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.