Skip to main content

candid_core/
diagnostics.rs

1use serde::{Deserialize, Serialize};
2// Only `CompileError` renders through `Display`, and it is compiler surface.
3#[cfg(feature = "compiler")]
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum DiagnosticPhase {
9    Parse,
10    TypeCheck,
11    Load,
12    Lower,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Severity {
18    Error,
19}
20
21/// The `{resource, limit, observed}` triple attached to every
22/// `resource_limit_exceeded` failure.
23///
24/// `limit` and `observed` are fixed-width `u64` so the serialized triple
25/// means the same thing on every platform; the internal `usize` counters they
26/// are widened from convert exactly on every supported (32- and 64-bit)
27/// target. The JSON numeric text is identical to what the previous
28/// platform-width fields produced.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct ResourceLimitInfo {
32    pub resource: String,
33    pub limit: u64,
34    pub observed: u64,
35}
36
37/// A logical source location.
38///
39/// Two forms exist, and producers must never mix them up:
40///
41/// * an **exact** span carries `start_byte`/`end_byte` offsets that are valid
42///   for the named source's original text (see [`SourceSpan::exact`]);
43/// * a **source-scoped** location names a logical source without offsets (see
44///   [`SourceSpan::source_only`]), used when the underlying tool reported a
45///   position against rewritten text whose offsets do not apply to the
46///   original source.
47///
48/// `start_byte` and `end_byte` are set together or not at all, and a span
49/// carries a source name, offsets, or both — deserialization rejects
50/// half-spans and empty spans. Offsets are omitted from JSON when absent, so
51/// pre-existing exact-span output is unchanged.
52///
53/// Offsets are fixed-width `u64` so serialized spans are platform-neutral;
54/// producers widen `usize` byte offsets exactly, and any consumer that
55/// indexes text with them must narrow with a checked conversion.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(try_from = "RawSourceSpan")]
58pub struct SourceSpan {
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub source_name: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub start_byte: Option<u64>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub end_byte: Option<u64>,
65}
66
67/// Decode-side mirror of [`SourceSpan`] so the two-forms invariant is checked
68/// on every deserialization, not only at the constructors.
69#[derive(Deserialize)]
70#[serde(deny_unknown_fields)]
71struct RawSourceSpan {
72    #[serde(default)]
73    source_name: Option<String>,
74    #[serde(default)]
75    start_byte: Option<u64>,
76    #[serde(default)]
77    end_byte: Option<u64>,
78}
79
80impl TryFrom<RawSourceSpan> for SourceSpan {
81    type Error = String;
82
83    fn try_from(raw: RawSourceSpan) -> Result<Self, String> {
84        match (raw.start_byte.is_some(), raw.end_byte.is_some()) {
85            (true, true) => {}
86            (false, false) if raw.source_name.is_some() => {}
87            (false, false) => {
88                return Err("a source span names a source, carries offsets, or both".to_string())
89            }
90            _ => return Err("start_byte and end_byte are set together or not at all".to_string()),
91        }
92        Ok(Self {
93            source_name: raw.source_name,
94            start_byte: raw.start_byte,
95            end_byte: raw.end_byte,
96        })
97    }
98}
99
100impl SourceSpan {
101    /// An exact byte range into the original text of `source_name`.
102    pub fn exact(source_name: Option<String>, start_byte: u64, end_byte: u64) -> Self {
103        Self {
104            source_name,
105            start_byte: Some(start_byte),
106            end_byte: Some(end_byte),
107        }
108    }
109
110    /// A location scoped to a logical source, with no byte offsets.
111    pub fn source_only(source_name: impl Into<String>) -> Self {
112        Self {
113            source_name: Some(source_name.into()),
114            start_byte: None,
115            end_byte: None,
116        }
117    }
118}
119
120/// A secondary location attached to a [`Diagnostic`], in the order the
121/// underlying tool reported it after the primary location.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct RelatedLocation {
125    pub message: String,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub span: Option<SourceSpan>,
128}
129
130/// The one serializable failure item shared by every domain in this crate.
131///
132/// `Diagnostic` is the single item algebra behind compiler failures
133/// (`CompileError`, `compiler` feature), Contract/provenance validation
134/// ([`crate::ContractValidationError`], whose items are the compatibility
135/// alias [`crate::ContractViolation`]), and HostValue validation
136/// (`HostValueValidationError`, items `HostValueViolation`, `host-value`
137/// feature). Domains differ only in which optional fields they populate:
138///
139/// * compile diagnostics always carry `phase` and `severity`;
140/// * validation violations always carry `path` and never `phase`/`severity`.
141///
142/// Every optional field is omitted from JSON when absent, so each domain's
143/// pre-existing serialized shape is unchanged. Construct items through
144/// [`Diagnostic::compiler`], [`Diagnostic::violation`], or
145/// [`Diagnostic::resource_violation`] so no producer silently drops fields.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct Diagnostic {
149    pub code: String,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub phase: Option<DiagnosticPhase>,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub severity: Option<Severity>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub path: Option<String>,
156    pub message: String,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub span: Option<SourceSpan>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub related: Vec<RelatedLocation>,
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    pub notes: Vec<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub resource_limit: Option<ResourceLimitInfo>,
165}
166
167impl Diagnostic {
168    /// A compile-domain item: `phase` and `severity` are always present.
169    pub fn compiler(
170        code: impl Into<String>,
171        phase: DiagnosticPhase,
172        message: impl Into<String>,
173    ) -> Self {
174        Self {
175            code: code.into(),
176            phase: Some(phase),
177            severity: Some(Severity::Error),
178            path: None,
179            message: message.into(),
180            span: None,
181            related: Vec::new(),
182            notes: Vec::new(),
183            resource_limit: None,
184        }
185    }
186
187    /// A validation-domain item: `path` is always present and
188    /// `phase`/`severity` never are.
189    pub fn violation(
190        code: impl Into<String>,
191        path: impl Into<String>,
192        message: impl Into<String>,
193    ) -> Self {
194        Self {
195            code: code.into(),
196            phase: None,
197            severity: None,
198            path: Some(path.into()),
199            message: message.into(),
200            span: None,
201            related: Vec::new(),
202            notes: Vec::new(),
203            resource_limit: None,
204        }
205    }
206
207    /// The canonical resource-limit violation shared by every validation
208    /// domain: code `resource_limit_exceeded`, path `$`, the standard message
209    /// template, and the exact `{resource, limit, observed}` triple.
210    pub fn resource_violation(resource: &str, limit: u64, observed: u64) -> Self {
211        Self::violation(
212            "resource_limit_exceeded",
213            "$",
214            format!("resource {resource} exceeded limit {limit}; observed {observed}"),
215        )
216        .with_resource_limit(ResourceLimitInfo {
217            resource: resource.to_string(),
218            limit,
219            observed,
220        })
221    }
222
223    pub fn with_path(mut self, path: impl Into<String>) -> Self {
224        self.path = Some(path.into());
225        self
226    }
227
228    pub fn with_span(mut self, span: SourceSpan) -> Self {
229        self.span = Some(span);
230        self
231    }
232
233    pub fn with_related(mut self, related: Vec<RelatedLocation>) -> Self {
234        self.related = related;
235        self
236    }
237
238    pub fn with_notes(mut self, notes: Vec<String>) -> Self {
239        self.notes = notes;
240        self
241    }
242
243    pub fn with_resource_limit(mut self, resource_limit: ResourceLimitInfo) -> Self {
244        self.resource_limit = Some(resource_limit);
245        self
246    }
247}
248
249/// The compiler's failure collection.
250///
251/// Compiler surface: it is produced only by source compilation and by the
252/// resolvers feeding it, so it is gated on the `compiler` feature. The
253/// [`Diagnostic`] items it carries — and every field they can hold, `phase`
254/// and `severity` included — are unconditional, because `Diagnostic` is the
255/// single item algebra the Contract model already validates with.
256#[cfg(feature = "compiler")]
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct CompileError {
259    pub diagnostics: Vec<Diagnostic>,
260}
261
262#[cfg(feature = "compiler")]
263impl CompileError {
264    pub(crate) fn single(
265        code: impl Into<String>,
266        phase: DiagnosticPhase,
267        message: impl Into<String>,
268    ) -> Self {
269        Self {
270            diagnostics: vec![Diagnostic::compiler(code, phase, message)],
271        }
272    }
273
274    pub(crate) fn resource_limit(
275        resource: &str,
276        limit: usize,
277        observed: usize,
278        message: impl Into<String>,
279    ) -> Self {
280        Self {
281            diagnostics: vec![Diagnostic::compiler(
282                "resource_limit_exceeded",
283                DiagnosticPhase::Load,
284                message,
285            )
286            .with_resource_limit(ResourceLimitInfo {
287                resource: resource.to_string(),
288                limit: crate::limits::portable_count(limit),
289                observed: crate::limits::portable_count(observed),
290            })],
291        }
292    }
293}
294
295#[cfg(feature = "compiler")]
296impl fmt::Display for CompileError {
297    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
298        if let Some(diagnostic) = self.diagnostics.first() {
299            write!(formatter, "{}: {}", diagnostic.code, diagnostic.message)
300        } else {
301            write!(formatter, "Candid compilation failed")
302        }
303    }
304}
305
306#[cfg(feature = "compiler")]
307impl std::error::Error for CompileError {}