Skip to main content

weaveffi_model/validate/
diagnostic.rs

1//! [`ValidationDiagnostic`]: a [`ValidationError`] plus an optional source
2//! snippet and best-effort span for fancy miette rendering.
3//!
4//! The span is located within the error's enclosing declaration: the
5//! validator records the declaration path (module segments, then declaration
6//! and member names), and the search walks that path through the source,
7//! finding each name as the value of a `name` key, before looking for the
8//! offending text. So a duplicate parameter `x` underlines the second `x`
9//! of the right function, not the first `x` anywhere in the file.
10//!
11//! The source snippet, the span, and the miette `Diagnostic` impl are only
12//! available with the `idl` feature; without it the wrapper carries the
13//! error alone.
14
15use super::ValidationError;
16#[cfg(feature = "idl")]
17use miette::{Diagnostic, NamedSource, SourceSpan};
18
19/// Diagnostic wrapper that attaches an optional source code snippet and a
20/// best-effort byte range to a [`ValidationError`] for fancy rendering via
21/// miette. The wrapper delegates `help()` and `code()` to the inner error
22/// while exposing its own `source_code` and `labels` so the renderer can
23/// underline the offending identifier in the input.
24#[derive(Debug)]
25pub struct ValidationDiagnostic {
26    /// The underlying validation error being rendered.
27    pub error: ValidationError,
28    /// Named source snippet (filename plus contents), when an on-disk IDL was
29    /// supplied. `None` for in-memory APIs.
30    #[cfg(feature = "idl")]
31    pub src: Option<NamedSource<String>>,
32    /// Best-effort byte range of the offending identifier within `src`, used
33    /// to underline it. `None` when no span could be located.
34    #[cfg(feature = "idl")]
35    pub span: Option<SourceSpan>,
36}
37
38impl std::fmt::Display for ValidationDiagnostic {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        std::fmt::Display::fmt(&self.error, f)
41    }
42}
43
44impl std::error::Error for ValidationDiagnostic {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        self.error.source()
47    }
48}
49
50#[cfg(feature = "idl")]
51impl Diagnostic for ValidationDiagnostic {
52    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
53        self.error.code()
54    }
55
56    fn severity(&self) -> Option<miette::Severity> {
57        self.error.severity()
58    }
59
60    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
61        self.error.help()
62    }
63
64    fn url<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
65        self.error.url()
66    }
67
68    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
69        self.src
70            .as_ref()
71            .map(|s| s as &dyn miette::SourceCode)
72            .or_else(|| self.error.source_code())
73    }
74
75    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
76        if let Some(span) = self.span {
77            Some(Box::new(std::iter::once(
78                miette::LabeledSpan::new_with_span(Some("here".to_string()), span),
79            )))
80        } else {
81            self.error.labels()
82        }
83    }
84}
85
86impl ValidationDiagnostic {
87    /// Wrap `error`, located within the declaration path `scope`, with an
88    /// optional `(filename, contents)` source. When a source is provided the
89    /// constructor searches for the offending text within the enclosing
90    /// declaration and attaches a [`SourceSpan`] for fancy rendering. If no
91    /// span can be computed the label is omitted and miette still produces
92    /// a nicer message + help section than plain `Display`.
93    #[cfg(feature = "idl")]
94    pub(crate) fn new(
95        error: ValidationError,
96        scope: &[String],
97        source: Option<(&str, &str)>,
98    ) -> Self {
99        let (src, span) = match source {
100            Some((filename, contents)) => {
101                let span = error
102                    .needle()
103                    .and_then(|needle| locate(contents, scope, needle));
104                (Some(NamedSource::new(filename, contents.to_string())), span)
105            }
106            None => (None, None),
107        };
108        Self { error, src, span }
109    }
110
111    /// Wrap `error`. Without the `idl` feature there's no miette rendering,
112    /// so the scope and source are accepted for signature parity and
113    /// ignored.
114    #[cfg(not(feature = "idl"))]
115    pub(crate) fn new(
116        error: ValidationError,
117        _scope: &[String],
118        _source: Option<(&str, &str)>,
119    ) -> Self {
120        Self { error }
121    }
122}
123
124/// What to look for in the source, after walking the enclosing declaration
125/// path.
126#[cfg(feature = "idl")]
127#[derive(Clone, Copy)]
128enum Needle<'a> {
129    /// A declared name: the value of a `name` key.
130    Decl(&'a str),
131    /// The second declaration of a name (the duplicate).
132    Second(&'a str),
133    /// Any other text, such as a type reference, as a whole token.
134    Text(&'a str),
135    /// The innermost declaration of the scope itself.
136    Scope,
137    /// The second declaration of the scope's innermost name (a duplicate
138    /// sibling).
139    SecondScope,
140}
141
142#[cfg(feature = "idl")]
143impl ValidationError {
144    /// The text a diagnostic underlines, or `None` when no one spot in the
145    /// source is to blame.
146    fn needle(&self) -> Option<Needle<'_>> {
147        use Needle::{Decl, Scope, Second, SecondScope, Text};
148        /// A same-module duplicate is the second declaration in scope; a
149        /// cross-module one is the first declaration in the second module.
150        fn duplicate<'a>(name: &'a str, first: &str, second: &str) -> Needle<'a> {
151            if first == second {
152                Second(name)
153            } else {
154                Decl(name)
155            }
156        }
157        Some(match self {
158            Self::UnsupportedSchemaVersion { version, .. } => Text(version),
159            Self::DuplicateModuleName { .. } => SecondScope,
160            Self::InvalidModuleName { name, .. }
161            | Self::ReservedKeyword { name }
162            | Self::InvalidIdentifier { name, .. }
163            | Self::NameCollisionWithErrorDomain { name, .. }
164            | Self::InvalidErrorCode { name, .. }
165            | Self::EmptyStruct { name, .. }
166            | Self::EmptyEnum { name, .. }
167            | Self::EmptyInterface { name, .. }
168            | Self::EmptyCallbackInterface { name, .. } => Decl(name),
169            Self::DuplicateTypeName {
170                name,
171                first,
172                second,
173            }
174            | Self::DuplicateFunctionName {
175                name,
176                first,
177                second,
178            }
179            | Self::DuplicateErrorCodeName {
180                name,
181                first,
182                second,
183            } => duplicate(name, first, second),
184            Self::DuplicateParamName { param: name, .. }
185            | Self::DuplicateStructField { field: name, .. }
186            | Self::DuplicateEnumVariant { variant: name, .. }
187            | Self::DuplicateEnumVariantField { field: name, .. }
188            | Self::DuplicateInterfaceMember { name, .. }
189            | Self::DuplicateCallbackMethod { name, .. } => Second(name),
190            Self::CancellableNotAsync { .. }
191            | Self::ThrowsWithoutErrorDomain { .. }
192            | Self::AsyncIteratorReturn { .. } => Scope,
193            Self::DuplicateEnumValue { enum_name, .. } => Decl(enum_name),
194            Self::ConstructorHasReturn { constructor, .. }
195            | Self::AsyncConstructor { constructor, .. } => Decl(constructor),
196            Self::InvalidCallbackMethod { method, .. } => Decl(method),
197            Self::UnknownTypeRef { name }
198            | Self::QualifiedTypeRef { name }
199            | Self::UnsupportedPrimitive { name }
200            | Self::InterfaceInInvalidPosition { name, .. }
201            | Self::CallbackInterfaceInInvalidPosition { name, .. } => Text(name),
202            Self::InvalidMapKey { key_type } => Text(key_type),
203            Self::IteratorInInvalidPosition { .. } => Text("iter"),
204            Self::NoModuleName
205            | Self::SymbolCollision { .. }
206            | Self::ErrorDomainMissingName { .. }
207            | Self::DuplicateErrorCode { .. } => return None,
208        })
209    }
210}
211
212/// Locate `needle` in `src` within the declaration path `scope`, falling
213/// back to its first occurrence anywhere when the scoped search fails.
214#[cfg(feature = "idl")]
215fn locate(src: &str, scope: &[String], needle: Needle<'_>) -> Option<SourceSpan> {
216    // A needle naming the scope itself searches for the innermost name
217    // within the scope around it.
218    let (scope, needle) = match (needle, scope.split_last()) {
219        (Needle::Scope, Some((last, outer))) => (outer, Needle::Decl(last)),
220        (Needle::SecondScope, Some((last, outer))) => (outer, Needle::Second(last)),
221        (Needle::Scope | Needle::SecondScope, None) => return None,
222        _ => (scope, needle),
223    };
224    let mut from = 0;
225    for anchor in scope {
226        if let Some(at) = find_decl(src, from, anchor) {
227            from = at + anchor.len();
228        }
229    }
230    let (text, scoped) = match needle {
231        Needle::Decl(name) => (name, find_decl(src, from, name)),
232        Needle::Second(name) => (
233            name,
234            find_decl(src, from, name)
235                .and_then(|first| find_decl(src, first + name.len(), name))
236                .or_else(|| find_decl(src, from, name)),
237        ),
238        Needle::Text(text) => (text, find_token(src, from, text)),
239        Needle::Scope | Needle::SecondScope => unreachable!("resolved above"),
240    };
241    let at = scoped.or_else(|| find_token(src, 0, text))?;
242    Some(span(src, at, text.len()))
243}
244
245#[cfg(feature = "idl")]
246fn is_ident_char(c: char) -> bool {
247    c.is_ascii_alphanumeric() || c == '_'
248}
249
250/// The byte offset of the first whole-token occurrence of `needle` at or
251/// after `from`: one not glued to identifier characters on either side.
252#[cfg(feature = "idl")]
253fn find_token(src: &str, from: usize, needle: &str) -> Option<usize> {
254    if needle.is_empty() {
255        return None;
256    }
257    src.get(from..)?
258        .match_indices(needle)
259        .map(|(i, _)| from + i)
260        .find(|&at| {
261            let before = src[..at].chars().next_back();
262            let after = src[at + needle.len()..].chars().next();
263            !before.is_some_and(is_ident_char) && !after.is_some_and(is_ident_char)
264        })
265}
266
267/// The byte offset of the first occurrence of `name` at or after `from` that
268/// is the value of a `name` key, in YAML (`name: x`), JSON
269/// (`"name": "x"`), or TOML (`name = "x"`) syntax.
270#[cfg(feature = "idl")]
271fn find_decl(src: &str, from: usize, name: &str) -> Option<usize> {
272    let mut at = from;
273    loop {
274        let hit = find_token(src, at, name)?;
275        let before = src[..hit].trim_end_matches(['"', '\'']).trim_end();
276        if let Some(key) = before.strip_suffix([':', '=']) {
277            let key = key.trim_end().trim_end_matches(['"', '\'']);
278            if key
279                .strip_suffix("name")
280                .is_some_and(|rest| !rest.chars().next_back().is_some_and(is_ident_char))
281            {
282                return Some(hit);
283            }
284        }
285        at = hit + name.len();
286    }
287}
288
289/// The span of `len` bytes at `at`, widened to its surrounding quotes when
290/// the text is a quoted scalar.
291#[cfg(feature = "idl")]
292fn span(src: &str, at: usize, len: usize) -> SourceSpan {
293    let quote = src[..at]
294        .chars()
295        .next_back()
296        .filter(|c| matches!(c, '"' | '\''));
297    match quote {
298        Some(q) if src[at + len..].starts_with(q) => SourceSpan::new((at - 1).into(), len + 2),
299        _ => SourceSpan::new(at.into(), len),
300    }
301}