Skip to main content

frust_gpu/
lint.rs

1//! Design rule enforcement: WGSL shader linting and GPU descriptor validation.
2//!
3//! Every design rule E1-E18 is enforced by a test that runs in `cargo test
4//! --workspace` from the phase that introduces the code it governs. This module
5//! contains the lint functions that tests use to validate compliance.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// A violation of a design rule.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Violation {
13    /// The file path where the violation was found (absolute or relative).
14    pub path: PathBuf,
15    /// The line number (1-indexed), if known.
16    pub line: Option<usize>,
17    /// The design rule E-number (e.g., "E1", "E2").
18    pub rule: String,
19    /// Human-readable description of the violation.
20    pub message: String,
21}
22
23/// An I/O failure encountered while scanning a directory for WGSL shaders.
24///
25/// Distinct from an empty [`Violation`] list: a directory (or file inside it)
26/// that could not be read is a scan that did not complete, not a scan that
27/// completed and found nothing clean.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LintError {
30    /// The path whose read failed — the directory being listed, or the
31    /// `.wgsl` file whose contents could not be read.
32    pub path: PathBuf,
33    /// The underlying I/O failure, rendered to a string (`std::io::Error` is
34    /// neither `Clone` nor `PartialEq`, and this type needs to stay
35    /// comparable for tests).
36    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
47/// Scans a directory, recursing into every subdirectory, for WGSL shader
48/// files (`*.wgsl`) and returns all design rule violations found.
49///
50/// Checks for violations of rules E1 (no compute), E2 (no storage buffers/textures),
51/// and related patterns in WGSL source code.
52///
53/// A directory that does not exist is tolerated and reported as zero
54/// violations — the convention callers rely on for a shader directory that
55/// has not been added to a crate yet. A directory that exists but cannot be
56/// listed, or a `.wgsl` file that cannot be read, is surfaced as a
57/// [`LintError`] rather than folded into an empty result: a scan that could
58/// not complete is not a clean scan.
59pub 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
69/// Recursive worker behind [`lint_wgsl_dir`]: walks `dir` and every
70/// subdirectory beneath it, checking each `.wgsl` file it finds.
71fn 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
101/// Checks a single WGSL file for design rule violations.
102fn check_wgsl_file(path: &Path, content: &str, violations: &mut Vec<Violation>) {
103    // E5 (textureLoad arm): correlated at file level, conservatively, rather
104    // than requiring both halves on the same line — naga cannot emit
105    // `textureLoad` on a depth texture for GLSL ES regardless of which
106    // texture binding a given call actually targets, so any file that both
107    // declares a `texture_depth*` binding and calls `textureLoad(` anywhere
108    // is flagged, even when the two are unrelated to each other.
109    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        // E1: no compute shaders
117        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        // E2: no storage_buffer or storage texture bindings
127        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        // firstTrailingBit, firstLeadingBit, countLeadingZeros not allowed
146        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        // textureLoad anywhere in a file that also declares a texture_depth*
159        // binding is not allowed (naga cannot emit it for GLSL ES) — see the
160        // file-level correlation note above.
161        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/// Pipeline layout descriptor — a simplified representation for lint checking.
177#[derive(Debug)]
178pub struct PipelineLayoutDesc {
179    /// Number of bind groups
180    pub bind_group_count: usize,
181    /// Maximum vertex buffers used
182    pub max_vertex_buffers: usize,
183    /// Total vertex attributes across all buffers
184    pub total_vertex_attributes: usize,
185    /// Maximum stride of any vertex buffer
186    pub max_vertex_buffer_stride: usize,
187    /// Sample count for rasterization
188    pub sample_count: u32,
189    /// Uniform buffer sizes per bind group
190    pub uniform_buffer_sizes: Vec<usize>,
191}
192
193/// Validates that a pipeline layout descriptor conforms to design rules E6 and E8.
194///
195/// Checks:
196/// - E6: uniform binding size ≤ `max_uniform_buffer_binding_size` (16 KiB on
197///   downlevel). 256-byte alignment is a property of a binding's *offset*,
198///   not its size, so it is not checked here — this descriptor carries sizes
199///   only, no offsets.
200/// - E8: ≤4 bind groups, ≤8 vertex buffers/16 attrs/255-B stride, sample_count 1 everywhere
201///
202/// Returns a vector of violations found.
203pub fn lint_pipeline_layout(desc: &PipelineLayoutDesc) -> Vec<Violation> {
204    let mut violations = Vec::new();
205
206    // E8: ≤4 bind groups
207    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    // E8: ≤8 vertex buffers
217    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    // E8: ≤16 vertex attributes
230    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    // E8: vertex buffer stride ≤255 bytes
243    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    // E8: sample_count must be 1 (no MSAA)
256    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    // E6: uniform binding size ≤16 KiB (max_uniform_buffer_binding_size).
269    // Alignment applies to a binding's offset, not its size, so a size that
270    // merely isn't a multiple of 256 is not itself a violation.
271    const MAX_UNIFORM_SIZE: usize = 16 << 10; // 16 KiB
272    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
289/// Checks that GPU limits conform to WebGL2/GLES3.0 downlevel defaults.
290///
291/// Uses `wgpu::Limits::check_limits` to validate that the given limits are at or
292/// below the WebGL2 downlevel defaults.
293pub 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    // Manually check each critical limit
298    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    // E7: texture dimension limit — its own rule id, distinct from the E8
313    // bind-group/vertex-topology family and the E5 unsupported-intrinsic
314    // family, since it concerns neither.
315    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        // Some environments (e.g. running as root in CI) ignore permission
389        // bits entirely; skip rather than false-failing when that is true.
390        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], // 20 KiB, exceeds 16 KiB limit
520        };
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], // legal (well under 16 KiB), not a multiple of 256
539        };
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; // Exceeds WebGL2 default of 4
554
555        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}