Skip to main content

tilewright_source/
diagnostic.rs

1// Copyright 2026 Seraphina Studios
2// SPDX-License-Identifier: MIT
3
4use std::{cell::Cell, fmt, rc::Rc};
5use tilewright_jsonc::ErrorCode;
6
7/// Anything with a stable diagnostic code string.
8pub(crate) trait CodeName: Copy {
9    fn name(self) -> &'static str;
10}
11
12impl CodeName for Code {
13    fn name(self) -> &'static str {
14        self.as_str()
15    }
16}
17
18impl CodeName for ErrorCode {
19    fn name(self) -> &'static str {
20        self.as_str()
21    }
22}
23
24/// Stable diagnostic codes. JSONC syntax errors use the parser's own codes.
25#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
26pub enum Code {
27    /// A file could not be read.
28    Io,
29    /// A file is not UTF-8.
30    Encoding,
31    /// A load exceeded its file or byte budget.
32    Budget,
33    /// A symbolic link where the format expects a file or directory.
34    Symlink,
35    /// A file or directory the format does not define.
36    UnexpectedFile,
37    /// A required file is missing.
38    MissingFile,
39    /// A path in a document is not a safe relative path.
40    Path,
41    /// A file name disagrees with the document's content.
42    NameMismatch,
43    /// A `format` value other than the one the document kind requires.
44    Format,
45    /// A key the format or schema does not define.
46    UnknownKey,
47    /// A required key is missing.
48    MissingKey,
49    /// A value of the wrong JSON type.
50    Type,
51    /// A value outside its allowed range or set.
52    Range,
53    /// An identifier that does not match the identifier pattern.
54    Identifier,
55    /// Two entries with the same identity.
56    Duplicate,
57    /// A reference that does not resolve.
58    Reference,
59    /// Zone parents that form a cycle.
60    Cycle,
61    /// A malformed terrain grid.
62    Grid,
63    /// A stairwell step that is not on the layer next to the step before
64    /// it, in the stairwell's direction, or that moves more than one tile
65    /// on an axis.
66    StairwellStep,
67    /// A tile that holds steps of two stairwells.
68    StairwellOverlap,
69}
70
71impl Code {
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Io => "source.io",
76            Self::Encoding => "source.encoding",
77            Self::Budget => "source.budget",
78            Self::Symlink => "source.symlink",
79            Self::UnexpectedFile => "source.unexpected_file",
80            Self::MissingFile => "source.missing_file",
81            Self::Path => "source.path",
82            Self::NameMismatch => "source.name_mismatch",
83            Self::Format => "shape.format",
84            Self::UnknownKey => "shape.unknown_key",
85            Self::MissingKey => "shape.missing_key",
86            Self::Type => "shape.type",
87            Self::Range => "shape.range",
88            Self::Identifier => "shape.identifier",
89            Self::Duplicate => "ref.duplicate",
90            Self::Reference => "ref.unresolved",
91            Self::Cycle => "ref.cycle",
92            Self::Grid => "grid.invalid",
93            Self::StairwellStep => "stairwell.step",
94            Self::StairwellOverlap => "stairwell.overlap",
95        }
96    }
97}
98
99/// One problem in a source tree.
100///
101/// `start..end` is the byte span in the file; `line` and `column` are
102/// 1-based and both 0 when the problem has no position, such as a missing
103/// file.
104#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
105pub struct Diagnostic {
106    pub path: String,
107    pub start: usize,
108    pub end: usize,
109    pub line: usize,
110    pub column: usize,
111    pub code: &'static str,
112    pub message: String,
113}
114
115impl Diagnostic {
116    /// A diagnostic without a position.
117    pub(crate) fn file(path: &str, code: impl CodeName, message: impl Into<String>) -> Self {
118        Self {
119            path: path.to_owned(),
120            start: 0,
121            end: 0,
122            line: 0,
123            column: 0,
124            code: code.name(),
125            message: message.into(),
126        }
127    }
128
129    /// A diagnostic at a byte span of the text `positions` indexes.
130    pub(crate) fn at(
131        path: &str,
132        positions: &Positions<'_>,
133        start: usize,
134        end: usize,
135        code: impl CodeName,
136        message: impl Into<String>,
137    ) -> Self {
138        let (line, column) = positions.locate(start);
139        Self {
140            path: path.to_owned(),
141            start,
142            end,
143            line,
144            column,
145            code: code.name(),
146            message: message.into(),
147        }
148    }
149}
150
151/// Bytes between character-count checkpoints in [`Positions`].
152const STRIDE: usize = 256;
153
154/// Whether `byte` continues a UTF-8 character rather than starting one.
155const fn is_continuation(byte: u8) -> bool {
156    byte & 0xC0 == 0x80
157}
158
159/// Line and column lookup for one text. Building it reads the text once;
160/// each lookup is then a binary search and a scan of at most [`STRIDE`]
161/// bytes, so a document with many diagnostics is not rescanned for each.
162pub(crate) struct Positions<'s> {
163    source: &'s str,
164    /// The byte offset where each line starts.
165    lines: Vec<usize>,
166    /// Characters before byte `i * STRIDE`, for every such offset up to and
167    /// including the end of the text.
168    characters: Vec<usize>,
169}
170
171impl<'s> Positions<'s> {
172    pub(crate) fn new(source: &'s str) -> Self {
173        let mut lines = vec![0];
174        let mut characters = Vec::with_capacity(source.len() / STRIDE + 1);
175        let mut count = 0;
176        for (index, byte) in source.bytes().enumerate() {
177            if index.is_multiple_of(STRIDE) {
178                characters.push(count);
179            }
180            if byte == b'\n' {
181                lines.push(index + 1);
182            }
183            if !is_continuation(byte) {
184                count += 1;
185            }
186        }
187        if source.len().is_multiple_of(STRIDE) {
188            characters.push(count);
189        }
190        Self {
191            source,
192            lines,
193            characters,
194        }
195    }
196
197    /// Characters before `offset`, a character boundary within the text.
198    fn characters_before(&self, offset: usize) -> usize {
199        let block = offset / STRIDE;
200        self.characters[block]
201            + self.source.as_bytes()[block * STRIDE..offset]
202                .iter()
203                .filter(|byte| !is_continuation(**byte))
204                .count()
205    }
206
207    /// The 1-based line and column, in characters, of `offset`, clamped to
208    /// the text and moved back to a character boundary.
209    pub(crate) fn locate(&self, offset: usize) -> (usize, usize) {
210        let mut boundary = offset.min(self.source.len());
211        while !self.source.is_char_boundary(boundary) {
212            boundary -= 1;
213        }
214        let line = self.lines.partition_point(|start| *start <= boundary);
215        let start = self.lines[line - 1];
216        (
217            line,
218            self.characters_before(boundary) - self.characters_before(start) + 1,
219        )
220    }
221}
222
223/// The diagnostics of one load. At most `maximum` are kept, so hostile input
224/// cannot grow the list without bound; past that, one `source.budget`
225/// diagnostic notes that the rest were dropped.
226pub(crate) struct Diagnostics {
227    items: Vec<Diagnostic>,
228    maximum: usize,
229    dropped: bool,
230    /// Whether reading stopped at the maximum, so that problems there may
231    /// be were not looked for.
232    skipped: bool,
233    /// The diagnostics kept by every collection of one load, shared, so
234    /// that the load stops reading once their sum reaches the maximum:
235    /// nothing more could be reported.
236    total: Rc<Cell<usize>>,
237}
238
239impl Diagnostics {
240    pub(crate) fn new(maximum: usize) -> Self {
241        Self::sharing(maximum, Rc::new(Cell::new(0)))
242    }
243
244    /// A collection that shares its count with others of the same load.
245    pub(crate) const fn sharing(maximum: usize, total: Rc<Cell<usize>>) -> Self {
246        Self {
247            items: Vec::new(),
248            maximum,
249            dropped: false,
250            skipped: false,
251            total,
252        }
253    }
254
255    /// The count shared by the collections of this load.
256    pub(crate) fn total(&self) -> Rc<Cell<usize>> {
257        Rc::clone(&self.total)
258    }
259
260    /// Whether the collections of this load together hold the maximum.
261    pub(crate) fn is_full(&self) -> bool {
262        self.total.get() >= self.maximum
263    }
264
265    pub(crate) fn push(&mut self, diagnostic: Diagnostic) {
266        if self.is_full() {
267            self.dropped = true;
268        } else {
269            self.items.push(diagnostic);
270            self.total.set(self.total.get() + 1);
271        }
272    }
273
274    pub(crate) const fn is_empty(&self) -> bool {
275        self.items.is_empty() && !self.dropped && !self.skipped
276    }
277
278    /// Notes that reading stopped because the collections are full.
279    pub(crate) const fn skip(&mut self) {
280        self.skipped = true;
281    }
282
283    /// Takes in another collection of the same load, whose diagnostics are
284    /// already counted.
285    pub(crate) fn append(&mut self, other: Self) {
286        self.items.extend(other.items);
287        self.dropped |= other.dropped;
288        self.skipped |= other.skipped;
289    }
290
291    /// The diagnostics sorted by path and position, without duplicates.
292    pub(crate) fn finish(self) -> Vec<Diagnostic> {
293        let mut items = self.items;
294        if self.dropped {
295            items.push(Diagnostic::file(
296                ".",
297                Code::Budget,
298                format!(
299                    "more than {} problems; the rest are not reported",
300                    self.maximum
301                ),
302            ));
303        } else if self.skipped {
304            items.push(Diagnostic::file(
305                ".",
306                Code::Budget,
307                format!(
308                    "{} problems, as many as are reported; reading stopped there, so there may be more",
309                    self.maximum
310                ),
311            ));
312        }
313        items.sort();
314        items.dedup();
315        items
316    }
317}
318
319impl fmt::Display for Diagnostic {
320    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321        if self.line == 0 {
322            write!(formatter, "{}: {}: {}", self.path, self.code, self.message)
323        } else {
324            write!(
325                formatter,
326                "{}:{}:{}: {}: {}",
327                self.path, self.line, self.column, self.code, self.message
328            )
329        }
330    }
331}