tensor_wasm_jit/lowering_errors.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! Structured errors produced by the Cranelift → [`LoweredFunction`]
5//! lowering pipeline (RFC 0001 wave 2).
6//!
7//! Each variant carries enough context for a maintainer to grep the call
8//! site and understand the failure. The boundary into
9//! [`crate::pliron_dialect::PlironLoweringError`] is provided so the
10//! wave-2 lowering driver (W2.4) and the `CraneliftLowerer` impl (W2.5)
11//! can bubble these errors through the trait surface.
12//!
13//! [`LoweredFunction`]: crate::lowered_ir::LoweredFunction
14
15#![cfg(feature = "cuda-oxide-backend")]
16
17use cranelift_codegen::ir as cl;
18use thiserror::Error;
19
20// TODO(W2.2): once `crate::lower_signature::SignatureLoweringError` lands,
21// re-introduce the `Signature(#[from] SignatureLoweringError)` variant and
22// update the `From<LoweringError> for PlironLoweringError` mapping so
23// signature errors also reach the trait surface.
24
25/// Structured errors produced by the Cranelift → `LoweredFunction`
26/// lowering pipeline.
27///
28/// Each variant carries enough context for a maintainer to grep the call
29/// site and understand the failure. The wave-2 lowering driver (W2.4)
30/// and `CraneliftLowerer` impl (W2.5) return this error type, which is
31/// then mapped into [`crate::pliron_dialect::PlironLoweringError`] at
32/// the trait boundary.
33#[derive(Debug, Clone, PartialEq, Eq, Error)]
34pub enum LoweringError {
35 /// The opcode has no `LoweredOp` equivalent and isn't on the
36 /// reject-list either — this is a "we forgot to wire it up" path.
37 /// Distinct from [`LoweringError::Rejected`] because it indicates a
38 /// hole, not a deliberate refusal.
39 #[error("lowering: unsupported opcode `{op}` (no lower_* family matched)")]
40 UnsupportedOpcode {
41 /// Cranelift opcode mnemonic.
42 op: String,
43 /// Block + instruction index inside the function, for diagnostics.
44 location: InstLocation,
45 },
46
47 /// A Cranelift [`Value`](cl::Value) or [`Block`](cl::Block) reference
48 /// resolved to an unsupported type.
49 #[error("lowering: unsupported type `{ty}` at {location}")]
50 UnsupportedType {
51 /// Cranelift type as printed via `Display`.
52 ty: String,
53 /// Where the unsupported type appears.
54 location: InstLocation,
55 },
56
57 /// An operand [`Value`](cl::Value) wasn't allocated in the
58 /// `LoweringBuilder` before use. Indicates a use-before-def in the
59 /// walker (caller bug, not user input).
60 #[error("lowering: undefined value `{value:?}` at {location}")]
61 UndefinedValue {
62 /// The Cranelift `Value` that wasn't in the `value_map`.
63 value: cl::Value,
64 /// Where the missing operand appeared.
65 location: InstLocation,
66 },
67
68 /// A branch instruction referred to a [`Block`](cl::Block) that the
69 /// walker didn't visit (so the `block_map` has no entry).
70 #[error("lowering: branch references unknown block `{block:?}` at {location}")]
71 BadBlockReference {
72 /// The unresolved block.
73 block: cl::Block,
74 /// Where the branch appears.
75 location: InstLocation,
76 },
77
78 /// A `LoweredBlock` has no terminator (or has a terminator that isn't
79 /// last).
80 #[error("lowering: malformed terminator in block `{block_id}`")]
81 MalformedTerminator {
82 /// The `LoweredBlockId` of the offending block.
83 block_id: crate::lowered_ir::LoweredBlockId,
84 },
85
86 /// The function was hit by the reject-list (W2.1). Carries the first
87 /// rejection for diagnostics; the full list is available from
88 /// [`crate::reject_list`].
89 #[error("lowering: function rejected by reject-list: {reason}")]
90 Rejected {
91 /// One-line description of the rejection reason.
92 reason: String,
93 /// Where the rejected op appears.
94 location: InstLocation,
95 },
96}
97
98/// Location of a Cranelift instruction inside a function, used for
99/// diagnostics. Cheap to construct and stable across the lowering pass.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct InstLocation {
102 /// Cranelift block id (as displayed: `block0`, `block1`, etc).
103 pub block: u32,
104 /// Position within the block (0-indexed, layout order).
105 pub inst_index: u32,
106}
107
108impl std::fmt::Display for InstLocation {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 write!(f, "block{}[{}]", self.block, self.inst_index)
111 }
112}
113
114impl InstLocation {
115 /// Construct from a Cranelift [`Block`](cl::Block) + a 0-indexed
116 /// instruction position.
117 ///
118 /// Cranelift's `Block` exposes its numeric index via the
119 /// `EntityRef::index()` trait method, which is what gets rendered as
120 /// `block0`/`block1`/... in Cranelift's textual IR. We snapshot that
121 /// index into a `u32` so `InstLocation` stays plain-old-data and
122 /// doesn't drag Cranelift types into error messages.
123 pub fn new(block: cl::Block, inst_index: u32) -> Self {
124 use cranelift_codegen::entity::EntityRef;
125 Self {
126 block: block.index() as u32,
127 inst_index,
128 }
129 }
130}
131
132impl From<LoweringError> for crate::pliron_dialect::PlironLoweringError {
133 /// Map every `LoweringError` variant into
134 /// [`PlironLoweringError::UnsupportedOp`] using the error's `Display`
135 /// representation as the `op` description.
136 ///
137 /// This is intentionally lossy for wave 2: richer mapping (dedicated
138 /// `PlironLoweringError` variants per `LoweringError` kind) comes in
139 /// wave 3 when `PlironLoweringError` grows the necessary surface.
140 /// The point of this `From` impl is to land the boundary now so W2.5
141 /// can bubble driver errors into the trait surface without churn
142 /// later.
143 fn from(err: LoweringError) -> Self {
144 Self::UnsupportedOp {
145 op: err.to_string(),
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::pliron_dialect::PlironLoweringError;
154 use cranelift_codegen::entity::EntityRef;
155
156 fn loc(block: u32, inst_index: u32) -> InstLocation {
157 InstLocation { block, inst_index }
158 }
159
160 #[test]
161 fn inst_location_display_matches_block_index_format() {
162 assert_eq!(loc(5, 3).to_string(), "block5[3]");
163 assert_eq!(loc(0, 0).to_string(), "block0[0]");
164 assert_eq!(loc(42, 17).to_string(), "block42[17]");
165 }
166
167 #[test]
168 fn inst_location_new_round_trips_block_index() {
169 // Cranelift `Block` numbering is exposed through `EntityRef`; the
170 // `new` constructor must snapshot that into the `block` field
171 // verbatim.
172 let block = cl::Block::new(7);
173 let location = InstLocation::new(block, 4);
174 assert_eq!(location.block, 7);
175 assert_eq!(location.inst_index, 4);
176 assert_eq!(location.to_string(), "block7[4]");
177 }
178
179 #[test]
180 fn inst_location_eq() {
181 // PartialEq + Eq let callers compare locations in tests cheaply.
182 assert_eq!(loc(1, 2), loc(1, 2));
183 assert_ne!(loc(1, 2), loc(1, 3));
184 assert_ne!(loc(1, 2), loc(2, 2));
185 }
186
187 #[test]
188 fn unsupported_opcode_displays_op_and_location() {
189 let err = LoweringError::UnsupportedOpcode {
190 op: "iadd_imm".to_string(),
191 location: loc(2, 5),
192 };
193 let s = err.to_string();
194 assert!(s.contains("iadd_imm"), "got: {s}");
195 assert!(s.contains("no lower_* family matched"), "got: {s}");
196 }
197
198 #[test]
199 fn unsupported_type_displays_ty_and_location() {
200 let err = LoweringError::UnsupportedType {
201 ty: "i128".to_string(),
202 location: loc(0, 1),
203 };
204 let s = err.to_string();
205 assert!(s.contains("i128"), "got: {s}");
206 assert!(s.contains("block0[1]"), "got: {s}");
207 }
208
209 #[test]
210 fn undefined_value_displays_value_and_location() {
211 let value = cl::Value::new(13);
212 let err = LoweringError::UndefinedValue {
213 value,
214 location: loc(3, 0),
215 };
216 let s = err.to_string();
217 assert!(s.contains("block3[0]"), "got: {s}");
218 // The Debug repr of `Value` typically renders as `v13`, but we
219 // only care that *some* rendering of the value is in the string.
220 assert!(s.contains("undefined value"), "got: {s}");
221 }
222
223 #[test]
224 fn bad_block_reference_displays_block_and_location() {
225 let block = cl::Block::new(9);
226 let err = LoweringError::BadBlockReference {
227 block,
228 location: loc(1, 7),
229 };
230 let s = err.to_string();
231 assert!(s.contains("block1[7]"), "got: {s}");
232 assert!(s.contains("unknown block"), "got: {s}");
233 }
234
235 #[test]
236 fn malformed_terminator_displays_block_id() {
237 let err = LoweringError::MalformedTerminator { block_id: 4 };
238 let s = err.to_string();
239 assert!(s.contains("malformed terminator"), "got: {s}");
240 assert!(s.contains("4"), "got: {s}");
241 }
242
243 #[test]
244 fn rejected_displays_reason_and_location() {
245 let err = LoweringError::Rejected {
246 reason: "atomic ops not supported".to_string(),
247 location: loc(0, 3),
248 };
249 let s = err.to_string();
250 assert!(s.contains("atomic ops not supported"), "got: {s}");
251 assert!(s.contains("rejected by reject-list"), "got: {s}");
252 }
253
254 #[test]
255 fn lowering_error_eq() {
256 // Two errors built from the same fields must compare equal.
257 let a = LoweringError::UnsupportedOpcode {
258 op: "iadd_imm".to_string(),
259 location: loc(2, 5),
260 };
261 let b = LoweringError::UnsupportedOpcode {
262 op: "iadd_imm".to_string(),
263 location: loc(2, 5),
264 };
265 let c = LoweringError::UnsupportedOpcode {
266 op: "iadd_imm".to_string(),
267 location: loc(2, 6),
268 };
269 assert_eq!(a, b);
270 assert_ne!(a, c);
271
272 // Across variants, different kinds never compare equal even with
273 // matching field values.
274 let d = LoweringError::MalformedTerminator { block_id: 4 };
275 let e = LoweringError::MalformedTerminator { block_id: 4 };
276 let f = LoweringError::MalformedTerminator { block_id: 5 };
277 assert_eq!(d, e);
278 assert_ne!(d, f);
279 assert_ne!(a, d);
280 }
281
282 #[test]
283 fn from_lowering_error_into_pliron_unsupported_op_uses_display() {
284 // The boundary into `PlironLoweringError` must produce
285 // `UnsupportedOp` and use the source error's Display string as
286 // the `op` description.
287 let err = LoweringError::UnsupportedOpcode {
288 op: "iadd_imm".to_string(),
289 location: loc(2, 5),
290 };
291 let display = err.to_string();
292 let pliron: PlironLoweringError = err.into();
293 match pliron {
294 PlironLoweringError::UnsupportedOp { op } => {
295 assert_eq!(op, display);
296 assert!(op.contains("iadd_imm"), "got: {op}");
297 }
298 other => panic!("expected UnsupportedOp, got {other:?}"),
299 }
300 }
301
302 #[test]
303 fn from_lowering_error_into_pliron_for_rejected_variant() {
304 // Sanity check: a non-opcode variant also routes to UnsupportedOp
305 // for now (richer mapping is wave 3).
306 let err = LoweringError::Rejected {
307 reason: "atomics".to_string(),
308 location: loc(0, 0),
309 };
310 let display = err.to_string();
311 let pliron: PlironLoweringError = err.into();
312 match pliron {
313 PlironLoweringError::UnsupportedOp { op } => {
314 assert_eq!(op, display);
315 assert!(op.contains("atomics"), "got: {op}");
316 }
317 other => panic!("expected UnsupportedOp, got {other:?}"),
318 }
319 }
320
321 #[test]
322 fn inst_location_new_uses_entity_ref_index() {
323 // Defence-in-depth: the EntityRef trait is what Cranelift uses to
324 // render `blockN`, and `InstLocation::new` must use the same
325 // numbering so error messages line up with Cranelift's textual
326 // IR.
327 let block = cl::Block::new(123);
328 assert_eq!(block.index(), 123);
329 let location = InstLocation::new(block, 0);
330 assert_eq!(location.block, 123);
331 }
332}