Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ 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"
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"
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions fixtures/rust-diagnostics/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "rust-diagnostics"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[profile.dev]
debug = 2
Binary file added fixtures/rust-diagnostics/diagnostics-stripped.wasm
Binary file not shown.
Binary file added fixtures/rust-diagnostics/diagnostics.wasm
Binary file not shown.
15 changes: 15 additions & 0 deletions fixtures/rust-diagnostics/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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
}
226 changes: 226 additions & 0 deletions src/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
/// One-based Rust source line, when present.
pub line: Option<u64>,
/// One-based source column; zero means the line's left edge.
pub column: Option<u64>,
/// The wasm function name or name-section fallback.
pub function: Option<String>,
/// The code-section instruction offset, when known.
pub wasm_offset: Option<usize>,
/// Whether a source file and line were recovered from DWARF.
pub source_available: bool,
/// Actionable guidance when source information is absent.
pub hint: Option<String>,
}

/// 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<Diagnostic>,
}

impl DiagnosticReport {
/// Creates a report from diagnostics collected during one compiler stage.
#[must_use]
pub const fn new(diagnostics: Vec<Diagnostic>) -> 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<Item = (String, String)>) -> 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<String>,
wasm_offset: Option<usize>,
) -> 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<R>(reader: &R) -> Option<String>
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<String>, Option<usize>)> {
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)
})
}
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 27 additions & 2 deletions src/translate/problem.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::diagnostics::DiagnosticReport;
use thiserror::Error;

/// Names one reason translation stopped before an artifact was produced.
Expand Down Expand Up @@ -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<TranslationProblemReason>,
}

impl TranslationRejection {
/// @why Lets every rejection problem travel together through one outcome.
#[must_use]
pub const fn from_problems(problems: Vec<TranslationProblemReason>) -> Self {
Self { problems }
pub fn from_problems(problems: Vec<TranslationProblemReason>) -> 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.
Expand All @@ -66,3 +82,12 @@ impl From<TranslationProblemReason> 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",
}
}
4 changes: 3 additions & 1 deletion src/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
))
}
}

Expand Down
Loading
Loading