lean_rs_host/host/elaboration/diagnostic.rs
1//! Typed diagnostic surface attached to elaboration / kernel-check
2//! failures.
3//!
4//! Lean's `MessageLog` carries a severity tag, a human-readable message,
5//! and an optional source position. The capability shim copies each
6//! diagnostic into a `Diagnostic` structure on the Lean side; the impls
7//! in this module decode them into Rust [`LeanDiagnostic`] /
8//! [`LeanPosition`] values without inspecting Lean's internal
9//! representation.
10//!
11//! The encoding contract (mirrored in `Elaboration.lean`):
12//!
13//! - `Diagnostic = lean_alloc_ctor(0, 3, 1)` — three object slots
14//! (message, position, fileLabel) and one scalar byte (severity at
15//! offset 0 of the scalar tail).
16//! - `DiagnosticPos = lean_alloc_ctor(0, 4, 0)` — four object slots
17//! (line, column, endLine, endColumn) carrying `Nat` and `Option Nat`
18//! values. `Nat` is scalar-tagged for small values (always true for
19//! source positions), so each slot decodes through
20//! [`lean_rs::abi::nat::try_to_u64`].
21
22// SAFETY DOC: every `unsafe { ... }` block in this file carries its own
23// `// SAFETY:` comment; the blanket allow keeps the scope minimal per
24// `docs/architecture/01-safety-model.md`.
25#![allow(unsafe_code)]
26
27use lean_rs_sys::ctor::lean_ctor_get_uint8;
28use lean_rs_sys::object::{lean_is_scalar, lean_obj_tag, lean_unbox};
29
30use lean_rs::Obj;
31use lean_rs::abi::nat;
32use lean_rs::abi::structure::{ctor_tag, take_ctor_objects};
33use lean_rs::abi::traits::{TryFromLean, conversion_error};
34use lean_rs::error::{LeanResult, bound_message};
35
36/// Severity classification attached to each [`LeanDiagnostic`]. Mirrors
37/// Lean's `MessageSeverity` constructors at 4.29.1.
38///
39/// `#[non_exhaustive]` so future Lean refinements can extend the
40/// taxonomy without breaking exhaustive matches in downstream code.
41#[derive(Copy, Clone, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub enum LeanSeverity {
44 /// Informational diagnostic; the operation may still have succeeded.
45 Info,
46 /// Warning diagnostic; the operation may still have succeeded.
47 Warning,
48 /// Error diagnostic; the operation did not succeed.
49 Error,
50}
51
52impl LeanSeverity {
53 /// Decode the byte the Lean side wrote for the `severity` scalar field.
54 /// `0 = info`, `1 = warning`, `2 = error` per the Lean-side
55 /// declaration order of `LeanRsHostShims.Elaboration.Severity`.
56 fn from_byte(byte: u8) -> LeanResult<Self> {
57 match byte {
58 0 => Ok(Self::Info),
59 1 => Ok(Self::Warning),
60 2 => Ok(Self::Error),
61 other => Err(conversion_error(format!(
62 "Lean Severity tag {other} is not in {{0=info, 1=warning, 2=error}}"
63 ))),
64 }
65 }
66}
67
68/// Source position attached to a Lean-emitted diagnostic.
69///
70/// `line` and `column` are 1-indexed (Lean convention). `end_line` /
71/// `end_column` are present when Lean attached an end position to the
72/// diagnostic — parser errors usually carry only a start position, while
73/// elaborator and kernel errors carry both.
74#[derive(Copy, Clone, Debug, Eq, PartialEq)]
75pub struct LeanPosition {
76 line: u32,
77 column: u32,
78 end_line: Option<u32>,
79 end_column: Option<u32>,
80}
81
82impl LeanPosition {
83 /// 1-indexed line number.
84 #[must_use]
85 pub fn line(&self) -> u32 {
86 self.line
87 }
88
89 /// 1-indexed column number.
90 #[must_use]
91 pub fn column(&self) -> u32 {
92 self.column
93 }
94
95 /// 1-indexed end line, if Lean attached one.
96 #[must_use]
97 pub fn end_line(&self) -> Option<u32> {
98 self.end_line
99 }
100
101 /// 1-indexed end column, if Lean attached one.
102 #[must_use]
103 pub fn end_column(&self) -> Option<u32> {
104 self.end_column
105 }
106}
107
108impl<'lean> TryFromLean<'lean> for LeanPosition {
109 fn try_from_lean(obj: Obj<'lean>) -> LeanResult<Self> {
110 let [line_o, column_o, end_line_o, end_column_o] = take_ctor_objects::<4>(obj, 0, "DiagnosticPos")?;
111 Ok(Self {
112 line: decode_nat_u32(line_o, "DiagnosticPos.line")?,
113 column: decode_nat_u32(column_o, "DiagnosticPos.column")?,
114 end_line: decode_option_nat_u32(end_line_o, "DiagnosticPos.endLine")?,
115 end_column: decode_option_nat_u32(end_column_o, "DiagnosticPos.endColumn")?,
116 })
117 }
118}
119
120/// One Lean-emitted diagnostic: severity tag, bounded message, optional
121/// source position, and the file label the elaborator received.
122///
123/// The `message` is structurally bounded at
124/// [`lean_rs::LEAN_ERROR_MESSAGE_LIMIT`]: the decoder truncates on a UTF-8
125/// char boundary before storing.
126#[derive(Clone, Debug)]
127pub struct LeanDiagnostic {
128 severity: LeanSeverity,
129 message: String,
130 position: Option<LeanPosition>,
131 file_label: String,
132}
133
134impl LeanDiagnostic {
135 /// The severity tag Lean attached to this diagnostic.
136 #[must_use]
137 pub fn severity(&self) -> LeanSeverity {
138 self.severity
139 }
140
141 /// Lean's diagnostic message, truncated to at most
142 /// [`lean_rs::LEAN_ERROR_MESSAGE_LIMIT`] bytes on a UTF-8 char
143 /// boundary.
144 #[must_use]
145 pub fn message(&self) -> &str {
146 &self.message
147 }
148
149 /// Source position when Lean attached one. Parser-level errors
150 /// often carry no position; elaborator and kernel errors do.
151 #[must_use]
152 pub fn position(&self) -> Option<&LeanPosition> {
153 self.position.as_ref()
154 }
155
156 /// The file label this diagnostic belongs to. Falls back to the
157 /// caller-supplied [`crate::host::elaboration::LeanElabOptions::file_label`]
158 /// when Lean's own `MessageLog` did not carry a file name.
159 #[must_use]
160 pub fn file_label(&self) -> &str {
161 &self.file_label
162 }
163
164 /// Construct a synthetic error-severity diagnostic without a source
165 /// position. Used by the host stack when it must surface a
166 /// diagnostic that did not originate in Lean's `MessageLog` — for
167 /// example, the `LeanMetaResponse::Unsupported` branch built when a
168 /// capability dylib does not export the requested meta service.
169 /// The `message` is bounded at [`lean_rs::LEAN_ERROR_MESSAGE_LIMIT`]
170 /// on a UTF-8 char boundary, mirroring the Lean-decoded path.
171 pub(crate) fn synthetic_error(message: String, file_label: String) -> Self {
172 Self {
173 severity: LeanSeverity::Error,
174 message: bound_message(message),
175 position: None,
176 file_label,
177 }
178 }
179}
180
181impl<'lean> TryFromLean<'lean> for LeanDiagnostic {
182 fn try_from_lean(obj: Obj<'lean>) -> LeanResult<Self> {
183 require_diagnostic_ctor(&obj)?;
184 let ptr = obj.as_raw_borrowed();
185 // SAFETY: ctor shape validated above; `lean_ctor_scalar_cptr`
186 // starts immediately past the object slots, so offset 0 reads
187 // the first scalar tail byte (the severity tag the Lean-side
188 // constructor writes via `lean_ctor_set_uint8(_,
189 // sizeof(void*)*num_objs, _)`).
190 let severity_byte = unsafe { lean_ctor_get_uint8(ptr, 0) };
191 let [msg_o, pos_o, label_o] = take_ctor_objects::<3>(obj, 0, "Diagnostic")?;
192 let severity = LeanSeverity::from_byte(severity_byte)?;
193 let message = bound_message(String::try_from_lean(msg_o)?);
194 let position = Option::<LeanPosition>::try_from_lean(pos_o)?;
195 let file_label = String::try_from_lean(label_o)?;
196 Ok(Self {
197 severity,
198 message,
199 position,
200 file_label,
201 })
202 }
203}
204
205/// Tag-only check; the field-count check happens inside
206/// [`take_ctor_objects`] below.
207fn require_diagnostic_ctor(obj: &Obj<'_>) -> LeanResult<()> {
208 let tag = ctor_tag(obj)?;
209 if tag == 0 {
210 Ok(())
211 } else {
212 Err(conversion_error(format!(
213 "expected Lean Diagnostic ctor (tag 0), found tag {tag}"
214 )))
215 }
216}
217
218/// Decode a Lean `Nat` slot to a `u32`, refusing values that overflow.
219fn decode_nat_u32(obj: Obj<'_>, label: &str) -> LeanResult<u32> {
220 let raw = nat::try_to_u64(obj)?;
221 u32::try_from(raw).map_err(|_| conversion_error(format!("{label} = {raw} exceeds u32 range")))
222}
223
224/// Decode a Lean `Option Nat` slot, refusing wrong tags / out-of-range
225/// `Some` payloads. Inlined rather than reusing [`Option<T>`]'s blanket
226/// impl because the inner `T = Nat` does not match the polymorphic-boxed
227/// `u32::try_from_lean` ABI (Nat is scalar-tagged, `UInt32` in
228/// polymorphic position is ctor-boxed).
229fn decode_option_nat_u32(obj: Obj<'_>, label: &str) -> LeanResult<Option<u32>> {
230 let ptr = obj.as_raw_borrowed();
231 // SAFETY: pure pointer-bit math.
232 if unsafe { lean_is_scalar(ptr) } {
233 // SAFETY: scalar branch verified above.
234 let payload = unsafe { lean_unbox(ptr) };
235 return match payload {
236 0 => Ok(None),
237 other => Err(conversion_error(format!(
238 "{label}: expected Option.none (scalar tag 0), found scalar payload {other}"
239 ))),
240 };
241 }
242 // SAFETY: non-scalar branch; tag read on the owned object header.
243 let tag = unsafe { lean_obj_tag(ptr) };
244 if tag != 1 {
245 return Err(conversion_error(format!(
246 "{label}: expected Option.some ctor (tag 1), found heap tag {tag}"
247 )));
248 }
249 let [inner] = take_ctor_objects::<1>(obj, 1, "Option Nat")?;
250 Ok(Some(decode_nat_u32(inner, label)?))
251}