Skip to main content

shuyi_assets/
error.rs

1//! Content errors: every problem names the file and, when known, the line and
2//! column, so a broken asset can be fixed without reading Rust. An error of
3//! a class the tools know carries its problem [`Code`] ([`codes`]), which
4//! `shuyi explain` prints the page of.
5use crate::prelude::*;
6
7use core::fmt;
8
9/// A problem code: `SH` and four digits, never reused or renumbered
10/// (ADR-0028). The content codes are in [`codes`]; the tools' own are in
11/// `shuyi_cli::contract::codes`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
13pub struct Code(pub &'static str);
14
15impl fmt::Display for Code {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        f.write_str(self.0)
18    }
19}
20
21/// The content codes (`SH2nnn`): a file of a game's content has a
22/// problem. Each has a row and a page in `shuyi_cli::contract::codes`.
23pub mod codes {
24    use super::Code;
25
26    /// `parse`: a text file's syntax is wrong.
27    pub const PARSE: Code = Code("SH2001");
28    /// `not-text`: a text format's file isn't valid UTF-8.
29    pub const NOT_TEXT: Code = Code("SH2002");
30    /// `missing-file`: a file asked for isn't in the bundle.
31    pub const MISSING_FILE: Code = Code("SH2003");
32    /// `ambiguous-file`: several files could be the asset asked for.
33    pub const AMBIGUOUS_FILE: Code = Code("SH2004");
34    /// `no-format`: no format of the kind reads the file.
35    pub const NO_FORMAT: Code = Code("SH2005");
36    /// `too-big`: the file is bigger than its format reads.
37    pub const TOO_BIG: Code = Code("SH2006");
38    /// `pack-differs`: a baked pack differs from its sources.
39    pub const PACK_DIFFERS: Code = Code("SH2007");
40    /// `unknown-name`: a name the file uses isn't one it may: an action,
41    /// a key or button, a terrain, a tile character.
42    pub const UNKNOWN_NAME: Code = Code("SH2008");
43    /// `duplicate`: something the file may give once is given twice.
44    pub const DUPLICATE: Code = Code("SH2009");
45    /// `out-of-range`: a number or a size is outside what it may be.
46    pub const OUT_OF_RANGE: Code = Code("SH2010");
47}
48
49/// One problem found while loading or validating an asset. Build it with
50/// [`ContentError::new`] and its methods (ADR-0039).
51#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
52#[non_exhaustive]
53pub struct ContentError {
54    /// Human-facing file path, e.g. `base/data/colors.ron`.
55    pub file: String,
56    /// 1-based line, if known.
57    pub line: Option<u32>,
58    /// 1-based column, if known (only shown together with a line).
59    pub column: Option<u32>,
60    /// What is wrong, in plain words.
61    pub message: String,
62    /// Its problem code, if its class has one ([`codes`]).
63    pub code: Option<Code>,
64}
65
66impl ContentError {
67    /// An error about a whole file (no position).
68    pub fn new(file: impl Into<String>, message: impl Into<String>) -> Self {
69        Self {
70            file: file.into(),
71            line: None,
72            column: None,
73            message: message.into(),
74            code: None,
75        }
76    }
77
78    /// The error that `file` isn't in the bundle
79    /// ([`NOT_FOUND`](crate::format::NOT_FOUND), [`codes::MISSING_FILE`]).
80    pub fn not_found(file: impl Into<String>) -> Self {
81        Self::new(file, crate::format::NOT_FOUND).with_code(codes::MISSING_FILE)
82    }
83
84    /// Attaches a 1-based line and optional column.
85    #[must_use]
86    pub fn at(mut self, line: u32, column: Option<u32>) -> Self {
87        self.line = Some(line);
88        self.column = column;
89        self
90    }
91
92    /// Gives it problem code `code`.
93    #[must_use]
94    pub fn with_code(mut self, code: Code) -> Self {
95        self.code = Some(code);
96        self
97    }
98}
99
100impl fmt::Display for ContentError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{}", self.file)?;
103        if let Some(line) = self.line {
104            write!(f, ":{line}")?;
105            if let Some(column) = self.column {
106                write!(f, ":{column}")?;
107            }
108        }
109        write!(f, ": {}", self.message)
110    }
111}
112
113/// Every problem found in one load, reported together.
114#[derive(Debug, Clone, PartialEq, Eq, Default, thiserror::Error)]
115pub struct ContentErrors(pub Vec<ContentError>);
116
117impl fmt::Display for ContentErrors {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        let n = self.0.len();
120        write!(f, "{n} content error{}:", if n == 1 { "" } else { "s" })?;
121        for e in &self.0 {
122            write!(f, "\n  {e}")?;
123        }
124        Ok(())
125    }
126}
127
128/// 1-based line of the first line containing `needle`.
129pub fn line_of(source: &str, needle: &str) -> Option<u32> {
130    let index = source.lines().position(|l| l.contains(needle))?;
131    u32::try_from(index + 1).ok()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn display_with_line_and_column() {
140        let e =
141            ContentError::new("base/data/colors.ron", "unknown colour \"plyer\"").at(12, Some(5));
142        assert_eq!(
143            e.to_string(),
144            "base/data/colors.ron:12:5: unknown colour \"plyer\""
145        );
146    }
147
148    #[test]
149    fn display_with_line_only() {
150        let e = ContentError::new("a.ron", "bad").at(3, None);
151        assert_eq!(e.to_string(), "a.ron:3: bad");
152    }
153
154    #[test]
155    fn display_without_position() {
156        let e = ContentError::new("a.ron", "missing file");
157        assert_eq!(e.to_string(), "a.ron: missing file");
158    }
159
160    #[test]
161    fn display_lists_all_errors() {
162        let errs = ContentErrors(vec![
163            ContentError::new("a.ron", "one").at(1, Some(2)),
164            ContentError::new("b.ron", "two"),
165        ]);
166        assert_eq!(
167            errs.to_string(),
168            "2 content errors:\n  a.ron:1:2: one\n  b.ron: two"
169        );
170        let single = ContentErrors(vec![ContentError::new("a.ron", "one")]);
171        assert_eq!(single.to_string(), "1 content error:\n  a.ron: one");
172    }
173
174    #[test]
175    fn a_code_leaves_the_text_as_it_is() {
176        let e = ContentError::new("a.ron", "bad").at(1, Some(2));
177        assert_eq!(e.code, None);
178        let coded = e.clone().with_code(codes::PARSE);
179        assert_eq!(coded.code, Some(Code("SH2001")));
180        assert_eq!(coded.to_string(), e.to_string());
181        assert_eq!(codes::PARSE.to_string(), "SH2001");
182        let missing = ContentError::not_found("b.png");
183        assert_eq!(missing.to_string(), "b.png: file not found in asset bundle");
184        assert_eq!(missing.code, Some(codes::MISSING_FILE));
185    }
186
187    #[test]
188    fn line_of_finds_first_match() {
189        assert_eq!(line_of("a\nbb\nb", "b"), Some(2));
190        assert_eq!(line_of("a", "z"), None);
191    }
192}