use crate::diagnostics::{Diag, Severity};
use std::collections::{HashMap, HashSet};
use std::path::Path;
pub fn check(
glsl_path: &Path,
glsl_source: &str,
module_name: &str,
types_path: &Path,
) -> Vec<Diag> {
let Ok(js) = std::fs::read_to_string(types_path) else {
return Vec::new();
};
let js_label = types_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("uniformTypes");
check_against(glsl_path, glsl_source, module_name, &js, js_label)
}
fn check_against(
glsl_path: &Path,
glsl_source: &str,
module_name: &str,
js: &str,
js_label: &str,
) -> Vec<Diag> {
let Some(js_types) = extract_uniform_types(js, module_name) else {
return Vec::new(); };
let glsl = glsl_members(glsl_source);
if glsl.is_empty() || js_types.is_empty() {
return Vec::new();
}
let js_map: HashMap<&str, &str> = js_types
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let glsl_names: HashSet<&str> = glsl.iter().map(|(n, ..)| n.as_str()).collect();
let mut out = Vec::new();
for (name, gtype, line, col) in &glsl {
match js_map.get(name.as_str()) {
None => out.push(warn(
glsl_path,
*line,
*col,
name.chars().count() as u32,
format!("`{name}` is in the GLSL block but not in `{js_label}` uniformTypes"),
)),
Some(luma) => {
if let Some(expected) = luma_to_glsl(luma)
&& expected != gtype
{
out.push(warn(
glsl_path,
*line,
*col,
name.chars().count() as u32,
format!("type drift: GLSL `{gtype} {name}` vs uniformTypes `{name}: '{luma}'` (expected `{expected}`)"),
));
}
}
}
}
for (name, _) in &js_types {
if !glsl_names.contains(name.as_str()) {
out.push(warn(
glsl_path,
1,
1,
1,
format!("`{js_label}` declares `{name}`, but the GLSL block has no such member"),
));
}
}
out
}
fn warn(path: &Path, line: u32, col: u32, len: u32, message: String) -> Diag {
Diag {
path: path.to_path_buf(),
line,
col,
len,
severity: Severity::Warning,
message,
source: "drift",
}
}
fn glsl_members(source: &str) -> Vec<(String, String, u32, u32)> {
let mut out = Vec::new();
let mut inside = false;
for (i, line) in source.lines().enumerate() {
let t = line.trim();
if !inside {
if t.contains("uniform") && t.ends_with('{') {
inside = true;
}
continue;
}
if t.starts_with('}') {
break;
}
if let Some(body) = t.strip_suffix(';') {
let mut it = body.split_whitespace();
if let (Some(ty), Some(name_raw), None) = (it.next(), it.next(), it.next()) {
let name = name_raw.split('[').next().unwrap_or(name_raw);
let col = line.find(name).map(|b| b as u32 + 1).unwrap_or(1);
out.push((name.to_string(), ty.to_string(), i as u32 + 1, col));
}
}
}
out
}
fn extract_uniform_types(js: &str, module_name: &str) -> Option<Vec<(String, String)>> {
let decl = decl_offset(js, module_name)?;
let kw = js[decl..].find("uniformTypes").map(|o| o + decl)?;
let open = js[kw..].find('{').map(|o| o + kw)?;
let inner = brace_block(&js[open..])?;
let pairs: Vec<(String, String)> = inner.lines().filter_map(parse_pair).collect();
(!pairs.is_empty()).then_some(pairs)
}
fn decl_offset(js: &str, name: &str) -> Option<usize> {
for kw in ["const ", "let ", "var "] {
let needle = format!("{kw}{name}");
let mut from = 0;
while let Some(rel) = js[from..].find(&needle) {
let start = from + rel;
let after = start + needle.len();
let ok = js[after..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_');
if ok {
return Some(after);
}
from = start + 1;
}
}
None
}
fn brace_block(s: &str) -> Option<&str> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'{') {
return None;
}
let mut depth = 0u32;
for (i, &b) in bytes.iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(&s[1..i]);
}
}
_ => {}
}
}
None
}
fn parse_pair(line: &str) -> Option<(String, String)> {
let (key, rest) = line.split_once(':')?;
let key = key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let q1 = rest.find('\'')?;
let q2 = rest[q1 + 1..].find('\'')? + q1 + 1;
Some((key.to_string(), rest[q1 + 1..q2].to_string()))
}
fn luma_to_glsl(t: &str) -> Option<&'static str> {
Some(match t {
"f32" => "float",
"i32" => "int",
"u32" => "uint",
"bool" => "bool",
"vec2<f32>" => "vec2",
"vec3<f32>" => "vec3",
"vec4<f32>" => "vec4",
"vec2<i32>" => "ivec2",
"vec3<i32>" => "ivec3",
"vec4<i32>" => "ivec4",
"vec2<u32>" => "uvec2",
"vec3<u32>" => "uvec3",
"vec4<u32>" => "uvec4",
"mat2x2<f32>" => "mat2",
"mat3x3<f32>" => "mat3",
"mat4x4<f32>" => "mat4",
_ => return None,
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
const GLSL: &str = "layout(std140) uniform windUniforms {\n float uMin;\n float uMax;\n vec2 viewOffset;\n} wind;\n";
const JS: &str = "export const windUniforms = {\n name: 'wind',\n uniformTypes: {\n uMin: 'f32',\n uMax: 'f32',\n viewOffset: 'vec2<f32>',\n },\n};\n";
fn run(glsl: &str, js: &str) -> Vec<String> {
check_against(
Path::new("/p/windUniforms.glsl"),
glsl,
"windUniforms",
js,
"modules.ts",
)
.into_iter()
.map(|d| d.message)
.collect()
}
#[test]
fn in_sync_is_silent() {
assert!(run(GLSL, JS).is_empty());
}
#[test]
fn flags_member_only_in_glsl() {
let glsl = GLSL.replace("} wind;", " float maxSpeed;\n} wind;");
let msgs = run(&glsl, JS);
assert!(
msgs.iter()
.any(|m| m.contains("maxSpeed") && m.contains("not in"))
);
}
#[test]
fn flags_member_only_in_js() {
let js = JS.replace("uMax: 'f32',", "uMax: 'f32',\n randSeed: 'f32',");
let msgs = run(GLSL, &js);
assert!(
msgs.iter()
.any(|m| m.contains("randSeed") && m.contains("no such member"))
);
}
#[test]
fn flags_type_mismatch() {
let js = JS.replace("viewOffset: 'vec2<f32>'", "viewOffset: 'f32'");
let msgs = run(GLSL, &js);
assert!(msgs.iter().any(|m| m.contains("type drift")
&& m.contains("viewOffset")
&& m.contains("expected `float`")));
}
#[test]
fn unreadable_js_is_silent() {
assert!(run(GLSL, "export const windUniforms = { name: 'wind' };").is_empty());
assert!(check_against(Path::new("/p/x.glsl"), GLSL, "other", JS, "modules.ts").is_empty());
}
#[test]
fn maps_luma_types() {
assert_eq!(luma_to_glsl("f32"), Some("float"));
assert_eq!(luma_to_glsl("vec3<f32>"), Some("vec3"));
assert_eq!(luma_to_glsl("mat4x4<f32>"), Some("mat4"));
assert_eq!(luma_to_glsl("weird"), None);
}
}