Skip to content

Commit 3b329aa

Browse files
committed
Resolve remaining source parity issues
1 parent 1eabfd3 commit 3b329aa

13 files changed

Lines changed: 380 additions & 293 deletions

composer.mbt

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ priv struct WgslComposeSession {
169169
modules : @hashmap.HashMap[String, String]
170170
redirects : Array[WgslSymbolRedirect]
171171
additional_imports : Array[ImportDefinition]
172+
redirect_match_counts : @hashmap.HashMap[String, Int]
172173
visited : @hashmap.HashMap[String, Bool]
173174
imported_item_names : @hashmap.HashMap[String, String]
174175
active_rel_paths : Array[String]
@@ -299,6 +300,7 @@ fn Composer::new_wgsl_compose_session(
299300
modules: self.registry.copy_import_module_paths(),
300301
redirects: options.redirects.copy(),
301302
additional_imports: copy_import_definitions(options.additional_imports),
303+
redirect_match_counts: @hashmap.HashMap::new(),
302304
visited: @hashmap.HashMap::new(),
303305
imported_item_names: @hashmap.HashMap::new(),
304306
active_rel_paths: [],
@@ -332,6 +334,27 @@ fn collect_wgsl_cached_import_redirects(
332334
count
333335
}
334336

337+
///|
338+
fn record_wgsl_redirect_matches(
339+
source : String,
340+
session : WgslComposeSession,
341+
) -> Unit {
342+
for redirect in session.redirects {
343+
let from_name = redirect.from_name.trim().to_string()
344+
let to_name = redirect.to_name.trim().to_string()
345+
if from_name == "" || to_name == "" || from_name == to_name {
346+
continue
347+
}
348+
if wgsl_source_contains_identifier(source, from_name) {
349+
let current = match session.redirect_match_counts.get(from_name) {
350+
Some(count) => count
351+
None => 0
352+
}
353+
session.redirect_match_counts.set(from_name, current + 1)
354+
}
355+
}
356+
}
357+
335358
///|
336359
fn composer_remove_composable_module_recursive(
337360
composer : Composer,
@@ -799,6 +822,7 @@ fn prepare_wgsl_compose_source(
799822
source : String,
800823
session : WgslComposeSession,
801824
) -> WgslPreparedComposeSource {
825+
record_wgsl_redirect_matches(source, session)
802826
let redirected_source = rewrite_wgsl_symbol_redirects(
803827
source,
804828
session.redirects,
@@ -1352,7 +1376,9 @@ pub fn Composer::compose_wgsl(
13521376
options : WgslComposeOptions,
13531377
) -> String raise ComposerError {
13541378
let session = self.new_wgsl_compose_session(options)
1355-
self.load_root_wgsl_preprocessed_into_session(rel, session)
1379+
sanitize_wgsl_invalid_identifiers(
1380+
self.load_root_wgsl_preprocessed_into_session(rel, session),
1381+
)
13561382
}
13571383

13581384
///|
@@ -1362,10 +1388,12 @@ pub fn Composer::compose_wgsl_source(
13621388
options : WgslComposeOptions,
13631389
) -> String raise ComposerError {
13641390
let session = self.new_wgsl_compose_session(options)
1365-
self.preprocess_wgsl_source_with_path(
1366-
"",
1367-
prepend_wgsl_additional_imports(source, session.additional_imports),
1368-
session,
1391+
sanitize_wgsl_invalid_identifiers(
1392+
self.preprocess_wgsl_source_with_path(
1393+
"",
1394+
prepend_wgsl_additional_imports(source, session.additional_imports),
1395+
session,
1396+
),
13691397
)
13701398
}
13711399

docs/moon_wgsl-issue-tracker.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,13 @@ Last updated: 2026-04-29
3636
| `WGSL-021` | GitHub issue #3 | Mixed Bevy PBR import graphs exposed missing import-only entry preservation and nested alias/type rewrites for source-level composition. | `DONE` | Fixed by preserving import-only WGSL entry items and expanding the Bevy mixed full/item import regression suite. Released across `0.1.4` and `0.1.5`; verified with `moon test` on 2026-04-29. |
3737
| `WGSL-022` | GitHub issue #4 | Repeated full-module imports under a nested alias skipped cached alias redirects, leaving expressions such as `view_bindings::view` unresolved. | `DONE` | Fixed by reusing cached alias redirects when a full-module import has already been visited. Released in `0.1.6`; verified with `moon test` on 2026-04-29 (`85/85` passing). |
3838
| `WGSL-023` | Upstream naga_oil parity audit | The upstream compose test surface was only partially represented in local tests and not tracked as an explicit coverage matrix. | `DONE` | Added `docs/naga_oil-parity.md` with the upstream compose test/fixture matrix, source-level compatibility boundary, blocked cases, and the next architecture priorities. |
39-
| `WGSL-024` | Architecture review | Import syntax parsing/planning is still split between preprocessor metadata parsing and composer/module-graph import target parsing, so alias/group syntax can drift between APIs. | `TODO` | Consolidate `parse_imports` and `collect_wgsl_import_targets` around one canonical import AST/parser, then route both metadata output and recursive compose planning through that representation. |
39+
| `WGSL-024` | Architecture review | Import syntax parsing/planning is still split between preprocessor metadata parsing and composer/module-graph import target parsing, so alias/group syntax can drift between APIs. | `DONE` | Consolidated metadata parsing and composer import-target planning around the same tokenizer-based import target parser, while keeping the existing public `collect_wgsl_import_targets` API. Verified with `moon test` on 2026-04-29. |
4040
| `WGSL-025` | Upstream `additional_import` parity | Descriptor-level `additional_imports` exists, but source-level `compose_wgsl` / `export_wgsl_with_options` has no stable root-request API for injecting additional imports. | `DONE` | Added root-only `WgslComposeOptions.additional_imports`, wired descriptor-level additional imports into registered composable module source, and added compose/export regressions based on the upstream `add_imports` fixture shape. Full `virtual`/`override` overlay semantics remain part of `WGSL-012` because they require Naga. |
41-
| `WGSL-026` | Upstream `bad_identifiers` parity | Naga can sanitize invalid/reserved identifiers during IR writeback, but this source-level library currently lacks a defined identifier-sanitization layer. | `TODO` | Implement a limited source-level sanitizer for declarations/references the local analyzer can identify, and document that full parser/IR-equivalent sanitization remains outside source-only scope. |
42-
| `WGSL-027` | Upstream `invalid_override` parity | Redirect/override-style source-level APIs do not yet diagnose redirects that never match a local declaration, while upstream catches invalid override cases through Naga. | `TODO` | Add explicit redirect diagnostics, likely through export diagnostics or a checked compose API, without pretending to validate full Naga `virtual`/`override` semantics. |
41+
| `WGSL-026` | Upstream `bad_identifiers` parity | Naga can sanitize invalid/reserved identifiers during IR writeback, but this source-level library currently lacks a defined identifier-sanitization layer. | `DONE` | Added a root-output source-level sanitizer for invalid/reserved top-level declaration names and function parameters, plus upstream-inspired `invalid_identifiers` parity fixtures. Struct-member sanitization remains intentionally outside source-only scope. Verified with `moon test` on 2026-04-29. |
42+
| `WGSL-027` | Upstream `invalid_override` parity | Redirect/override-style source-level APIs do not yet diagnose redirects that never match a local declaration, while upstream catches invalid override cases through Naga. | `DONE` | Added export diagnostics for source-level redirects that never match any source identifier. Full Naga `virtual`/`override` validation remains part of `WGSL-012`. Verified with `moon test` on 2026-04-29. |
4343
| `WGSL-028` | Upstream `test_shader` parity | The local parity corpus lacks a simple compute shader smoke test corresponding to upstream `compute_test.wgsl`. | `DONE` | Added `testdata/upstream_compose/compute_test` and an export smoke test that preserves the compute entry point plus imported module dependency. Verified with `moon test` on 2026-04-29. |
4444

4545
## Current work queue
4646

47-
- `WGSL-024`: consolidate import parsing and planning around one canonical import AST.
48-
- `WGSL-026`: add a documented source-level identifier sanitizer where local parsing is sufficient.
49-
- `WGSL-027`: add checked redirect diagnostics for source-level override/redirect usage.
47+
- No active `TODO` or `IN_PROGRESS` items.
5048
- `WGSL-012` remains intentionally `BLOCKED` on true Naga/runtime-backed parity coverage.

docs/naga_oil-parity.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ The parity target is therefore:
5353
| `glsl_const_import`, `glsl_wgsl_const_import`, `wgsl_glsl_const_import`, `glsl_const_import/` | Blocked | Requires GLSL parsing plus constant import/writeback semantics. |
5454
| `test_raycasts`, `raycast/` | Blocked | Requires Naga/wgpu-style shader validation or runtime execution behavior. |
5555
| `additional_import`, `add_imports/` | Covered source-level subset | Root compose/export requests and registered composable modules can inject additional imports. Upstream `virtual`/`override` overlay semantics still require Naga and remain blocked. |
56-
| `invalid_override` | TODO | Local redirect diagnostics should pin invalid redirect/override behavior explicitly. |
57-
| `bad_identifiers`, `invalid_identifiers/` | TODO | A limited source-level sanitizer is feasible for locally parsed declarations; full Naga writeback parity is not. |
56+
| `invalid_override` | Covered source-level subset | Export diagnostics now warn when a source-level redirect never matches. Full Naga `virtual`/`override` validation remains blocked. |
57+
| `bad_identifiers`, `invalid_identifiers/` | Covered source-level subset | Top-level declaration names and function parameters are sanitized in final composed/exported source. Struct-member rewriteback remains blocked without a real parser/IR. |
5858
| `test_shader`, `compute_test.wgsl` | Covered source-level subset | Local export smoke coverage preserves the compute entry point and imported module dependency. Upstream runtime execution remains outside source-only scope. |
5959

6060
## Architecture Priorities

export.mbt

Lines changed: 176 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,73 @@ fn build_wgsl_symbol_redirect_map(
7979
redirect_map
8080
}
8181

82+
///|
83+
fn wgsl_identifier_is_reserved_or_invalid(ident : String) -> Bool {
84+
if ident == "" {
85+
return false
86+
}
87+
ident == "alias" ||
88+
ident == "break" ||
89+
ident == "case" ||
90+
ident == "const" ||
91+
ident == "const_assert" ||
92+
ident == "continue" ||
93+
ident == "continuing" ||
94+
ident == "default" ||
95+
ident == "diagnostic" ||
96+
ident == "discard" ||
97+
ident == "else" ||
98+
ident == "enable" ||
99+
ident == "false" ||
100+
ident == "fn" ||
101+
ident == "for" ||
102+
ident == "if" ||
103+
ident == "let" ||
104+
ident == "loop" ||
105+
ident == "override" ||
106+
ident == "requires" ||
107+
ident == "return" ||
108+
ident == "struct" ||
109+
ident == "switch" ||
110+
ident == "true" ||
111+
ident == "var" ||
112+
ident == "while" ||
113+
ident == "in" ||
114+
ident.has_suffix("_") ||
115+
starts_with_text(ident, "__")
116+
}
117+
118+
///|
119+
fn sanitize_wgsl_identifier(ident : String) -> String {
120+
let mut out = ident
121+
while out.has_suffix("_") {
122+
out = out[:out.length() - 1].to_string()
123+
}
124+
while starts_with_text(out, "__") {
125+
out = out[1:out.length()].to_string()
126+
}
127+
if out == "" {
128+
out = "wgsl"
129+
}
130+
if wgsl_identifier_is_reserved_or_invalid(out) {
131+
out = "\{out}_wgsl"
132+
}
133+
out
134+
}
135+
136+
///|
137+
fn wgsl_redirect_map_set_sanitized(
138+
redirect_map : @hashmap.HashMap[String, String],
139+
ident : String,
140+
) -> Unit {
141+
if wgsl_identifier_is_reserved_or_invalid(ident) {
142+
let sanitized = sanitize_wgsl_identifier(ident)
143+
if sanitized != ident {
144+
redirect_map.set(ident, sanitized)
145+
}
146+
}
147+
}
148+
82149
///|
83150
fn rewrite_wgsl_declaration_text_with_map(
84151
source : String,
@@ -240,6 +307,80 @@ fn rewrite_wgsl_declaration_text_with_renamed_declarations(
240307
rewrite_wgsl_declaration_text_with_map(source, redirect_map, true)
241308
}
242309

310+
///|
311+
fn collect_wgsl_function_parameter_sanitizers(
312+
block_text : String,
313+
redirect_map : @hashmap.HashMap[String, String],
314+
) -> Unit {
315+
let header_end = match block_text.find("{") {
316+
Some(index) => index
317+
None => block_text.length()
318+
}
319+
let header = block_text[:header_end].to_string()
320+
guard header.find("fn ") is Some(fn_index) else { return }
321+
let fn_header = header[fn_index:header.length()].to_string()
322+
guard fn_header.find("(") is Some(open_index) else { return }
323+
guard fn_header.find(")") is Some(close_index) else { return }
324+
if close_index <= open_index {
325+
return
326+
}
327+
let params = fn_header[open_index + 1:close_index].to_string()
328+
for param_item in params.split(",") {
329+
let param = param_item.to_string().trim().to_string()
330+
if param == "" {
331+
continue
332+
}
333+
let mut end = 0
334+
while end < param.length() &&
335+
wgsl_identifier_char(param.code_unit_at(end).to_int()) {
336+
end = end + 1
337+
}
338+
if end > 0 {
339+
wgsl_redirect_map_set_sanitized(redirect_map, param[:end].to_string())
340+
}
341+
}
342+
}
343+
344+
///|
345+
fn sanitize_wgsl_invalid_identifiers(source : String) -> String {
346+
let graph = build_wgsl_declaration_graph(source)
347+
let global_redirects : @hashmap.HashMap[String, String] = @hashmap.HashMap::new()
348+
for block in graph.declaration_blocks {
349+
wgsl_redirect_map_set_sanitized(global_redirects, block.name)
350+
}
351+
if global_redirects.is_empty() {
352+
let mut has_parameter_sanitizers = false
353+
for block in graph.declaration_blocks {
354+
let block_redirects : @hashmap.HashMap[String, String] = @hashmap.HashMap::new()
355+
collect_wgsl_function_parameter_sanitizers(block.text, block_redirects)
356+
if !block_redirects.is_empty() {
357+
has_parameter_sanitizers = true
358+
}
359+
}
360+
if !has_parameter_sanitizers {
361+
return source
362+
}
363+
}
364+
let mut sanitized = ""
365+
for import_block in graph.import_blocks {
366+
sanitized = sanitized + import_block.text
367+
}
368+
for block in graph.declaration_blocks {
369+
let block_redirects : @hashmap.HashMap[String, String] = @hashmap.HashMap::new()
370+
for entry in global_redirects.iter() {
371+
let (from_name, to_name) = entry
372+
block_redirects.set(from_name, to_name)
373+
}
374+
collect_wgsl_function_parameter_sanitizers(block.text, block_redirects)
375+
sanitized = sanitized +
376+
rewrite_wgsl_declaration_text_with_renamed_declarations(
377+
block.text,
378+
block_redirects,
379+
)
380+
}
381+
sanitized
382+
}
383+
243384
///|
244385
pub fn rewrite_wgsl_symbol_redirects(
245386
source : String,
@@ -438,22 +579,55 @@ fn serialize_wgsl_export_output(
438579
WgslExportOutput::{ source, source_catalog, source_map, diagnostics }
439580
}
440581

582+
///|
583+
fn append_wgsl_unused_redirect_diagnostics(
584+
diagnostics : Array[WgslDiagnostic],
585+
session : WgslComposeSession,
586+
) -> Unit {
587+
for redirect in session.redirects {
588+
let from_name = redirect.from_name.trim().to_string()
589+
let to_name = redirect.to_name.trim().to_string()
590+
if from_name == "" || to_name == "" || from_name == to_name {
591+
continue
592+
}
593+
let matched = match session.redirect_match_counts.get(from_name) {
594+
Some(count) => count > 0
595+
None => false
596+
}
597+
if !matched {
598+
push_wgsl_diagnostic(
599+
diagnostics,
600+
WgslDiagnosticSeverity::Warn,
601+
"symbol redirect did not match any source identifier",
602+
None,
603+
Some(from_name),
604+
None,
605+
None,
606+
)
607+
}
608+
}
609+
}
610+
441611
///|
442612
fn Composer::export_wgsl_from_session(
443613
self : Composer,
444614
rel : String,
445615
session : WgslComposeSession,
446616
options : WgslExportOptions,
447617
) -> WgslExportOutput raise ComposerError {
448-
let source = self.load_root_wgsl_preprocessed_into_session(rel, session)
618+
let source = sanitize_wgsl_invalid_identifiers(
619+
self.load_root_wgsl_preprocessed_into_session(rel, session),
620+
)
449621
let graph = build_wgsl_declaration_graph(source)
450-
serialize_wgsl_export_output(
622+
let output = serialize_wgsl_export_output(
451623
graph,
452624
copy_wgsl_source_files_from_registry(session.resolved_source_files),
453625
options,
454626
session.defines,
455627
session.value_defines,
456628
)
629+
append_wgsl_unused_redirect_diagnostics(output.diagnostics, session)
630+
output
457631
}
458632

459633
///|

export_test.mbt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,29 @@ test "naga_oil: export_wgsl_with_redirects rewrites dependencies before tree-sha
281281
debug_inspect(output.diagnostics.length(), content="0")
282282
@moon_wgsl.clear_registered_wgsl_source_registry()
283283
}
284+
285+
///|
286+
test "naga_oil: export warns when a source-level redirect never matches" {
287+
register_redirect_export_test_shaders()
288+
let output = export_test_global_registry_composer().export_wgsl_with_options(
289+
"shaders/effects/redirect.wgsl",
290+
export_test_compose_options(export_test_empty_defines(), [
291+
{ from_name: "missing_shadow", to_name: "build_color" },
292+
]),
293+
{ root_items: ["shade"] },
294+
) catch {
295+
err =>
296+
abort("expected redirect diagnostic export success: \{err.message()}")
297+
}
298+
debug_inspect(output.diagnostics.length(), content="1")
299+
debug_inspect(output.diagnostics[0].severity, content="Warn")
300+
debug_inspect(
301+
output.diagnostics[0].message,
302+
content="\"symbol redirect did not match any source identifier\"",
303+
)
304+
debug_inspect(
305+
output.diagnostics[0].symbol_name,
306+
content="Some(\"missing_shadow\")",
307+
)
308+
@moon_wgsl.clear_registered_wgsl_source_registry()
309+
}

0 commit comments

Comments
 (0)