Skip to main content

morphir_core/ir/
diagnostic.rs

1//! Diagnostics shared by every IR codec, matching the Morphir Compatibility Kit's
2//! diagnostic codes, stages and JSON-pointer cursors.
3
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// The kit's diagnostic codes.
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum DiagnosticCode {
11    InvalidJson,
12    DuplicateMember,
13    NestingTooDeep,
14    InvalidType,
15    MissingMember,
16    UnknownMember,
17    UnknownNode,
18    AmbiguousShorthand,
19    InvalidName,
20    InvalidPath,
21    InvalidFqname,
22    InvalidLiteral,
23    InvalidAccess,
24    InvalidDistributionShape,
25    LegacySpelling,
26    MissingFormatVersion,
27    DuplicateFormatVersion,
28    InvalidFormatVersionType,
29    InvalidFormatVersionSyntax,
30    FormatVersionOutOfRange,
31    UnsupportedFormatVersionMajor,
32    UnsupportedFormatVersionMinor,
33    InvalidYaml,
34    UnsupportedYamlFeature,
35    /// A file says a format version other than the one it has to: a document tree's file that
36    /// disagrees with the tree's manifest, or a manifest a reader of another version was handed.
37    /// Not one of the kit's codes; the document-tree layout adds it.
38    VersionMismatch,
39}
40
41/// The stage in which a diagnostic was raised.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "lowercase")]
44pub enum DiagnosticStage {
45    Syntax,
46    Normalization,
47    Semantic,
48}
49
50/// A single diagnostic, located by a JSON pointer cursor.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Diagnostic {
53    pub code: DiagnosticCode,
54    pub stage: DiagnosticStage,
55    pub cursor: String,
56    pub message: String,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub line: Option<u32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub column: Option<u32>,
61}
62
63/// A non-fatal warning, e.g. `legacy_spelling`.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Warning {
66    pub code: DiagnosticCode,
67    pub cursor: String,
68}
69
70impl Diagnostic {
71    pub fn new(
72        code: DiagnosticCode,
73        stage: DiagnosticStage,
74        cursor: impl Into<String>,
75        message: impl Into<String>,
76    ) -> Self {
77        Self {
78            code,
79            stage,
80            cursor: cursor.into(),
81            message: message.into(),
82            line: None,
83            column: None,
84        }
85    }
86
87    pub fn normalization(
88        code: DiagnosticCode,
89        cursor: impl Into<String>,
90        message: impl Into<String>,
91    ) -> Self {
92        Self::new(code, DiagnosticStage::Normalization, cursor, message)
93    }
94
95    pub fn syntax(
96        code: DiagnosticCode,
97        cursor: impl Into<String>,
98        message: impl Into<String>,
99    ) -> Self {
100        Self::new(code, DiagnosticStage::Syntax, cursor, message)
101    }
102
103    /// Recovers a [`Diagnostic`] previously smuggled through a serde error via
104    /// [`DiagnosticError`].
105    ///
106    /// The marker is only honoured where [`DiagnosticError`] writes it: at the very start of the
107    /// message. A derived `Deserialize` echoes the document back into its own messages
108    /// (``unknown variant `…` ``), so a document member spelling the marker out would otherwise
109    /// forge a diagnostic of its own choosing. What follows the marker has to be one JSON
110    /// `Diagnostic` and then nothing but the location suffix a codec appends, for the same
111    /// reason.
112    pub fn from_serde_error<E: fmt::Display>(err: &E) -> Option<Diagnostic> {
113        let text = err.to_string();
114        let rest = text.strip_prefix(DIAGNOSTIC_MARKER)?;
115        // serde_json appends its own " at line N column M" text after the JSON payload, so parse
116        // just the leading JSON value rather than requiring the whole remainder to be valid JSON.
117        let mut carried = serde_json::Deserializer::from_str(rest).into_iter::<Diagnostic>();
118        let diagnostic = carried.next()?.ok()?;
119        is_location_suffix(&rest[carried.byte_offset()..]).then_some(diagnostic)
120    }
121}
122
123/// Whether `text` is all a codec may add after the carried JSON: nothing, or its own location.
124///
125/// serde_json writes ` at line N column M` and serde-saphyr ` at line N, column M`; anything
126/// else means the JSON was not the whole of what the marker introduced.
127fn is_location_suffix(text: &str) -> bool {
128    let text = text.trim_start();
129    if text.is_empty() {
130        return true;
131    }
132    let Some(rest) = text.strip_prefix("at line ") else {
133        return false;
134    };
135    let rest = rest.trim_start_matches(|c: char| c.is_ascii_digit());
136    let rest = rest.trim_start_matches(',').trim_start();
137    let Some(rest) = rest.strip_prefix("column ") else {
138        return false;
139    };
140    !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit())
141}
142
143const DIAGNOSTIC_MARKER: &str = "@@morphir-diagnostic@@";
144
145/// Wraps a [`Diagnostic`] so it can be carried through a serde error via
146/// `serde::de::Error::custom`, and recovered later with [`Diagnostic::from_serde_error`].
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct DiagnosticError(pub Diagnostic);
149
150impl fmt::Display for DiagnosticError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let json = serde_json::to_string(&self.0).map_err(|_| fmt::Error)?;
153        write!(f, "{DIAGNOSTIC_MARKER}{json}")
154    }
155}
156
157impl std::error::Error for DiagnosticError {}