1use std::fmt;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Violation {
13 pub path: PathBuf,
15 pub line: Option<usize>,
17 pub rule: String,
19 pub message: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LintError {
30 pub path: PathBuf,
33 pub message: String,
37}
38
39impl fmt::Display for LintError {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "{}: {}", self.path.display(), self.message)
42 }
43}
44
45impl std::error::Error for LintError {}
46
47pub fn lint_wgsl_dir(dir: &Path) -> Result<Vec<Violation>, LintError> {
60 if !dir.exists() {
61 return Ok(Vec::new());
62 }
63
64 let mut violations = Vec::new();
65 scan_wgsl_dir(dir, &mut violations)?;
66 Ok(violations)
67}
68
69fn scan_wgsl_dir(dir: &Path, violations: &mut Vec<Violation>) -> Result<(), LintError> {
72 let entries = std::fs::read_dir(dir).map_err(|err| LintError {
73 path: dir.to_path_buf(),
74 message: err.to_string(),
75 })?;
76
77 for entry in entries {
78 let entry = entry.map_err(|err| LintError {
79 path: dir.to_path_buf(),
80 message: err.to_string(),
81 })?;
82 let path = entry.path();
83
84 if path.is_dir() {
85 scan_wgsl_dir(&path, violations)?;
86 continue;
87 }
88
89 if path.extension().and_then(|s| s.to_str()) == Some("wgsl") {
90 let content = std::fs::read_to_string(&path).map_err(|err| LintError {
91 path: path.clone(),
92 message: err.to_string(),
93 })?;
94 check_wgsl_file(&path, &content, violations);
95 }
96 }
97
98 Ok(())
99}
100
101fn check_wgsl_file(path: &Path, content: &str, violations: &mut Vec<Violation>) {
103 let declares_depth_texture_binding = content
110 .lines()
111 .any(|line| line.contains("var") && line.contains("texture_depth"));
112
113 for (line_num, line) in content.lines().enumerate() {
114 let line_num_1indexed = line_num + 1;
115
116 if line.contains("@compute") {
118 violations.push(Violation {
119 path: path.to_path_buf(),
120 line: Some(line_num_1indexed),
121 rule: "E1".to_string(),
122 message: "Compute shaders not allowed: rule E1 (no compute)".to_string(),
123 });
124 }
125
126 if line.contains("var<storage") {
128 violations.push(Violation {
129 path: path.to_path_buf(),
130 line: Some(line_num_1indexed),
131 rule: "E2".to_string(),
132 message: "Storage buffers not allowed: rule E2 (no storage buffers)".to_string(),
133 });
134 }
135
136 if line.contains("texture_storage_") {
137 violations.push(Violation {
138 path: path.to_path_buf(),
139 line: Some(line_num_1indexed),
140 rule: "E2".to_string(),
141 message: "Storage textures not allowed: rule E2 (no storage textures)".to_string(),
142 });
143 }
144
145 if line.contains("firstTrailingBit")
147 || line.contains("firstLeadingBit")
148 || line.contains("countLeadingZeros")
149 {
150 violations.push(Violation {
151 path: path.to_path_buf(),
152 line: Some(line_num_1indexed),
153 rule: "E5".to_string(),
154 message: "Bit manipulation intrinsics not allowed: rule E5".to_string(),
155 });
156 }
157
158 if declares_depth_texture_binding && line.contains("textureLoad(") {
162 violations.push(Violation {
163 path: path.to_path_buf(),
164 line: Some(line_num_1indexed),
165 rule: "E5".to_string(),
166 message:
167 "textureLoad in a file that declares a texture_depth binding not allowed: \
168 rule E5 (naga cannot emit for GLSL ES; correlated at file level, not \
169 per-line)"
170 .to_string(),
171 });
172 }
173 }
174}
175
176#[derive(Debug)]
178pub struct PipelineLayoutDesc {
179 pub bind_group_count: usize,
181 pub max_vertex_buffers: usize,
183 pub total_vertex_attributes: usize,
185 pub max_vertex_buffer_stride: usize,
187 pub sample_count: u32,
189 pub uniform_buffer_sizes: Vec<usize>,
191}
192
193pub fn lint_pipeline_layout(desc: &PipelineLayoutDesc) -> Vec<Violation> {
204 let mut violations = Vec::new();
205
206 if desc.bind_group_count > 4 {
208 violations.push(Violation {
209 path: PathBuf::from("<pipeline>"),
210 line: None,
211 rule: "E8".to_string(),
212 message: format!("Bind groups: {} > 4 (max): rule E8", desc.bind_group_count),
213 });
214 }
215
216 if desc.max_vertex_buffers > 8 {
218 violations.push(Violation {
219 path: PathBuf::from("<pipeline>"),
220 line: None,
221 rule: "E8".to_string(),
222 message: format!(
223 "Vertex buffers: {} > 8 (max): rule E8",
224 desc.max_vertex_buffers
225 ),
226 });
227 }
228
229 if desc.total_vertex_attributes > 16 {
231 violations.push(Violation {
232 path: PathBuf::from("<pipeline>"),
233 line: None,
234 rule: "E8".to_string(),
235 message: format!(
236 "Vertex attributes: {} > 16 (max): rule E8",
237 desc.total_vertex_attributes
238 ),
239 });
240 }
241
242 if desc.max_vertex_buffer_stride > 255 {
244 violations.push(Violation {
245 path: PathBuf::from("<pipeline>"),
246 line: None,
247 rule: "E8".to_string(),
248 message: format!(
249 "Vertex buffer stride: {} > 255 bytes (max): rule E8",
250 desc.max_vertex_buffer_stride
251 ),
252 });
253 }
254
255 if desc.sample_count != 1 {
257 violations.push(Violation {
258 path: PathBuf::from("<pipeline>"),
259 line: None,
260 rule: "E8".to_string(),
261 message: format!(
262 "Sample count: {} != 1 (no MSAA): rule E8",
263 desc.sample_count
264 ),
265 });
266 }
267
268 const MAX_UNIFORM_SIZE: usize = 16 << 10; for (idx, &size) in desc.uniform_buffer_sizes.iter().enumerate() {
273 if size > MAX_UNIFORM_SIZE {
274 violations.push(Violation {
275 path: PathBuf::from("<pipeline>"),
276 line: None,
277 rule: "E6".to_string(),
278 message: format!(
279 "Uniform buffer size in bind group {}: {} > {} bytes (max): rule E6",
280 idx, size, MAX_UNIFORM_SIZE
281 ),
282 });
283 }
284 }
285
286 violations
287}
288
289pub fn check_limits_against_webgl2(limits: &wgpu::Limits) -> Vec<String> {
294 let mut violations = Vec::new();
295 let webgl2_limits = wgpu::Limits::downlevel_webgl2_defaults();
296
297 macro_rules! check_limit {
299 ($field:ident, $rule:expr) => {
300 if limits.$field > webgl2_limits.$field {
301 violations.push(format!(
302 "{}: {} > {} (WebGL2 max): rule {}",
303 stringify!($field),
304 limits.$field,
305 webgl2_limits.$field,
306 $rule
307 ));
308 }
309 };
310 }
311
312 check_limit!(max_texture_dimension_2d, "E7");
316 check_limit!(max_bind_groups, "E8");
317 check_limit!(max_vertex_attributes, "E8");
318 check_limit!(max_vertex_buffers, "E8");
319
320 violations
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use tempfile;
327
328 #[test]
329 fn empty_wgsl_dir_returns_no_violations() {
330 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
331 let violations =
332 lint_wgsl_dir(tmpdir.path()).expect("scan of a fresh temp dir must not fail");
333 assert!(violations.is_empty());
334 }
335
336 #[test]
337 fn detects_compute_shader_e1() {
338 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
339 let wgsl_file = tmpdir.path().join("test.wgsl");
340 std::fs::write(&wgsl_file, "@compute @workgroup_size(8, 8, 1) fn main() {}").unwrap();
341
342 let violations = lint_wgsl_dir(tmpdir.path()).expect("scan must not fail");
343 assert_eq!(violations.len(), 1);
344 assert_eq!(violations[0].rule, "E1");
345 assert!(violations[0].message.contains("Compute"));
346 }
347
348 #[test]
349 fn detects_storage_buffer_e2() {
350 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
351 let wgsl_file = tmpdir.path().join("test.wgsl");
352 std::fs::write(&wgsl_file, "var<storage> data: array<u32>;").unwrap();
353
354 let violations = lint_wgsl_dir(tmpdir.path()).expect("scan must not fail");
355 assert!(violations.iter().any(|v| v.rule == "E2"));
356 }
357
358 #[test]
359 fn lint_wgsl_dir_recurses_into_subdirectories() {
360 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
361 let nested = tmpdir.path().join("nested").join("deeper");
362 std::fs::create_dir_all(&nested).expect("create nested dirs");
363 std::fs::write(
364 nested.join("bad.wgsl"),
365 "@compute @workgroup_size(1, 1, 1) fn main() {}",
366 )
367 .unwrap();
368
369 let violations =
370 lint_wgsl_dir(tmpdir.path()).expect("scan of a readable tree must not fail");
371 assert!(
372 violations.iter().any(|v| v.rule == "E1"),
373 "a violation two directories deep must still be found: {violations:?}"
374 );
375 }
376
377 #[cfg(unix)]
378 #[test]
379 fn lint_wgsl_dir_surfaces_unreadable_subdirectory_as_lint_error() {
380 use std::os::unix::fs::PermissionsExt;
381
382 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
383 let blocked = tmpdir.path().join("blocked");
384 std::fs::create_dir(&blocked).expect("create blocked subdir");
385 std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000))
386 .expect("chmod blocked subdir unreadable");
387
388 let still_readable = std::fs::read_dir(&blocked).is_ok();
391 if still_readable {
392 let _ = std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755));
393 return;
394 }
395
396 let result = lint_wgsl_dir(tmpdir.path());
397 let _ = std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755));
398
399 match result {
400 Err(err) => assert_eq!(err.path, blocked),
401 Ok(violations) => panic!(
402 "a directory containing an unreadable subdirectory must surface a LintError, \
403 not a clean scan; got {violations:?}"
404 ),
405 }
406 }
407
408 #[test]
409 fn detects_texture_load_correlated_with_depth_binding_e5() {
410 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
411 let wgsl_file = tmpdir.path().join("test.wgsl");
412 std::fs::write(
413 &wgsl_file,
414 "@group(0) @binding(0) var t_depth: texture_depth_2d;\n\
415 @group(0) @binding(1) var t_other: texture_2d<f32>;\n\
416 fn main() {\n\
417 let x = textureLoad(t_other, vec2<i32>(0, 0), 0);\n\
418 }\n",
419 )
420 .unwrap();
421
422 let violations = lint_wgsl_dir(tmpdir.path()).expect("scan must not fail");
423 assert!(
424 violations.iter().any(|v| v.rule == "E5"),
425 "a textureLoad anywhere in a file that also declares a texture_depth binding must \
426 be flagged, even on a separate line and against a different texture: {violations:?}"
427 );
428 }
429
430 #[test]
431 fn texture_load_on_non_depth_texture_alone_is_not_flagged_e5() {
432 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
433 let wgsl_file = tmpdir.path().join("test.wgsl");
434 std::fs::write(
435 &wgsl_file,
436 "@group(0) @binding(0) var t: texture_2d<f32>;\n\
437 fn main() {\n\
438 let x = textureLoad(t, vec2<i32>(0, 0), 0);\n\
439 }\n",
440 )
441 .unwrap();
442
443 let violations = lint_wgsl_dir(tmpdir.path()).expect("scan must not fail");
444 assert!(
445 violations.iter().all(|v| v.rule != "E5"),
446 "textureLoad on a plain (non-depth) texture, with no texture_depth binding anywhere \
447 in the file, must not be flagged: {violations:?}"
448 );
449 }
450
451 #[test]
452 fn depth_binding_sampled_via_compare_without_texture_load_is_not_flagged_e5() {
453 let tmpdir = tempfile::tempdir().expect("failed to create temp dir");
454 let wgsl_file = tmpdir.path().join("test.wgsl");
455 std::fs::write(
456 &wgsl_file,
457 "@group(0) @binding(0) var t_depth: texture_depth_2d;\n\
458 @group(0) @binding(1) var s: sampler_comparison;\n\
459 fn main() {\n\
460 let x = textureSampleCompare(t_depth, s, vec2<f32>(0.0, 0.0), 0.5);\n\
461 }\n",
462 )
463 .unwrap();
464
465 let violations = lint_wgsl_dir(tmpdir.path()).expect("scan must not fail");
466 assert!(
467 violations.iter().all(|v| v.rule != "E5"),
468 "a depth binding sampled only via textureSampleCompare (no textureLoad call) must \
469 not be flagged: {violations:?}"
470 );
471 }
472
473 #[test]
474 fn pipeline_layout_enforces_bind_group_limit_e8() {
475 let desc = PipelineLayoutDesc {
476 bind_group_count: 5,
477 max_vertex_buffers: 2,
478 total_vertex_attributes: 4,
479 max_vertex_buffer_stride: 128,
480 sample_count: 1,
481 uniform_buffer_sizes: vec![],
482 };
483
484 let violations = lint_pipeline_layout(&desc);
485 assert!(
486 violations
487 .iter()
488 .any(|v| { v.rule == "E8" && v.message.contains("Bind groups") })
489 );
490 }
491
492 #[test]
493 fn pipeline_layout_enforces_msaa_disabled_e8() {
494 let desc = PipelineLayoutDesc {
495 bind_group_count: 2,
496 max_vertex_buffers: 2,
497 total_vertex_attributes: 4,
498 max_vertex_buffer_stride: 128,
499 sample_count: 4,
500 uniform_buffer_sizes: vec![],
501 };
502
503 let violations = lint_pipeline_layout(&desc);
504 assert!(
505 violations
506 .iter()
507 .any(|v| { v.rule == "E8" && v.message.contains("Sample count") })
508 );
509 }
510
511 #[test]
512 fn pipeline_layout_enforces_uniform_size_e6() {
513 let desc = PipelineLayoutDesc {
514 bind_group_count: 1,
515 max_vertex_buffers: 1,
516 total_vertex_attributes: 2,
517 max_vertex_buffer_stride: 16,
518 sample_count: 1,
519 uniform_buffer_sizes: vec![20 << 10], };
521
522 let violations = lint_pipeline_layout(&desc);
523 assert!(
524 violations
525 .iter()
526 .any(|v| { v.rule == "E6" && v.message.contains("Uniform buffer size") })
527 );
528 }
529
530 #[test]
531 fn pipeline_layout_uniform_size_not_256_aligned_is_not_flagged_e6() {
532 let desc = PipelineLayoutDesc {
533 bind_group_count: 1,
534 max_vertex_buffers: 1,
535 total_vertex_attributes: 2,
536 max_vertex_buffer_stride: 16,
537 sample_count: 1,
538 uniform_buffer_sizes: vec![300], };
540
541 let violations = lint_pipeline_layout(&desc);
542 assert!(
543 violations.iter().all(|v| v.rule != "E6"),
544 "a uniform buffer under the size limit must not be flagged merely for not being a \
545 multiple of 256 — alignment is a property of a binding's offset, not its size: \
546 {violations:?}"
547 );
548 }
549
550 #[test]
551 fn limits_check_detects_exceeded_webgl2_max() {
552 let mut limits = wgpu::Limits::downlevel_webgl2_defaults();
553 limits.max_bind_groups = 8; let violations = check_limits_against_webgl2(&limits);
556 assert!(!violations.is_empty());
557 assert!(violations.iter().any(|v| v.contains("max_bind_groups")));
558 }
559
560 #[test]
561 fn limits_check_flags_texture_dimension_as_e7_not_e5() {
562 let mut limits = wgpu::Limits::downlevel_webgl2_defaults();
563 limits.max_texture_dimension_2d *= 2;
564
565 let violations = check_limits_against_webgl2(&limits);
566 assert!(
567 violations
568 .iter()
569 .any(|v| v.contains("max_texture_dimension_2d") && v.contains("rule E7")),
570 "exceeding max_texture_dimension_2d must cite its own rule id, not E5: {violations:?}"
571 );
572 }
573
574 #[test]
575 fn limits_check_passes_webgl2_defaults() {
576 let limits = wgpu::Limits::downlevel_webgl2_defaults();
577 let violations = check_limits_against_webgl2(&limits);
578 assert!(violations.is_empty());
579 }
580}