use crate::assemble::{self, Assembled};
use crate::config::Config;
use crate::diagnostics::{Diag, Severity};
use crate::embed::{self, Embedded};
use crate::lints;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::process::{Command, Stdio};
pub fn check_file(path: &Path) -> anyhow::Result<Vec<Diag>> {
let source = std::fs::read_to_string(path)?;
Ok(check_source(path, &source))
}
pub fn check_source(path: &Path, source: &str) -> Vec<Diag> {
if embed::is_js_ts(path) {
return check_embedded(path, source);
}
let config = Config::resolve_for(path);
let assembled = assemble::assemble(path, source, &config);
let mut diags = check_assembled(&assembled);
diags.extend(lints::run_lints(path, source));
if let Some((module, types_path)) = crate::config::drift_for(path) {
diags.extend(crate::drift::check(path, source, &module, &types_path));
}
diags.sort_by(|a, b| (a.path.as_path(), a.line, a.col).cmp(&(b.path.as_path(), b.line, b.col)));
diags
}
fn check_embedded(host: &Path, source: &str) -> Vec<Diag> {
let mut diags = Vec::new();
for emb in embed::extract(source) {
diags.extend(check_one_embedded(host, source, &emb));
}
diags.sort_by(|a, b| (a.path.as_path(), a.line, a.col).cmp(&(b.path.as_path(), b.line, b.col)));
diags
}
fn check_one_embedded(host: &Path, source: &str, emb: &Embedded) -> Vec<Diag> {
let mut out = Vec::new();
for d in lints::run_lints(host, &emb.source) {
out.push(remap(d, host, emb));
}
if emb.has_interp {
out.push(interp_note(host, emb));
return out;
}
let config = config_for_embedded(host, source, emb);
let assembled =
assemble::assemble_embedded(host, &emb.source, &config, emb.stage, emb.has_entry);
for d in check_assembled(&assembled) {
out.push(remap(d, host, emb));
}
out
}
fn config_for_embedded(host: &Path, source: &str, emb: &Embedded) -> Config {
if let Some(name) = &emb.name
&& let Some(d) = crate::derive::derive_for_binding(source, host, name)
{
return Config {
preludes: Vec::new(),
modules: d.modules,
use_builtin_prelude: d.use_builtin_prelude,
dialect: crate::dialect::Preference::default(),
};
}
Config::resolve_for(host)
}
fn remap(d: Diag, host: &Path, emb: &Embedded) -> Diag {
if d.path.as_path() != host {
return d;
}
let pos = emb.map(d.line, d.col);
Diag {
path: host.to_path_buf(),
line: pos.line,
col: pos.col,
..d
}
}
fn interp_note(host: &Path, emb: &Embedded) -> Diag {
let pos = emb
.line_map
.first()
.copied()
.unwrap_or(embed::HostPos { line: 1, col: 1 });
Diag {
path: host.to_path_buf(),
line: pos.line,
col: pos.col,
len: 1,
severity: Severity::Note,
message: "embedded shader has a ${…} interpolation — checked with lints only \
(glslang validation skipped)"
.to_string(),
source: "embed",
}
}
fn check_assembled(a: &Assembled) -> Vec<Diag> {
let run = match run_glslang(a) {
Ok(run) => run,
Err(RunError::NotFound) => {
return vec![tool_error(a, missing_glslang_message())];
}
Err(RunError::BadOverride(e)) => {
return vec![tool_error(
a,
format!(
"GLSLINT_GLSLANG is set but couldn't be run ({e}) — point it at \
the glslangValidator (or glslang) executable"
),
)];
}
Err(RunError::Io(e)) => {
return vec![tool_error(
a,
format!("failed to run glslangValidator: {e}"),
)];
}
};
let diags = parse_output(a, &run.output);
if diags.is_empty() && !run.success {
let detail = run
.output
.lines()
.map(str::trim)
.filter(|l| l.starts_with("ERROR:") || l.starts_with("WARNING:"))
.find(|l| !l.contains("compilation error") && !l.contains("compilation warning"))
.unwrap_or("unknown error");
return vec![tool_error(a, format!("glslangValidator failed: {detail}"))];
}
diags
}
enum RunError {
NotFound,
BadOverride(std::io::Error),
Io(std::io::Error),
}
struct GlslangRun {
output: String,
success: bool,
}
fn run_glslang(a: &Assembled) -> Result<GlslangRun, RunError> {
let (candidates, explicit) = glslang_candidates();
for bin in candidates {
let mut child = match Command::new(&bin)
.arg("--stdin")
.arg("-S")
.arg(a.stage.glslang_stage())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) if explicit => return Err(RunError::BadOverride(e)),
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(RunError::Io(e)),
};
{
let mut stdin = child.stdin.take().expect("stdin was piped");
if let Err(e) = stdin.write_all(a.source.as_bytes()) {
return Err(RunError::Io(e));
}
}
let out = child.wait_with_output().map_err(RunError::Io)?;
let mut output = String::from_utf8_lossy(&out.stdout).into_owned();
output.push_str(&String::from_utf8_lossy(&out.stderr));
return Ok(GlslangRun {
output,
success: out.status.success(),
});
}
Err(RunError::NotFound)
}
fn missing_glslang_message() -> String {
let install = if cfg!(target_os = "macos") {
"install it with `brew install glslang`"
} else if cfg!(target_os = "windows") {
"install the Vulkan SDK, which ships glslangValidator.exe"
} else {
"install it with `apt install glslang-tools` (or your distro's glslang package)"
};
format!(
"glslangValidator not found on PATH — {install}, \
or set GLSLINT_GLSLANG to an existing binary"
)
}
fn glslang_candidates() -> (Vec<String>, bool) {
if let Some(bin) = std::env::var_os("GLSLINT_GLSLANG") {
return (vec![bin.to_string_lossy().into_owned()], true);
}
(
vec!["glslangValidator".to_string(), "glslang".to_string()],
false,
)
}
fn parse_output(a: &Assembled, output: &str) -> Vec<Diag> {
let mut mapped = Vec::new();
let mut fileless = Vec::new();
for line in output.lines() {
let (severity, rest) = if let Some(r) = line.strip_prefix("ERROR: ") {
(Severity::Error, r)
} else if let Some(r) = line.strip_prefix("WARNING: ") {
(Severity::Warning, r)
} else {
continue;
};
match parse_located(rest) {
Some((lineno, token, msg)) => {
if msg.contains("compilation terminated") {
continue;
}
if let Some(d) = map_located(a, lineno, token.as_deref(), severity, msg) {
mapped.push(d);
}
}
None => {
let msg = rest.trim();
if msg.contains("compilation error") || msg.contains("compilation warning") {
continue;
}
fileless.push((severity, msg.to_string()));
}
}
}
if !mapped.is_empty() {
return collapse_per_line(mapped);
}
fileless
.into_iter()
.map(|(severity, message)| Diag {
path: a.target.clone(),
line: 1,
col: 1,
len: 1,
severity,
message,
source: "glslang",
})
.collect()
}
fn parse_located(rest: &str) -> Option<(u32, Option<String>, String)> {
let mut parts = rest.splitn(3, ':');
let _str_no: u32 = parts.next()?.trim().parse().ok()?;
let lineno: u32 = parts.next()?.trim().parse().ok()?;
let message = parts.next()?.trim().to_string();
let token = message
.strip_prefix('\'')
.and_then(|after| after.find('\'').map(|end| after[..end].to_string()))
.filter(|t| !t.is_empty());
Some((lineno, token, message))
}
fn map_located(
a: &Assembled,
asm_line: u32,
token: Option<&str>,
severity: Severity,
message: String,
) -> Option<Diag> {
let idx = asm_line.checked_sub(1)? as usize;
match a.map.get(idx)? {
Some(loc) => {
let (col, len) = a
.source
.lines()
.nth(idx)
.and_then(|text| locate_token(text, token))
.unwrap_or((1, 1));
Some(Diag {
path: loc.path.clone(),
line: loc.line,
col,
len,
severity,
message: truncate_message(message),
source: "glslang",
})
}
None => Some(Diag {
path: a.target.clone(),
line: 1,
col: 1,
len: 1,
severity,
message: truncate_message(format!("(in glslint-injected code) {message}")),
source: "glslang",
}),
}
}
fn locate_token(line: &str, token: Option<&str>) -> Option<(u32, u32)> {
let tok = token?;
let first = tok.chars().next()?;
if !(first.is_alphanumeric() || first == '_') {
return None; }
let bytes = line.as_bytes();
let mut from = 0;
while let Some(rel) = line[from..].find(tok) {
let start = from + rel;
let end = start + tok.len();
let left_ok = start == 0 || !is_word_byte(bytes[start - 1]);
let right_ok = end == line.len() || !is_word_byte(bytes[end]);
if left_ok && right_ok {
let col = line[..start].chars().count() as u32 + 1;
return Some((col, tok.chars().count() as u32));
}
from = start + 1;
}
None
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn collapse_per_line(diags: Vec<Diag>) -> Vec<Diag> {
let mut seen = std::collections::HashSet::new();
diags
.into_iter()
.filter(|d| seen.insert((d.path.clone(), d.line)))
.collect()
}
fn truncate_message(message: String) -> String {
const MAX: usize = 200;
if message.chars().count() > MAX {
let mut t: String = message.chars().take(MAX).collect();
t.push('…');
t
} else {
message
}
}
fn tool_error(a: &Assembled, message: String) -> Diag {
Diag {
path: a.target.clone(),
line: 1,
col: 1,
len: 1,
severity: Severity::Error,
message,
source: "glslint",
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use crate::assemble::{Assembled, Loc, Stage};
use std::path::PathBuf;
fn assembled(map: Vec<Option<Loc>>, source: &str) -> Assembled {
Assembled {
source: source.to_string(),
stage: Stage::Fragment,
map,
target: PathBuf::from("/proj/draw.frag.glsl"),
note: None,
}
}
#[test]
fn missing_glslang_message_names_an_installer_and_the_override() {
let m = missing_glslang_message();
assert!(m.contains("GLSLINT_GLSLANG"), "no override hint: {m}");
let installer = if cfg!(target_os = "macos") {
"brew install glslang"
} else if cfg!(target_os = "windows") {
"Vulkan SDK"
} else {
"glslang-tools"
};
assert!(
m.contains(installer),
"no install hint for this platform: {m}"
);
assert!(!m.contains('\n'), "message must stay single-line: {m}");
}
#[test]
fn parse_located_extracts_line_token_and_verbatim_message() {
let (line, token, msg) =
parse_located("0:5: 'undefined_a' : undeclared identifier ").unwrap();
assert_eq!(line, 5);
assert_eq!(token.as_deref(), Some("undefined_a"));
assert_eq!(msg, "'undefined_a' : undeclared identifier");
}
#[test]
fn parse_located_empty_token_becomes_none() {
let (line, token, msg) = parse_located("0:5: '' : compilation terminated ").unwrap();
assert_eq!(line, 5);
assert_eq!(token, None);
assert!(msg.contains("compilation terminated"));
}
#[test]
fn parse_located_file_level_messages_have_no_prefix() {
assert!(
parse_located("#version: only version 300, 310, and 320 support the es profile")
.is_none()
);
assert!(parse_located("version not supported").is_none());
}
#[test]
fn locate_token_returns_char_column_and_length() {
let line = " float alpha = nope;";
let (col, len) = locate_token(line, Some("nope")).unwrap();
assert_eq!(len, 4);
assert_eq!(col, line.find("nope").unwrap() as u32 + 1);
}
#[test]
fn locate_token_column_is_char_based_not_byte_based() {
let line = "x = café + bad;"; let (col, _) = locate_token(line, Some("bad")).unwrap();
let char_col = line.chars().position(|c| c == 'b').unwrap() as u32 + 1;
let byte_col = line.find("bad").unwrap() as u32 + 1;
assert_eq!(col, char_col);
assert_ne!(col, byte_col);
}
#[test]
fn locate_token_skips_operators_and_absent_tokens() {
assert_eq!(locate_token("a = b;", Some("=")), None); assert_eq!(locate_token("float x;", Some("zzz")), None); assert_eq!(locate_token("float x;", None), None); }
#[test]
fn locate_token_matches_whole_words_only() {
let line = "float speedFactor; x = speed;";
let (col, len) = locate_token(line, Some("speed")).unwrap();
assert_eq!(len, 5);
assert_eq!(col, line.rfind("speed").unwrap() as u32 + 1);
assert_eq!(locate_token("vec2 position;", Some("pos")), None);
}
#[test]
fn map_located_surfaces_injected_code_errors_at_line_one() {
let a = assembled(vec![None], "#version 300 es\n");
let d = map_located(&a, 1, None, Severity::Error, "boom".into()).unwrap();
assert_eq!(d.line, 1);
assert_eq!(d.path, a.target);
assert!(d.message.contains("glslint-injected"));
}
#[test]
fn map_located_retargets_to_the_injected_module_file() {
let module = PathBuf::from("/proj/windUniforms.glsl");
let a = assembled(
vec![
None,
Some(Loc {
path: module.clone(),
line: 3,
}),
],
"#version 300 es\nfloat uMax;\n",
);
let d = map_located(&a, 2, Some("uMax"), Severity::Error, "boom".into()).unwrap();
assert_eq!(d.path, module);
assert_eq!(d.line, 3);
}
#[test]
fn map_located_truncates_giant_messages() {
let a = assembled(
vec![Some(Loc {
path: PathBuf::from("/proj/x.frag.glsl"),
line: 1,
})],
"x\n",
);
let giant = format!("'=' : cannot convert from {}", "a".repeat(500));
let d = map_located(&a, 1, None, Severity::Error, giant).unwrap();
assert!(d.message.chars().count() <= 201);
assert!(d.message.ends_with('…'));
}
#[test]
fn parse_output_collapses_cascade_to_first_per_line() {
let a = assembled(
vec![Some(Loc {
path: PathBuf::from("/proj/x.frag.glsl"),
line: 7,
})],
"ignored\n",
);
let out = "x.frag.glsl\n\
ERROR: 0:1: 'speed' : no such field in structure 'wind'\n\
ERROR: 0:1: '=' : cannot convert from a-giant-block-type\n\
ERROR: 0:1: '' : compilation terminated \n\
ERROR: 1 compilation errors. No code generated.\n";
let diags = parse_output(&a, out);
assert_eq!(diags.len(), 1);
assert!(diags[0].message.contains("no such field"));
assert_eq!(diags[0].line, 7);
}
#[test]
fn parse_output_routes_file_level_errors_to_line_one() {
let a = assembled(vec![None], "#version 999 es\n");
let out = "stdin\n\
ERROR: #version: only version 300, 310, and 320 support the es profile\n\
ERROR: version not supported\n\
ERROR: 1 compilation errors. No code generated.\n";
let diags = parse_output(&a, out);
assert!(!diags.is_empty());
assert!(diags.iter().all(|d| d.line == 1));
assert!(diags.iter().any(|d| d.message.contains("version")));
}
fn glslang_on_path() -> bool {
["glslangValidator", "glslang"].iter().any(|b| {
Command::new(b)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok()
})
}
#[test]
fn embedded_interpolation_is_lints_only_with_a_note() {
let src = include_str!("../tests/fixtures/interpolated.ts");
let diags = check_source(Path::new("interpolated.ts"), src);
let notes: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Note)
.collect();
assert_eq!(notes.len(), 2, "diags: {diags:#?}");
assert_eq!(notes[0].line, 3);
assert_eq!(notes[1].line, 14);
assert!(notes.iter().all(|d| d.source == "embed"));
let varying = diags
.iter()
.find(|d| d.message.contains("varying"))
.expect("varying lint");
assert_eq!(varying.severity, Severity::Warning);
assert_eq!(varying.line, 16);
let frag = diags
.iter()
.find(|d| d.message.contains("gl_FragColor"))
.expect("gl_FragColor lint");
assert_eq!(frag.line, 17);
assert!(diags.iter().all(|d| d.source != "glslang"));
}
#[test]
fn embedded_error_maps_to_the_host_template_line() {
let src = include_str!("../tests/fixtures/tagged.ts");
let diags = check_source(Path::new("tagged.ts"), src);
if glslang_on_path() {
let errs: Vec<_> = diags
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert_eq!(errs.len(), 1, "diags: {diags:#?}");
assert_eq!(errs[0].line, 8);
assert_eq!(errs[0].source, "glslang");
assert!(errs[0].message.contains("nope"));
} else {
assert!(diags.iter().any(|d| d.line == 4 && d.source == "glslint"));
}
}
#[test]
fn embedded_stage_guess_does_not_false_positive_on_valid_shaders() {
if !glslang_on_path() {
return;
}
let src = include_str!("../tests/fixtures/stages.ts");
let diags = check_source(Path::new("stages.ts"), src);
assert!(
diags.iter().all(|d| d.severity != Severity::Error),
"unexpected errors on valid shaders: {diags:#?}"
);
}
const MAPLIBRE_LINE_VERTEX: &str = "\
layout(location = 0) in ivec2 a_pos_normal;
uniform lowp float u_device_pixel_ratio;
out highp float v_linesofar;
#pragma maplibre: define highp vec4 color
#pragma maplibre: define lowp float opacity
#pragma maplibre: define mediump float width
void main() {
#pragma maplibre: initialize highp vec4 color
#pragma maplibre: initialize lowp float opacity
#pragma maplibre: initialize mediump float width
if (opacity < 0.01) {
gl_Position = vec4(-2.0, -2.0, -2.0, 1.0);
return;
}
gl_Position = vec4(color.rgb, 1.0) * 0.0 + vec4(width, v_linesofar, 0.0, 1.0);
}
";
#[test]
fn maplibre_pragmas_resolve_via_bundled_preset() {
if !glslang_on_path() {
return;
}
let diags = check_source(Path::new("line.vertex.glsl"), MAPLIBRE_LINE_VERTEX);
assert!(
diags.iter().all(|d| d.severity != Severity::Error),
"maplibre pragmas should resolve, got: {diags:#?}"
);
}
#[test]
fn maplibre_dialect_still_catches_real_type_errors() {
if !glslang_on_path() {
return;
}
let src = "\
#pragma maplibre: define highp vec4 color
void main() {
#pragma maplibre: initialize highp vec4 color
float x = color;
gl_Position = vec4(x);
}
";
let diags = check_source(Path::new("bad.vertex.glsl"), src);
assert!(
diags.iter().any(|d| d.severity == Severity::Error),
"a real type error must survive the dialect: {diags:#?}"
);
}
}