Skip to main content

specta_elm/
error.rs

1use std::{borrow::Cow, error, fmt, io, panic::Location, path::PathBuf};
2
3use specta::datatype::{NamedDataType, OpaqueReference, RecursiveInlineType};
4
5use crate::types::NDT;
6
7#[non_exhaustive]
8pub struct Error {
9    kind: ErrorKind,
10    named_datatype: Option<Box<NamedDataType>>,
11    trace: Vec<ErrorTraceFrame>,
12}
13
14/// Additional TypeScript exporter context for an [`Error`].
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub enum ErrorTraceFrame {
18    /// The exporter was rendering a core-provided inline reference at `path` when the error occurred.
19    Inlined {
20        /// The named Rust type being inlined, if it could be resolved.
21        named_datatype: Option<Box<NamedDataType>>,
22        /// Field, variant, or variant-field path where the inline expansion occurred.
23        path: String,
24    },
25}
26
27type FrameworkSource = Box<dyn error::Error + Send + Sync + 'static>;
28const BIGINT_DOCS_URL: &str =
29    "https://docs.rs/specta-typescript/latest/specta_typescript/struct.Error.html#bigint-forbidden";
30
31#[allow(dead_code)]
32enum ErrorKind {
33    /// A map key type cannot be represented as a valid Typescript index signature.
34    ///
35    /// Typescript map keys must resolve to string-like, number-like, symbol-like, or literal key
36    /// types. Complex structural values cannot safely be represented as object keys.
37    InvalidMapKey {
38        path: String,
39        reason: Cow<'static, str>,
40    },
41    /// Attempted to export a bigint type but the configuration forbids it.
42    BigIntForbidden { path: String },
43    /// A type's name conflicts with a reserved keyword in Typescript.
44    ForbiddenName { path: String, name: &'static str },
45    /// A type's name contains invalid characters or is not valid.
46    InvalidName {
47        path: String,
48        name: Cow<'static, str>,
49    },
50    /// A type's name is empty and cannot be emitted as a Typescript type name.
51    EmptyName { path: String },
52    /// Anonymous enum variants cannot be represented by the Typescript exporter.
53    UnsupportedAnonymousEnumVariant {
54        path: String,
55        variant_kind: &'static str,
56    },
57    /// Detected multiple items within the same scope with the same name.
58    /// Typescript doesn't support this so we error out.
59    ///
60    /// Using anything other than [Layout::FlatFile] should make this basically impossible.
61    DuplicateTypeName {
62        name: Cow<'static, str>,
63        first: String,
64        second: String,
65    },
66    /// An filesystem IO error.
67    /// This is possible when using `Typescript::export_to` when writing to a file or formatting the file.
68    Io(io::Error),
69    /// Failed to read a directory while exporting files.
70    ReadDir { path: PathBuf, source: io::Error },
71    /// Failed to inspect filesystem metadata while exporting files.
72    Metadata { path: PathBuf, source: io::Error },
73    /// Failed to remove a stale file while exporting files.
74    RemoveFile { path: PathBuf, source: io::Error },
75    /// Failed to remove an empty directory while exporting files.
76    RemoveDir { path: PathBuf, source: io::Error },
77    /// Failed to create an output directory while exporting files.
78    CreateDir { path: PathBuf, source: io::Error },
79    /// Failed to write an output file while exporting files.
80    WriteFile { path: PathBuf, source: io::Error },
81    /// Failed to read a generated file while exporting files.
82    ReadFile { path: PathBuf, source: io::Error },
83    /// Found an opaque reference which the Typescript exporter doesn't know how to handle.
84    /// You may be referencing a type which is not supported by the Typescript exporter.
85    UnsupportedOpaqueReference {
86        path: String,
87        reference: OpaqueReference,
88    },
89    /// Found a named reference that cannot be resolved from the provided
90    /// [`Types`](specta::Types).
91    DanglingNamedReference { path: String, reference: String },
92    /// Found a recursive named reference marked by core inline resolution.
93    InfiniteRecursiveInlineType {
94        path: String,
95        reference: String,
96        cycle: RecursiveInlineType,
97    },
98    /// Reached the recursion limit while rendering an anonymous Typescript type.
99    InlineRecursionLimitExceeded { path: String },
100    /// An error occurred in your exporter framework.
101    Framework {
102        message: Cow<'static, str>,
103        source: FrameworkSource,
104    },
105    /// An error occurred in a format callback.
106    Format {
107        message: Cow<'static, str>,
108        path: Option<String>,
109        source: FrameworkSource,
110    },
111    UnrepresentableAliasCycle {
112        path: String,
113        reason: Cow<'static, str>,
114    },
115}
116
117impl Error {
118    fn new(kind: ErrorKind) -> Self {
119        Self {
120            kind,
121            named_datatype: None,
122            trace: Vec::new(),
123        }
124    }
125
126    /// The named Rust type being exported when this error occurred, if known.
127    pub fn named_datatype(&self) -> Option<&NamedDataType> {
128        self.named_datatype.as_deref()
129    }
130
131    /// TypeScript exporter traversal context for this error.
132    pub fn trace(&self) -> &[ErrorTraceFrame] {
133        &self.trace
134    }
135
136    pub(crate) fn with_named_datatype(mut self, ndt: &NDT) -> Self {
137        self.named_datatype
138            .get_or_insert_with(|| Box::new(ndt.inner().clone()));
139        self
140    }
141
142    pub(crate) fn with_inline_trace(
143        mut self,
144        ndt: Option<&NamedDataType>,
145        path: impl Into<String>,
146    ) -> Self {
147        self.trace.push(ErrorTraceFrame::Inlined {
148            named_datatype: ndt.map(|ndt| Box::new(ndt.clone())),
149            path: path.into(),
150        });
151        self
152    }
153
154    pub(crate) fn invalid_map_key(
155        path: impl Into<String>,
156        reason: impl Into<Cow<'static, str>>,
157    ) -> Self {
158        Self::new(ErrorKind::InvalidMapKey {
159            path: path.into(),
160            reason: reason.into(),
161        })
162    }
163
164    /// Construct an error for framework-specific logic.
165    pub fn framework(
166        message: impl Into<Cow<'static, str>>,
167        source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
168    ) -> Self {
169        Self::new(ErrorKind::Framework {
170            message: message.into(),
171            source: source.into(),
172        })
173    }
174
175    /// Construct an error for custom format callbacks.
176    // pub(crate) fn format(
177    //     message: impl Into<Cow<'static, str>>,
178    //     source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
179    // ) -> Self {
180    //     Self::new(ErrorKind::Format {
181    //         message: message.into(),
182    //         path: None,
183    //         source: source.into(),
184    //     })
185    // }
186    //
187    // pub(crate) fn format_at(
188    //     message: impl Into<Cow<'static, str>>,
189    //     path: impl Into<String>,
190    //     source: impl Into<Box<dyn std::error::Error + Send + Sync>>,
191    // ) -> Self {
192    //     Self::new(ErrorKind::Format {
193    //         message: message.into(),
194    //         path: Some(path.into()),
195    //         source: source.into(),
196    //     })
197    // }
198
199    pub(crate) fn bigint_forbidden(path: String) -> Self {
200        Self::new(ErrorKind::BigIntForbidden { path })
201    }
202
203    pub(crate) fn invalid_name(path: String, name: impl Into<Cow<'static, str>>) -> Self {
204        Self::new(ErrorKind::InvalidName {
205            path,
206            name: name.into(),
207        })
208    }
209
210    pub(crate) fn empty_name(path: String) -> Self {
211        Self::new(ErrorKind::EmptyName { path })
212    }
213
214    pub(crate) fn unsupported_anonymous_enum_variant(
215        path: String,
216        variant_kind: &'static str,
217    ) -> Self {
218        Self::new(ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind })
219    }
220
221    pub(crate) fn forbidden_name(path: String, name: &'static str) -> Self {
222        Self::new(ErrorKind::ForbiddenName { path, name })
223    }
224
225    // pub(crate) fn duplicate_type_name(
226    //     name: Cow<'static, str>,
227    //     first: Location<'static>,
228    //     second: Location<'static>,
229    // ) -> Self {
230    //     Self::new(ErrorKind::DuplicateTypeName {
231    //         name,
232    //         first: format_location(first),
233    //         second: format_location(second),
234    //     })
235    // }
236
237    pub(crate) fn read_dir(path: PathBuf, source: io::Error) -> Self {
238        Self::new(ErrorKind::ReadDir { path, source })
239    }
240
241    pub(crate) fn metadata(path: PathBuf, source: io::Error) -> Self {
242        Self::new(ErrorKind::Metadata { path, source })
243    }
244
245    pub(crate) fn remove_file(path: PathBuf, source: io::Error) -> Self {
246        Self::new(ErrorKind::RemoveFile { path, source })
247    }
248
249    pub(crate) fn remove_dir(path: PathBuf, source: io::Error) -> Self {
250        Self::new(ErrorKind::RemoveDir { path, source })
251    }
252
253    pub(crate) fn create_dir(path: PathBuf, source: io::Error) -> Self {
254        Self::new(ErrorKind::CreateDir { path, source })
255    }
256
257    pub(crate) fn write_file(path: PathBuf, source: io::Error) -> Self {
258        Self::new(ErrorKind::WriteFile { path, source })
259    }
260
261    pub(crate) fn read_file(path: PathBuf, source: io::Error) -> Self {
262        Self::new(ErrorKind::ReadFile { path, source })
263    }
264
265    pub(crate) fn unsupported_opaque_reference(path: String, reference: OpaqueReference) -> Self {
266        Self::new(ErrorKind::UnsupportedOpaqueReference { path, reference })
267    }
268
269    pub(crate) fn dangling_named_reference(path: String, reference: String) -> Self {
270        Self::new(ErrorKind::DanglingNamedReference { path, reference })
271    }
272
273    pub(crate) fn infinite_recursive_inline_type(
274        path: String,
275        reference: String,
276        cycle: RecursiveInlineType,
277    ) -> Self {
278        Self::new(ErrorKind::InfiniteRecursiveInlineType {
279            path,
280            reference,
281            cycle,
282        })
283    }
284
285    // pub(crate) fn inline_recursion_limit_exceeded(path: String) -> Self {
286    //     Self::new(ErrorKind::InlineRecursionLimitExceeded { path })
287    // }
288    //
289    // pub(crate) fn unrepresentable_alias_cycle(
290    //     path: String,
291    //     reason: impl Into<Cow<'static, str>>,
292    // ) -> Self {
293    //     Self::new(ErrorKind::UnrepresentableAliasCycle {
294    //         path,
295    //         reason: reason.into(),
296    //     })
297    // }
298}
299
300impl From<io::Error> for Error {
301    fn from(error: io::Error) -> Self {
302        Self::new(ErrorKind::Io(error))
303    }
304}
305
306impl fmt::Display for Error {
307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308        match &self.kind {
309            ErrorKind::InvalidMapKey { path, reason } => {
310                write!(f, "Invalid map key at '{path}': {reason}")
311            }
312            ErrorKind::BigIntForbidden { path } => write!(
313                f,
314                "Attempted to export {path:?} but Specta forbids exporting BigInt-style types (usize, isize, i64, u64, i128, u128) to avoid precision loss. See {BIGINT_DOCS_URL} for a full explanation."
315            ),
316            ErrorKind::ForbiddenName { path, name } => write!(
317                f,
318                "Attempted to export {} but was unable to due to name {name:?} conflicting with a reserved keyword in Typescript. Try renaming it or using `#[specta(rename = \"new name\")]`",
319                display_path(path)
320            ),
321            ErrorKind::InvalidName { path, name } => write!(
322                f,
323                "Attempted to export {} but was unable to due to name {name:?} containing an invalid character. Try renaming it or using `#[specta(rename = \"new name\")]`",
324                display_path(path)
325            ),
326            ErrorKind::EmptyName { path } => write!(
327                f,
328                "Attempted to export {} but was unable to because the Typescript type name is empty. Try renaming it or using `#[specta(rename = \"new name\")]`",
329                display_path(path)
330            ),
331            ErrorKind::UnsupportedAnonymousEnumVariant { path, variant_kind } => write!(
332                f,
333                "Attempted to export {} but anonymous {variant_kind} enum variants cannot be exported to Typescript. Try giving the variant a name or changing the enum representation.",
334                display_path(path)
335            ),
336            ErrorKind::DuplicateTypeName {
337                name,
338                first,
339                second,
340            } => write!(
341                f,
342                "Detected multiple types with the same name: {name:?} at {first} and {second}"
343            ),
344            ErrorKind::Io(err) => write!(f, "IO error: {err}"),
345            ErrorKind::ReadDir { path, source } => {
346                write!(f, "Failed to read directory '{}': {source}", path.display())
347            }
348            ErrorKind::Metadata { path, source } => {
349                write!(
350                    f,
351                    "Failed to read metadata for '{}': {source}",
352                    path.display()
353                )
354            }
355            ErrorKind::RemoveFile { path, source } => {
356                write!(f, "Failed to remove file '{}': {source}", path.display())
357            }
358            ErrorKind::RemoveDir { path, source } => {
359                write!(
360                    f,
361                    "Failed to remove directory '{}': {source}",
362                    path.display()
363                )
364            }
365            ErrorKind::CreateDir { path, source } => {
366                write!(
367                    f,
368                    "Failed to create directory '{}': {source}",
369                    path.display()
370                )
371            }
372            ErrorKind::WriteFile { path, source } => {
373                write!(f, "Failed to write file '{}': {source}", path.display())
374            }
375            ErrorKind::ReadFile { path, source } => {
376                write!(f, "Failed to read file '{}': {source}", path.display())
377            }
378            ErrorKind::UnsupportedOpaqueReference { path, reference } => write!(
379                f,
380                "Found unsupported opaque reference '{}' at {}. It is not supported by the Elm exporter.",
381                reference.type_name(),
382                display_path(path)
383            ),
384            ErrorKind::DanglingNamedReference { path, reference } => write!(
385                f,
386                "Found dangling named reference {reference} at {}. The referenced type is missing from the resolved type collection.",
387                display_path(path)
388            ),
389            ErrorKind::InfiniteRecursiveInlineType {
390                path,
391                reference,
392                cycle,
393            } => {
394                write!(
395                    f,
396                    "Found infinitely recursive inline named reference {reference} at {}. Recursive inline types cannot be expanded because they would produce an infinite Elm type.",
397                    display_path(path)
398                )?;
399                write!(f, "\nInline cycle:\n  {cycle:?}")?;
400                Ok(())
401            }
402            ErrorKind::InlineRecursionLimitExceeded { path } if path.is_empty() => write!(
403                f,
404                "Type recursion limit exceeded while expanding the provided inline type. Recursive inline types cannot be expanded because they would produce an infinite Typescript type."
405            ),
406            ErrorKind::InlineRecursionLimitExceeded { path } => write!(
407                f,
408                "Type recursion limit exceeded while expanding an inline Typescript type at {}. Recursive inline types cannot be expanded because they would produce an infinite Typescript type.",
409                display_path(path)
410            ),
411            ErrorKind::Framework { message, source } => {
412                let source = source.to_string();
413                if message.is_empty() && source.is_empty() {
414                    write!(f, "Framework error")
415                } else if source.is_empty() {
416                    write!(f, "Framework error: {message}")
417                } else {
418                    write!(f, "Framework error: {message}: {source}")
419                }
420            }
421            ErrorKind::Format {
422                message,
423                path,
424                source,
425            } => {
426                let source = source.to_string();
427                let location = path
428                    .as_deref()
429                    .filter(|path| !path.is_empty())
430                    .map(|path| format!(" at {}", display_path(path)))
431                    .unwrap_or_default();
432                if message.is_empty() && source.is_empty() {
433                    write!(f, "Format error{location}")
434                } else if source.is_empty() {
435                    write!(f, "Format error{location}: {message}")
436                } else {
437                    write!(f, "Format error{location}: {message}: {source}")
438                }
439            }
440            ErrorKind::UnrepresentableAliasCycle { path, reason } => write!(
441                f,
442                "Attempted to export {} but it is part of a recursive type-alias cycle that cannot be collapsed into valid TypeScript: {reason}. TypeScript rejects self-referential type aliases (TS2456); restructure the type so the recursion passes through an object, array, or tuple instead of a bare (untagged/transparent) reference.",
443                display_path(path)
444            ),
445        }?;
446
447        if let Some(ndt) = self.named_datatype() {
448            write!(
449                f,
450                "\nRust type: {}::{} at {}",
451                ndt.module_path,
452                ndt.name,
453                format_location(ndt.location)
454            )?;
455        }
456
457        if !self.trace.is_empty() {
458            write!(f, "\nWhile inlining:")?;
459            for frame in self.trace.iter().rev() {
460                match frame {
461                    ErrorTraceFrame::Inlined {
462                        named_datatype,
463                        path,
464                    } => {
465                        write!(f, "\n  {path} -> ")?;
466                        if let Some(ndt) = named_datatype.as_deref() {
467                            write!(f, "{}::{}", ndt.module_path, ndt.name)?;
468                        } else {
469                            write!(f, "<unresolved named type>")?;
470                        }
471                    }
472                }
473            }
474        }
475
476        Ok(())
477    }
478}
479
480impl fmt::Debug for Error {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        fmt::Display::fmt(self, f)
483    }
484}
485
486impl error::Error for Error {
487    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
488        match &self.kind {
489            ErrorKind::Io(error) => Some(error),
490            ErrorKind::ReadDir { source, .. }
491            | ErrorKind::Metadata { source, .. }
492            | ErrorKind::RemoveFile { source, .. }
493            | ErrorKind::RemoveDir { source, .. }
494            | ErrorKind::CreateDir { source, .. }
495            | ErrorKind::WriteFile { source, .. }
496            | ErrorKind::ReadFile { source, .. } => Some(source),
497            ErrorKind::Framework { source, .. } | ErrorKind::Format { source, .. } => {
498                Some(source.as_ref())
499            }
500            _ => None,
501        }
502    }
503}
504
505fn format_location(location: Location<'static>) -> String {
506    format!(
507        "{}:{}:{}",
508        location.file(),
509        location.line(),
510        location.column()
511    )
512}
513
514fn display_path(path: &str) -> Cow<'_, str> {
515    if path.is_empty() {
516        Cow::Borrowed("<unknown path>")
517    } else {
518        Cow::Owned(format!("{path:?}"))
519    }
520}