glslint 0.7.0

A luma.gl/deck.gl-aware GLSL checker and language server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Per-file configuration: which preludes and luma/deck module fragments to
//! splice in before validating. Resolved from a `glsl-lsp.toml` found by walking
//! up from the target file, or sensible zero-config defaults.
//!
//! The richest 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, not every sibling:
//!
//! ```toml
//! [[module]]
//! name = "windUniforms"
//! source = "src/shaders/windUniforms.glsl"
//!
//! [[module]]
//! name = "project32"
//! builtin = true          # deck project32 (baked-in stub for now)
//!
//! [[shader]]
//! match   = "draw.*.glsl"  # first matching binding wins
//! modules = ["project32", "windUniforms"]
//! ```
//!
//! Without `[[shader]]` bindings it falls back to a legacy global `modules` list,
//! and with no `glsl-lsp.toml` at all to zero-config sibling discovery.

use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize, Default)]
struct ConfigFile {
    /// Extra GLSL files prepended verbatim (after `#version`, before modules).
    #[serde(default)]
    preludes: Vec<String>,
    /// Legacy global module list (applied to every shader). Superseded by
    /// `[[module]]` + `[[shader]]` bindings when those are present.
    #[serde(default)]
    modules: Vec<String>,
    /// Legacy global toggle for the baked-in deck prelude.
    builtin_prelude: Option<bool>,
    /// Named module definitions (`[[module]]`).
    #[serde(default, rename = "module")]
    module_defs: Vec<ModuleDef>,
    /// Per-shader module bindings (`[[shader]]`).
    #[serde(default, rename = "shader")]
    shaders: Vec<ShaderBinding>,
    /// Shader-dialect selection and custom directive rules (`[dialect]`).
    #[serde(default)]
    dialect: DialectFile,
}

/// The `[dialect]` table: pick a built-in preset, toggle auto-detection, and/or
/// extend it with project-local rules and prelude.
#[derive(Debug, Clone, Deserialize, Default)]
struct DialectFile {
    /// A built-in preset name (`"maplibre"`, `"shadertoy"`, `"none"`). When set,
    /// it replaces auto-detection as the base dialect.
    preset: Option<String>,
    /// Whether to sniff the source for a dialect when no `preset` is given.
    /// Defaults to on, so a maplibre checkout works with zero config.
    auto: Option<bool>,
    /// Extra prelude prepended for every stage (implicit globals).
    prelude: Option<String>,
    /// Project-local expansion rules (`[[dialect.expand]]`), merged ahead of the
    /// base dialect's own so a project can override a preset rule.
    #[serde(default, rename = "expand")]
    expand: Vec<ExpandRuleFile>,
}

