From abcd83d475e526574d0a50b60904792481a5038a Mon Sep 17 00:00:00 2001 From: CheckPickerUpper Date: Tue, 25 Aug 2026 19:07:24 -0400 Subject: [PATCH] feat: report source-aware compiler diagnostics --- Cargo.lock | 2 + Cargo.toml | 2 + README.md | 11 + fixtures/rust-diagnostics/Cargo.toml | 10 + .../diagnostics-stripped.wasm | Bin 0 -> 374 bytes fixtures/rust-diagnostics/diagnostics.wasm | Bin 0 -> 791 bytes fixtures/rust-diagnostics/src/lib.rs | 15 ++ src/diagnostics.rs | 226 ++++++++++++++++++ src/lib.rs | 2 + src/translate/problem.rs | 29 ++- src/wasm/mod.rs | 4 +- src/wasm/problem.rs | 74 +++++- tests/diagnostics.rs | 78 ++++++ 13 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 fixtures/rust-diagnostics/Cargo.toml create mode 100644 fixtures/rust-diagnostics/diagnostics-stripped.wasm create mode 100644 fixtures/rust-diagnostics/diagnostics.wasm create mode 100644 fixtures/rust-diagnostics/src/lib.rs create mode 100644 src/diagnostics.rs create mode 100644 tests/diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index fd60929..7471812 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,7 @@ dependencies = [ "clap", "fs-err", "full_moon", + "gimli", "insta", "predicates", "rstest", @@ -546,6 +547,7 @@ dependencies = [ "tracing-subscriber", "walkdir", "walrus", + "wasmparser 0.245.1", "wast", ] diff --git a/Cargo.toml b/Cargo.toml index e2af5d6..4804e3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://github.com/CheckPickerUpper/luau-rs" atomic-write-file = "0.3.1" clap = { version = "4.6.6", features = ["derive"] } fs-err = "3.3.1" +gimli = "0.32.3" serde = { version = "1.0.229", features = ["derive"] } thiserror = "2.0.20" toml = "1.1.4" @@ -17,6 +18,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } walkdir = "2.5.0" walrus = "0.26.5" +wasmparser = "0.245.1" [dev-dependencies] assert_cmd = "2.2.2" diff --git a/README.md b/README.md index 43cd998..1768833 100644 --- a/README.md +++ b/README.md @@ -212,3 +212,14 @@ phases: the same pattern this emitter produces. See `CLAUDE.md` for the full architecture and agent guidance. + +## Rejection diagnostics + +`WasmDecodeRejection::diagnostics()` and `TranslationRejection::diagnostics()` +expose a stable serializable report. Each diagnostic contains `code`, `message`, +and a `location` with optional `file`, `line`, `column`, `function`, and `wasm_offset` +fields. `source_available` distinguishes a DWARF-backed Rust location from the +fallback, and `hint` explains how to recover source lines when debug metadata is +missing. Decode diagnostics use DWARF when available; translation-stage +diagnostics retain the function/offset contract for callers that add their own +source metadata. diff --git a/fixtures/rust-diagnostics/Cargo.toml b/fixtures/rust-diagnostics/Cargo.toml new file mode 100644 index 0000000..27689b4 --- /dev/null +++ b/fixtures/rust-diagnostics/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rust-diagnostics" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[profile.dev] +debug = 2 diff --git a/fixtures/rust-diagnostics/diagnostics-stripped.wasm b/fixtures/rust-diagnostics/diagnostics-stripped.wasm new file mode 100644 index 0000000000000000000000000000000000000000..564f7f3ad22a0ea00c9a2dfeedfd74b46ac82afd GIT binary patch literal 374 zcmZvXy-ve06os$*lhPI`sHoi#9YL0>CId-A literal 0 HcmV?d00001 diff --git a/fixtures/rust-diagnostics/diagnostics.wasm b/fixtures/rust-diagnostics/diagnostics.wasm new file mode 100644 index 0000000000000000000000000000000000000000..41ba885f282a7720c3571cfe2c2e4bc1aa6ee1e1 GIT binary patch literal 791 zcmaJ<&2AGh5FT%`Nt-~@AGJLIBB}&xwQ@Fz5K3-vK;qC|kl=*0_3lo#R=xJtKcQ(4 z^hJ0EUV-;QB6!nQ>H%TN9{HP3^YhIBGbI54(jsT%hTJp)0%3!|R%?@dC+}}>e?hnX zGH8`pD!mL~$4TR=N@)=^$ zn|wa-nPAer=Wsb!haPCwC^YLb;5p1{2NeSnojc>jwcKUlhNcY9Zl(YdVCBtjc;b6$LahlQ>{ZY zmlidgZdO|5F7pZo8?~^G6-b(&d|Y_qT^SmKIZc-&TxG`7%Fj|LSwx-E3CkBuIE?zGvn&-z3!zdbOtdSN ow3<~Fmj#_ywr(csYn-D-nIW~y3Qe?Ocp)iX*{E%JnU5#azbc*F%K!iX literal 0 HcmV?d00001 diff --git a/fixtures/rust-diagnostics/src/lib.rs b/fixtures/rust-diagnostics/src/lib.rs new file mode 100644 index 0000000..079fa76 --- /dev/null +++ b/fixtures/rust-diagnostics/src/lib.rs @@ -0,0 +1,15 @@ +#![no_std] + +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} +} + +use core::arch::wasm32::v128; + +/// This function intentionally uses a SIMD value so the backend rejects it. +#[unsafe(no_mangle)] +#[target_feature(enable = "simd128")] +pub unsafe extern "C" fn unsupported_vector(value: v128) -> v128 { + value +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..9570ee0 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,226 @@ +//! Structured source diagnostics for rejected WebAssembly modules. + +use gimli::{Dwarf, EndianSlice, LittleEndian, Reader}; +use serde::Serialize; +use std::borrow::Cow; +use walrus::Module; +use wasmparser::{Parser, Payload}; + +/// A source location attached to a compiler diagnostic. +/// +/// The fields are stable and optional because optimized or stripped wasm can +/// legitimately omit source information. The wasm offset and function name +/// remain available as the deterministic fallback in that case. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct SourceLocation { + /// The Rust source file from DWARF, when present. + pub file: Option, + /// One-based Rust source line, when present. + pub line: Option, + /// One-based source column; zero means the line's left edge. + pub column: Option, + /// The wasm function name or name-section fallback. + pub function: Option, + /// The code-section instruction offset, when known. + pub wasm_offset: Option, + /// Whether a source file and line were recovered from DWARF. + pub source_available: bool, + /// Actionable guidance when source information is absent. + pub hint: Option, +} + +/// One rejection with a stable machine-readable code and location. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct Diagnostic { + /// Stable identifier for the rejection category. + pub code: String, + /// Human-readable explanation of the rejected construct. + pub message: String, + /// Source and wasm fallback location. + pub location: SourceLocation, +} + +/// A stable collection of diagnostics for one rejected module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct DiagnosticReport { + /// Diagnostics in deterministic rejection order, with exact duplicates removed. + pub diagnostics: Vec, +} + +impl DiagnosticReport { + /// Creates a report from diagnostics collected during one compiler stage. + #[must_use] + pub const fn new(diagnostics: Vec) -> Self { + Self { diagnostics } + } + + /// Returns the structured diagnostics without exposing report internals. + #[must_use] + pub fn diagnostics(&self) -> &[Diagnostic] { + &self.diagnostics + } + + /// Creates a report for a stage that has no wasm metadata context. + #[must_use] + pub fn without_locations(messages: impl IntoIterator) -> Self { + let diagnostics = messages + .into_iter() + .map(|(code, message)| Diagnostic { + code, + message, + location: SourceLocation { + file: None, + line: None, + column: None, + function: None, + wasm_offset: None, + source_available: false, + hint: Some( + "rebuild the Rust crate with debug information to recover a source line" + .into(), + ), + }, + }) + .collect(); + Self::new(diagnostics) + } +} + +/// Resolves a wasm function/offset into the best source location available. +pub(crate) fn source_location( + raw_wasm: &[u8], + function: Option, + wasm_offset: Option, +) -> SourceLocation { + let source = wasm_offset.and_then(|offset| lookup_line(raw_wasm, offset as u64)); + match source { + Some((file, line, column)) => SourceLocation { + file: Some(file), + line: Some(line), + column: Some(column), + function, + wasm_offset, + source_available: true, + hint: None, + }, + None => SourceLocation { + file: None, + line: None, + column: None, + function, + wasm_offset, + source_available: false, + hint: Some( + "source locations were unavailable; rebuild the Rust crate with debug information" + .into(), + ), + }, + } +} + +fn lookup_line(raw_wasm: &[u8], address: u64) -> Option<(String, u64, u64)> { + let mut sections = std::collections::HashMap::new(); + for payload in Parser::new(0).parse_all(raw_wasm) { + let Payload::CustomSection(section) = payload.ok()? else { + continue; + }; + if section.name().starts_with(".debug") { + sections.insert(section.name().to_owned(), section.data()); + } + } + let dwarf = Dwarf::load(|section| { + Ok::<_, gimli::Error>(EndianSlice::new( + sections.get(section.name()).copied().unwrap_or_default(), + LittleEndian, + )) + }) + .ok()?; + let mut units = dwarf.units(); + while let Some(header) = units.next().ok()? { + let unit = dwarf.unit(header).ok()?; + let Some(line_program) = unit.line_program.clone() else { + continue; + }; + let (program, sequences) = line_program.sequences().ok()?; + for sequence in sequences { + if address < sequence.start || address > sequence.end { + continue; + } + let mut rows = program.resume_from(&sequence); + let mut best = None; + while let Some((header, row)) = rows.next_row().ok()? { + if row.end_sequence() { + continue; + } + if row.address() > address { + break; + } + let Some(file) = row.file(header) else { + continue; + }; + let file_name = dwarf + .attr_string(&unit, file.path_name()) + .ok() + .and_then(|reader| reader_text(&reader))?; + let directory = file + .directory(header) + .and_then(|directory| dwarf.attr_string(&unit, directory).ok()) + .and_then(|reader| reader_text(&reader)); + let path = match directory { + Some(directory) if !directory.is_empty() && !file_name.starts_with('/') => { + format!("{directory}/{file_name}") + } + _ => file_name, + }; + let line = row.line()?.get(); + let column = match row.column() { + gimli::ColumnType::Column(column) => column.get(), + gimli::ColumnType::LeftEdge => 0, + }; + best = Some((path, line, column)); + } + if best.is_some() { + return best; + } + } + } + None +} + +fn reader_text(reader: &R) -> Option +where + R: Reader, +{ + let bytes: Cow<'_, [u8]> = reader.to_slice().ok()?; + String::from_utf8(bytes.into_owned()).ok() +} + +pub(crate) fn function_context( + module: &Module, + preferred: impl Fn(&walrus::Function) -> bool, +) -> Option<(Option, Option)> { + module + .funcs + .iter() + .filter(|function| matches!(function.kind, walrus::FunctionKind::Local(_))) + .find(|function| preferred(function)) + .or_else(|| { + module + .funcs + .iter() + .find(|function| matches!(function.kind, walrus::FunctionKind::Local(_))) + }) + .map(|function| { + let offset = match &function.kind { + walrus::FunctionKind::Local(local) => local + .instruction_mapping + .first() + .map(|(offset, _location)| *offset), + walrus::FunctionKind::Import(_) | walrus::FunctionKind::Uninitialized(_) => None, + }; + (function.name.clone(), offset) + }) +} diff --git a/src/lib.rs b/src/lib.rs index 5739155..c3bf654 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,12 @@ //! Luau module per decoded module, and [`project::compile_project`] assembles //! multiple modules into a Roblox project layout. +pub mod diagnostics; pub mod project; pub mod translate; pub mod wasm; +pub use diagnostics::{Diagnostic, DiagnosticReport, SourceLocation}; pub use project::{ compile_project, discover_project_request, CompiledProject, GeneratedProjectModule, ModuleExecutionSide, ProjectCompilationOutcome, ProjectCompilationProblem, diff --git a/src/translate/problem.rs b/src/translate/problem.rs index 4ed1154..b69e8ea 100644 --- a/src/translate/problem.rs +++ b/src/translate/problem.rs @@ -1,3 +1,4 @@ +use crate::diagnostics::DiagnosticReport; use thiserror::Error; /// Names one reason translation stopped before an artifact was produced. @@ -34,14 +35,29 @@ pub enum TranslationProblemReason { /// Carries every translation problem discovered while emitting one module. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranslationRejection { + diagnostics: DiagnosticReport, problems: Vec, } impl TranslationRejection { /// @why Lets every rejection problem travel together through one outcome. #[must_use] - pub const fn from_problems(problems: Vec) -> Self { - Self { problems } + pub fn from_problems(problems: Vec) -> Self { + let diagnostics = DiagnosticReport::without_locations( + problems + .iter() + .map(|problem| (problem_code(problem).into(), problem.to_string())), + ); + Self { + diagnostics, + problems, + } + } + + /// Returns the stable structured diagnostics for these translation problems. + #[must_use] + pub const fn diagnostics(&self) -> &DiagnosticReport { + &self.diagnostics } /// @why Lets callers report every problem at once instead of stopping at the first. @@ -66,3 +82,12 @@ impl From for TranslationRejection { Self::from_problems(vec![reason]) } } +const fn problem_code(problem: &TranslationProblemReason) -> &'static str { + match problem { + TranslationProblemReason::UnsupportedInstruction { .. } => "unsupported_instruction", + TranslationProblemReason::ImmutableGlobalSet { .. } => "immutable_global_set", + TranslationProblemReason::MissingIndirectCallTable => "missing_indirect_call_table", + TranslationProblemReason::MissingMemory => "missing_memory", + TranslationProblemReason::Internal(_) => "internal_translation_failure", + } +} diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index a02097e..dc68dfe 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -489,7 +489,9 @@ pub fn decode_module(wasm_bytes: &[u8]) -> DecodeOutcome { start_function, })) } else { - DecodeOutcome::Rejected(WasmDecodeRejection::from_problems(problems)) + DecodeOutcome::Rejected(WasmDecodeRejection::from_module( + problems, &module, wasm_bytes, + )) } } diff --git a/src/wasm/problem.rs b/src/wasm/problem.rs index 8e7dd1d..c3c7218 100644 --- a/src/wasm/problem.rs +++ b/src/wasm/problem.rs @@ -1,3 +1,4 @@ +use crate::diagnostics::{function_context, source_location, Diagnostic, DiagnosticReport}; use thiserror::Error; /// Names one reason a wasm module cannot be accepted by the luau-rs pipeline. @@ -82,13 +83,57 @@ pub enum WasmDecodeProblemReason { #[derive(Debug, Clone, PartialEq, Eq)] pub struct WasmDecodeRejection { problems: Vec, + diagnostics: DiagnosticReport, } impl WasmDecodeRejection { /// @why Lets every rejection problem travel together through one outcome. #[must_use] - pub const fn from_problems(problems: Vec) -> Self { - Self { problems } + pub fn from_problems(problems: Vec) -> Self { + let diagnostics = DiagnosticReport::without_locations( + problems + .iter() + .map(|problem| (problem_code(problem).into(), problem.to_string())), + ); + Self { + problems, + diagnostics, + } + } + + /// Builds diagnostics while the parsed wasm metadata is still available. + pub(crate) fn from_module( + problems: Vec, + module: &walrus::Module, + raw_wasm: &[u8], + ) -> Self { + let mut diagnostics = problems + .iter() + .map(|problem| { + let preferred = |function: &walrus::Function| match problem { + WasmDecodeProblemReason::UnsupportedVectorType => { + let ty = module.types.get(function.ty()); + ty.params() + .iter() + .chain(ty.results()) + .any(|value_type| *value_type == walrus::ValType::V128) + } + _ => true, + }; + let (function, offset) = + function_context(module, preferred).unwrap_or((None, None)); + Diagnostic { + code: problem_code(problem).into(), + message: problem.to_string(), + location: source_location(raw_wasm, function, offset), + } + }) + .collect::>(); + diagnostics.dedup(); + Self { + problems, + diagnostics: DiagnosticReport::new(diagnostics), + } } /// @why Lets callers report every problem at once instead of stopping at the first. @@ -101,6 +146,12 @@ impl WasmDecodeRejection { &self.problems } + /// Returns the stable structured diagnostics for these rejection reasons. + #[must_use] + pub const fn diagnostics(&self) -> &DiagnosticReport { + &self.diagnostics + } + /// @why Gives diagnostics a stable count without exposing the problem vector. #[must_use] pub const fn problem_count(&self) -> usize { @@ -113,3 +164,22 @@ impl From for WasmDecodeRejection { Self::from_problems(vec![reason]) } } + +const fn problem_code(problem: &WasmDecodeProblemReason) -> &'static str { + match problem { + WasmDecodeProblemReason::MalformedModule(_) => "malformed_module", + WasmDecodeProblemReason::UnsupportedMemoryCount { .. } => "unsupported_memory_count", + WasmDecodeProblemReason::MemorySizeTooLarge { .. } => "memory_size_too_large", + WasmDecodeProblemReason::NegativeSegmentOffset { .. } => "negative_segment_offset", + WasmDecodeProblemReason::MemoryIndexTooLarge { .. } => "memory_index_too_large", + WasmDecodeProblemReason::UnsupportedImportKind { .. } => "unsupported_import_kind", + WasmDecodeProblemReason::UnsupportedInstruction { .. } => "unsupported_instruction", + WasmDecodeProblemReason::UnsupportedVectorType => "unsupported_vector_type", + WasmDecodeProblemReason::UnsupportedExceptionHandling => "unsupported_exception_handling", + WasmDecodeProblemReason::InvalidDataSegmentMemory { .. } => "invalid_data_segment_memory", + WasmDecodeProblemReason::UnsupportedElementSegment => "unsupported_element_segment", + WasmDecodeProblemReason::UnsupportedExportKind { .. } => "unsupported_export_kind", + WasmDecodeProblemReason::UnsupportedGlobalInitializer => "unsupported_global_initializer", + WasmDecodeProblemReason::UnsupportedDataOffset => "unsupported_data_offset", + } +} diff --git a/tests/diagnostics.rs b/tests/diagnostics.rs new file mode 100644 index 0000000..006cc2e --- /dev/null +++ b/tests/diagnostics.rs @@ -0,0 +1,78 @@ +//! Diagnostics contract tests for source-aware wasm rejection output. + +use luau_rs::{decode_module, DecodeOutcome, WasmDecodeProblemReason}; +use std::error::Error; +use std::path::PathBuf; + +fn diagnostics_fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!("fixtures/rust-diagnostics/{name}")) +} + +#[test] +fn given_debug_wasm_when_decoder_rejects_simd_then_source_line_is_reported( +) -> Result<(), Box> { + let wasm = fs_err::read(diagnostics_fixture("diagnostics.wasm"))?; + let DecodeOutcome::Rejected(rejection) = decode_module(&wasm) else { + panic!("SIMD fixture must be rejected"); + }; + assert!(rejection + .problems() + .contains(&WasmDecodeProblemReason::UnsupportedVectorType)); + + let [diagnostic] = rejection.diagnostics().diagnostics() else { + panic!("the SIMD rejection should have one structured diagnostic"); + }; + assert_eq!(diagnostic.code, "unsupported_vector_type"); + assert!(diagnostic + .location + .file + .as_deref() + .is_some_and(|file| file.ends_with("src/lib.rs"))); + assert_eq!(diagnostic.location.line, Some(15)); + assert!(diagnostic.location.source_available); + assert!(diagnostic.location.function.is_some()); + Ok(()) +} + +#[test] +fn given_stripped_wasm_when_decoder_rejects_simd_then_function_offset_is_reported( +) -> Result<(), Box> { + let wasm = fs_err::read(diagnostics_fixture("diagnostics-stripped.wasm"))?; + let DecodeOutcome::Rejected(rejection) = decode_module(&wasm) else { + panic!("SIMD fixture must be rejected"); + }; + let [diagnostic] = rejection.diagnostics().diagnostics() else { + panic!("the SIMD rejection should have one structured diagnostic"); + }; + assert_eq!(diagnostic.code, "unsupported_vector_type"); + assert_eq!( + diagnostic.location.function.as_deref(), + Some("unsupported_vector") + ); + assert!(diagnostic.location.wasm_offset.is_some()); + assert!(!diagnostic.location.source_available); + assert!(diagnostic + .location + .hint + .as_deref() + .is_some_and(|hint| hint.contains("debug information"))); + Ok(()) +} + +#[test] +fn given_no_metadata_when_rejection_is_constructed_then_fallback_is_actionable() { + let rejection = luau_rs::WasmDecodeRejection::from_problems(vec![ + WasmDecodeProblemReason::UnsupportedInstruction { + instruction: "v128.load".into(), + }, + ]); + let [diagnostic] = rejection.diagnostics().diagnostics() else { + panic!("one structured diagnostic is expected"); + }; + assert!(!diagnostic.location.source_available); + assert!(diagnostic + .location + .hint + .as_deref() + .is_some_and(|hint| hint.contains("debug information"))); +}