Skip to main content

limu/
errors.rs

1//! The errors of a diagram's text, and the edits that map a place in the text a parser reads
2//! back to where its author wrote it, as Pele's src/errors.ts has them.
3
4use std::cell::RefCell;
5use std::fmt;
6use std::rc::Rc;
7
8use crate::detect::{Detected, DiagramType};
9use crate::js::chars::{is_word, len16, trim_end};
10
11/// What kind of error an [`Error`] is, as Pele's PeleErrorCode says.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub enum ErrorCode {
14    /// Text of no diagram type known, or of a type not supported.
15    UnsupportedDiagram,
16    /// Text that does not parse.
17    Syntax,
18    /// Text that parses but asks for what cannot be, as a shape of no such name.
19    Semantic,
20    /// Text past a limit, as of its length or of a flowchart's edges.
21    Limit,
22}
23
24impl ErrorCode {
25    /// The code as Pele writes it, as "unsupported-diagram".
26    pub fn as_str(self) -> &'static str {
27        match self {
28            ErrorCode::UnsupportedDiagram => "unsupported-diagram",
29            ErrorCode::Syntax => "syntax",
30            ErrorCode::Semantic => "semantic",
31            ErrorCode::Limit => "limit",
32        }
33    }
34}
35
36/// An error in a diagram's text, as Pele's PeleError is.
37#[derive(Clone, Debug, PartialEq)]
38#[non_exhaustive]
39pub struct Error {
40    /// What kind of error it is.
41    pub code: ErrorCode,
42    /// What is wrong, as Pele words it.
43    pub message: String,
44    /// The type of the diagram the error is in, where it is known.
45    pub diagram: Option<DiagramType>,
46    /// That type as Mermaid names it, as "flowchart-v2", where it is known.
47    pub mermaid_type: Option<&'static str>,
48    /// The line of the text the error is on, from 1, or 0 where it has no place.
49    pub line: usize,
50    /// The column of the line, from 1, counted in UTF-16 code units as JavaScript counts
51    /// them, or 0 where the error has no place.
52    pub column: usize,
53    /// The line, and a caret under the column, or nothing where the error has no place.
54    pub snippet: String,
55}
56
57impl Error {
58    /// An error with no place.
59    pub(crate) fn new(message: impl Into<String>, code: ErrorCode) -> Error {
60        Error {
61            code,
62            message: message.into(),
63            diagram: None,
64            mermaid_type: None,
65            line: 0,
66            column: 0,
67            snippet: String::new(),
68        }
69    }
70
71    /// The error, of a diagram of a type found.
72    pub(crate) fn of(mut self, found: Detected) -> Error {
73        self.diagram = Some(found.diagram);
74        self.mermaid_type = Some(found.mermaid_type);
75        self
76    }
77
78    /// Gives the error the diagram's type where it has none, and writes the entity codes in
79    /// its message and snippet as the text has them, not as the parser read them, as Pele's
80    /// tagErrors does for the errors of a diagram's parser and of its layout.
81    pub(crate) fn tagged(mut self, found: Detected) -> Error {
82        self.diagram.get_or_insert(found.diagram);
83        self.mermaid_type.get_or_insert(found.mermaid_type);
84        self.message = written_codes(&self.message);
85        self.snippet = written_codes(&self.snippet);
86        self
87    }
88}
89
90impl Error {
91    /// The byte of src the error is at, where src is the text it was found in, or none where
92    /// the error has no place, or src has no such line and column, or the column falls
93    /// between the two code units of a character. It turns the column, which is counted in
94    /// UTF-16 code units, into a byte for a host that slices src.
95    pub fn byte_offset(&self, src: &str) -> Option<usize> {
96        if self.line == 0 || self.column == 0 {
97            return None;
98        }
99        let mut start = 0;
100        for _ in 1..self.line {
101            start += src[start..].find('\n')? + 1;
102        }
103        let line = &src[start..];
104        let line = &line[..line.find('\n').unwrap_or(line.len())];
105        let mut units = self.column - 1;
106        for (i, c) in line.char_indices() {
107            if units < c.len_utf16() {
108                return (units == 0).then_some(start + i);
109            }
110            units -= c.len_utf16();
111        }
112        (units == 0).then_some(start + line.len())
113    }
114}
115
116impl fmt::Display for Error {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.message)
119    }
120}
121
122impl std::error::Error for Error {}
123
124/// Pele's Edit: the bytes from..to of a text replaced by text. Pele records the length of
125/// the replacement in code units. An edit keeps the replacement itself, so that a place
126/// inside it is counted in code units as Pele counts it.
127#[derive(Clone, Debug)]
128pub(crate) struct Edit {
129    pub(crate) from: usize,
130    pub(crate) to: usize,
131    pub(crate) text: String,
132}
133
134impl Edit {
135    /// An edit that takes the bytes from..to out.
136    pub(crate) fn cut(from: usize, to: usize) -> Edit {
137        Edit {
138            from,
139            to,
140            text: String::new(),
141        }
142    }
143}
144
145/// The edits of the passes over a text, each pass's in the order they were made, or none
146/// where nothing is recorded.
147pub(crate) type Steps<'a> = Option<&'a mut Vec<Vec<Edit>>>;
148
149/// Adds the edits of a pass to steps, if they are recorded. A pass adds its edits once it is
150/// done, where Pele's recording adds them as it begins. No pass adds while another is under
151/// way, so the steps are in the same order.
152pub(crate) fn push(steps: &mut Steps, edits: Vec<Edit>) {
153    if let Some(steps) = steps {
154        steps.push(edits);
155    }
156}
157
158/// Maps byte at of the text edits made back to the text they were made in, as Pele's
159/// stepInput does. A place inside a replacement maps to as many code units into what it
160/// replaced, or to the last of those.
161fn step_input(edits: &[Edit], at: usize) -> usize {
162    let mut shift: isize = 0;
163    for e in edits {
164        let start = (e.from as isize - shift) as usize;
165        if at < start {
166            break;
167        }
168        if at < start + e.text.len() {
169            return e.from + len16(&e.text[..at - start]).min(e.to - e.from - 1);
170        }
171        shift += e.to as isize - e.from as isize - e.text.len() as isize;
172    }
173    (at as isize + shift) as usize
174}
175
176/// Maps byte at of the text the last of steps made back to the text the first was made in,
177/// as Pele's inputOffset does.
178pub(crate) fn input_offset(steps: &[Vec<Edit>], at: usize) -> usize {
179    steps
180        .iter()
181        .rev()
182        .fold(at, |at, edits| step_input(edits, at))
183}
184
185/// Writes each entity code that Pele's encodeEntities hid behind placeholders, as `fl°°35¶ß`
186/// or `fl°quot¶ß`, back as its author wrote it, `#35;` or `#quot;`, as Pele's writtenCodes
187/// does with `text.replace(/fl°°?(\w+)¶ß/g, '#$1;')`.
188pub(crate) fn written_codes(text: &str) -> String {
189    const OPEN: &str = "fl°";
190    const CLOSE: &str = "¶ß";
191    if !text.contains(CLOSE) {
192        return text.to_string();
193    }
194    let mut out = String::new();
195    let (mut last, mut i) = (0, 0);
196    while let Some(n) = text[i..].find(OPEN) {
197        let at = i + n;
198        let mut j = at + OPEN.len();
199        if text[j..].starts_with('°') {
200            j += '°'.len_utf8();
201        }
202        let k = j + text[j..]
203            .bytes()
204            .take_while(|&c| is_word(i32::from(c)))
205            .count();
206        if k > j && text[k..].starts_with(CLOSE) {
207            out.push_str(&text[last..at]);
208            out.push('#');
209            out.push_str(&text[j..k]);
210            out.push(';');
211            last = k + CLOSE.len();
212            i = last;
213            continue;
214        }
215        // With no word after `fl°°`, the expression backtracks to take the second `°` as no
216        // part of it, and no word starts there either.
217        i = at + OPEN.len();
218    }
219    out.push_str(&text[last..]);
220    out
221}
222
223/// A text a diagram's parser is reading, and what finds a byte of it in the text its author
224/// wrote, as Pele's Origin has them.
225pub(crate) struct Origin {
226    pub(crate) parsed: Rc<str>,
227    pub(crate) find: Box<dyn Fn(usize) -> (String, usize)>,
228}
229
230thread_local! {
231    /// The origin of the text being parsed on this thread. Pele keeps one origin in a variable
232    /// while its parser runs, and a thread runs one parser at a time.
233    static ORIGIN: RefCell<Option<Rc<Origin>>> = const { RefCell::new(None) };
234}
235
236/// Runs run with the errors it locates in next's text reporting where they are in the text
237/// its author wrote, as Pele's locatingIn does.
238pub(crate) fn locating_in<T>(next: Origin, run: impl FnOnce() -> T) -> T {
239    /// Puts the outer origin back, however run ends.
240    struct Restore(Option<Rc<Origin>>);
241    impl Drop for Restore {
242        fn drop(&mut self) {
243            let outer = self.0.take();
244            ORIGIN.with(|o| *o.borrow_mut() = outer);
245        }
246    }
247    let outer = ORIGIN.with(|o| o.borrow_mut().replace(Rc::new(next)));
248    let _restore = Restore(outer);
249    run()
250}
251
252/// Finds byte offset of src in the text its author wrote, as Pele's written does: where src
253/// is the text being parsed, the text as written and the byte in it, and otherwise src and
254/// offset as they are.
255pub(crate) fn written(src: &str, offset: usize) -> (String, usize) {
256    match ORIGIN.with(|o| o.borrow().clone()) {
257        Some(origin) if *origin.parsed == *src => (origin.find)(offset),
258        _ => (src.to_string(), offset),
259    }
260}
261
262/// A place in a text: its line and column, counted from 1, the column in UTF-16 code units,
263/// and its line with a caret under the column.
264#[derive(Clone, Debug, PartialEq, Eq)]
265pub(crate) struct Location {
266    pub(crate) line: usize,
267    pub(crate) column: usize,
268    pub(crate) snippet: String,
269}
270
271/// The place of byte offset in src, as Pele's locate finds that of a code unit. An offset at
272/// or past the end of what src holds but white space is put after its last character, and a
273/// place in the text being parsed is found in the text its author wrote.
274pub(crate) fn locate(src: &str, offset: usize) -> Location {
275    // Keep errors at the end on the last line that holds anything.
276    let end = trim_end(src).len();
277    let (src, mut offset) = if end > 0 && offset >= end {
278        // Pele steps back one code unit, to the last character or the low half of it, and on
279        // past it. limu steps back to the start of the character.
280        let last = src[..end].chars().next_back().map_or(1, char::len_utf8);
281        let (src, at) = written(src, end - last);
282        let size = src[at.min(src.len())..]
283            .chars()
284            .next()
285            .map_or(0, char::len_utf8);
286        (src, at + size)
287    } else {
288        written(src, offset)
289    };
290    offset = offset.min(src.len());
291    while !src.is_char_boundary(offset) {
292        offset -= 1;
293    }
294    let line_start = src[..offset].rfind('\n').map_or(0, |i| i + 1);
295    let line = 1 + src[..offset].matches('\n').count();
296    let line_end = src[offset..].find('\n').map_or(src.len(), |i| offset + i);
297    let column = len16(&src[line_start..offset]) + 1;
298    let snippet = format!(
299        "{}\n{}^",
300        &src[line_start..line_end],
301        "-".repeat(column - 1)
302    );
303    Location {
304        line,
305        column,
306        snippet,
307    }
308}
309
310/// The error of text that does not parse at byte offset, worded as Jison's parsers word it,
311/// as Pele's syntaxError makes it. A lexical error is of text no token starts with.
312pub(crate) fn syntax_error(
313    diagram: DiagramType,
314    src: &str,
315    offset: usize,
316    detail: &str,
317    lexical: bool,
318) -> Error {
319    let loc = locate(src, offset);
320    let message = if lexical {
321        format!(
322            "Lexical error on line {}. Unrecognized text.\n{}",
323            loc.line, loc.snippet
324        )
325    } else {
326        format!(
327            "Parse error on line {}:\n{}\n{detail}",
328            loc.line, loc.snippet
329        )
330    };
331    Error {
332        code: ErrorCode::Syntax,
333        message,
334        diagram: Some(diagram),
335        mermaid_type: None,
336        line: loc.line,
337        column: loc.column,
338        snippet: loc.snippet,
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[test]
347    fn finds_the_byte_of_a_place() {
348        let src = "graph TD\n  a😀b --> 中\n\nx\n";
349        for (at, _) in src.char_indices().chain([(src.len(), ' ')]) {
350            let loc = locate_in(src, at);
351            let e = Error {
352                line: loc.line,
353                column: loc.column,
354                ..Error::new("", ErrorCode::Syntax)
355            };
356            assert_eq!(e.byte_offset(src), Some(at), "{at}");
357        }
358        let at = |line, column| {
359            Error {
360                line,
361                column,
362                ..Error::new("", ErrorCode::Syntax)
363            }
364            .byte_offset(src)
365        };
366        // After the emoji, inside it, at a line's end and past it, on no line, and with no
367        // place.
368        assert_eq!(at(2, 6), Some(16));
369        assert_eq!(at(2, 5), None);
370        assert_eq!(at(1, 9), Some(8));
371        assert_eq!(at(1, 10), None);
372        assert_eq!(at(6, 1), None);
373        assert_eq!(at(0, 0), None);
374    }
375
376    /// The place of a byte, found as locate finds it but without its rules for the end of
377    /// the text.
378    fn locate_in(src: &str, offset: usize) -> Location {
379        let line_start = src[..offset].rfind('\n').map_or(0, |i| i + 1);
380        Location {
381            line: 1 + src[..offset].matches('\n').count(),
382            column: len16(&src[line_start..offset]) + 1,
383            snippet: String::new(),
384        }
385    }
386}