/// One `[[dialect.expand]]` rule in `glsl-lsp.toml`.
#[derive(Debug, Clone, Deserialize)]
struct ExpandRuleFile {
    /// Pragma namespace: matches `#pragma <pragma>: …`.
    pragma: String,
    /// First token after the namespace to match, or omit to match any.
    verb: Option<String>,
    /// Names bound, in order, to the remaining whitespace tokens.
    #[serde(default)]
    args: Vec<String>,
    /// Expansion template; `{arg}` placeholders are substituted. Applies to every
    /// stage unless a stage-specific field overrides it.
    emit: Option<String>,
    /// Vertex-stage override for `emit`.
    vertex: Option<String>,
    /// Fragment-stage override for `emit`.
    fragment: Option<String>,
    /// Compute-stage override for `emit`.
    compute: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct ModuleDef {
    name: String,
    /// A `.glsl` file providing the module's declarations.
    source: Option<String>,
    /// The baked-in deck project32 prelude stub.
    #[serde(default)]
    builtin: bool,
    /// A JS/TS file whose `uniformTypes` mirrors this module's UBO block. When
    /// set, glslint cross-checks the two and warns on drift.
    types: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct ShaderBinding {
    /// Glob matched against the shader's filename (or path relative to the toml
    /// when the pattern contains `/`).
    #[serde(rename = "match")]
    pattern: String,
    /// Module names (from `[[module]]`) this shader uses.
    #[serde(default)]
    modules: Vec<String>,
    /// Override the deck prelude for this binding (else inferred from a bound
    /// `builtin` module).
    builtin_prelude: Option<bool>,
}

#[derive(Debug, Clone)]
pub struct Config {
    pub preludes: Vec<PathBuf>,
    pub modules: Vec<PathBuf>,
    pub use_builtin_prelude: bool,
    /// Which shader dialect (maplibre, shadertoy, …) and custom directive rules
    /// apply. Resolved to a concrete dialect at assembly time, once the source is
    /// known (auto-detection needs it).
    pub dialect: crate::dialect::Preference,
}

/// Build a dialect [`Preference`](crate::dialect::Preference) from the `[dialect]`
/// table, translating each `[[dialect.expand]]` entry into a rule.
fn dialect_pref(df: &DialectFile) -> crate::dialect::Preference {
    let custom_rules = df
        .expand
        .iter()
        .map(|e| crate::dialect::Rule {
            ns: e.pragma.clone(),
            verb: e.verb.clone(),
            args: e.args.clone(),
            vertex: e.vertex.clone(),
            fragment: e.fragment.clone(),
            compute: e.compute.clone(),
            any: e.emit.clone(),
        })
        .collect();
    crate::dialect::Preference {
        preset: df.preset.clone(),
        auto: df.auto.unwrap_or(true),
        custom_rules,
        custom_prelude: df.prelude.clone(),
    }
}

impl Config {
    /// Resolve config for `file`. Infallible: any IO/parse error degrades to the
    /// zero-config default rather than failing the check.
    pub fn resolve_for(file: &Path) -> Config {
        let dir = file.parent().unwrap_or(Path::new("."));

        if let Some(toml_path) = find_up(dir, "glsl-lsp.toml")
            && let Ok(text) = std::fs::read_to_string(&toml_path)
            && let Ok(cf) = toml::from_str::<ConfigFile>(&text)
        {
            let base = toml_path.parent().unwrap_or(Path::new("."));
            // Per-shader bindings take precedence over the legacy global list.
            if !cf.shaders.is_empty() {
                return resolve_bindings(file, &cf, base);
            }
            return Config {
                preludes: join_all(base, &cf.preludes),
                modules: join_all(base, &cf.modules),
                use_builtin_prelude: cf.builtin_prelude.unwrap_or(true),
                dialect: dialect_pref(&cf.dialect),
            };
        }

        // No glsl-lsp.toml — try to auto-derive the bindings from the JS
        // `new Model({ modules })` calls before falling back to sibling discovery.
        if let Some(d) = crate::derive::derive(file) {
            return Config {
                preludes: Vec::new(),
                modules: d.modules,
                use_builtin_prelude: d.use_builtin_prelude,
                dialect: crate::dialect::Preference::default(),
            };
        }

        // Zero-config: builtin deck/luma prelude + auto-discovered sibling UBO
        // fragments (`*Uniforms.glsl`) next to the target. Dialect auto-detection
        // stays on, so a maplibre checkout with no config still validates.
        Config {
            preludes: Vec::new(),
            modules: discover_sibling_modules(dir),
            use_builtin_prelude: true,
            dialect: crate::dialect::Preference::default(),
        }
    }
}

/// Resolve the modules for `file` from the first `[[shader]]` binding whose glob
/// matches it. An unmatched shader gets only the builtin prelude (it's likely a
/// module fragment or not yet bound).
fn resolve_bindings(file: &Path, cf: &ConfigFile, base: &Path) -> Config {
    let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
    let rel = file
        .strip_prefix(base)
        .ok()
        .and_then(|r| r.to_str())
        .map(|s| s.replace('\\', "/"));
    let matches =
        |pat: &str| glob_match(pat, name) || rel.as_deref().is_some_and(|r| glob_match(pat, r));

    let Some(binding) = cf.shaders.iter().find(|s| matches(&s.pattern)) else {
        return Config {
            preludes: join_all(base, &cf.preludes),
            modules: Vec::new(),
            use_builtin_prelude: cf.builtin_prelude.unwrap_or(true),
            dialect: dialect_pref(&cf.dialect),
        };
    };

    let mut modules = Vec::new();
    let mut wants_builtin = false;
    for module_name in &binding.modules {
        // An unknown module name is ignored (could warn once we have a channel).
        if let Some(def) = cf.module_defs.iter().find(|m| &m.name == module_name) {
            if let Some(src) = &def.source {
                modules.push(base.join(src));
            }
            wants_builtin |= def.builtin;
        }
    }

    Config {
        preludes: join_all(base, &cf.preludes),
        modules,
        use_builtin_prelude: binding.builtin_prelude.unwrap_or(wants_builtin),
        dialect: dialect_pref(&cf.dialect),
    }
}

fn join_all(base: &Path, rels: &[String]) -> Vec<PathBuf> {
    rels.iter().map(|p| base.join(p)).collect()
}

/// If `file` is a module's GLSL `source` and that module declares a `types` JS
/// file, return `(module name, resolved types path)` for the drift check.
pub fn drift_for(file: &Path) -> Option<(String, PathBuf)> {
    let dir = file.parent().unwrap_or(Path::new("."));
    let toml_path = find_up(dir, "glsl-lsp.toml")?;
    let text = std::fs::read_to_string(&toml_path).ok()?;
    let cf: ConfigFile = toml::from_str(&text).ok()?;
    let base = toml_path.parent().unwrap_or(Path::new("."));
    for m in &cf.module_defs {
        if let (Some(src), Some(types)) = (&m.source, &m.types)
            && same_path(&base.join(src), file)
        {
            return Some((m.name.clone(), base.join(types)));
        }
    }
    None
}

fn same_path(a: &Path, b: &Path) -> bool {
    match (a.canonicalize(), b.canonicalize()) {
        (Ok(x), Ok(y)) => x == y,
        _ => a == b,
    }
}

/// Match `text` against a simple glob: `*` is any run (including `/`), `?` is any
/// single char. Backtracking two-pointer; no character classes.
fn glob_match(pattern: &str, text: &str) -> bool {
    let (p, t) = (pattern.as_bytes(), text.as_bytes());
    let (mut pi, mut ti) = (0, 0);
    let (mut star, mut resume) = (None, 0);
    while ti < t.len() {
        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
            pi += 1;
            ti += 1;
        } else if pi < p.len() && p[pi] == b'*' {
            star = Some(pi);
            resume = ti;
            pi += 1;
        } else if let Some(s) = star {
            pi = s + 1;
            resume += 1;
            ti = resume;
        } else {
            return false;
        }
    }
    while pi < p.len() && p[pi] == b'*' {
        pi += 1;
    }
    pi == p.len()
}

fn discover_sibling_modules(dir: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Ok(entries) = std::fs::read_dir(dir) {
        for e in entries.flatten() {
            let p = e.path();
            if p.file_name()
                .and_then(|n| n.to_str())
                .is_some_and(|n| n.ends_with("Uniforms.glsl"))
            {
                out.push(p);
            }
        }
    }
    out.sort();
    out
}

fn find_up(start: &Path, name: &str) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(d) = dir {
        let candidate = d.join(name);
        if candidate.is_file() {
            return Some(candidate);
        }
        // Stop climbing at a repo root.
        if d.join(".git").exists() {
            break;
        }
        dir = d.parent();
    }
    None
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // test code: unwrap IS the assertion
mod tests {
    use super::*;

    #[test]
    fn glob_matches_filename_patterns() {
        assert!(glob_match("draw.*.glsl", "draw.vert.glsl"));
        assert!(glob_match("draw.*.glsl", "draw.frag.glsl"));
        assert!(glob_match("*.frag.glsl", "blit.frag.glsl"));
        assert!(glob_match("blit.vert.glsl", "blit.vert.glsl"));
        assert!(!glob_match("draw.*.glsl", "blit.vert.glsl"));
        assert!(!glob_match("draw.*.glsl", "draw.vert.glsl.bak"));
        assert!(glob_match(
            "src/shaders/draw.*",
            "src/shaders/draw.vert.glsl"
        ));
    }

    fn cf() -> ConfigFile {
        ConfigFile {
            module_defs: vec![
                ModuleDef {
                    name: "windUniforms".into(),
                    source: Some("src/shaders/windUniforms.glsl".into()),
                    builtin: false,
                    types: None,
                },
                ModuleDef {
                    name: "blitUniforms".into(),
                    source: Some("src/shaders/blitUniforms.glsl".into()),
                    builtin: false,
                    types: None,
                },
                ModuleDef {
                    name: "project32".into(),
                    source: None,
                    builtin: true,
                    types: None,
                },
            ],
            shaders: vec![
                ShaderBinding {
                    pattern: "draw.*.glsl".into(),
                    modules: vec!["project32".into(), "windUniforms".into()],
                    builtin_prelude: None,
                },
                ShaderBinding {
                    pattern: "blit.*.glsl".into(),
                    modules: vec!["blitUniforms".into()],
                    builtin_prelude: None,
                },
            ],
            ..Default::default()
        }
    }

    #[test]
    fn binding_resolves_each_shader_to_its_own_modules() {
        let base = Path::new("/proj");
        // draw -> project32 (builtin) + windUniforms (a file), no blit module.
        let draw = resolve_bindings(Path::new("/proj/src/shaders/draw.vert.glsl"), &cf(), base);
        assert!(draw.use_builtin_prelude);
        assert_eq!(
            draw.modules,
            vec![PathBuf::from("/proj/src/shaders/windUniforms.glsl")]
        );

        // blit -> only blitUniforms, and NOT the deck prelude (it uses no project).
        let blit = resolve_bindings(Path::new("/proj/src/shaders/blit.frag.glsl"), &cf(), base);
        assert!(!blit.use_builtin_prelude);
        assert_eq!(
            blit.modules,
            vec![PathBuf::from("/proj/src/shaders/blitUniforms.glsl")]
        );
    }

    #[test]
    fn unmatched_shader_gets_only_the_prelude() {
        let cfg = resolve_bindings(
            Path::new("/proj/src/shaders/windUniforms.glsl"),
            &cf(),
            Path::new("/proj"),
        );
        assert!(cfg.modules.is_empty());
    }
}