surrealdb_core/syn/error/
render.rs

1//! Module for rendering errors onto source code.
2
3use std::{cmp::Ordering, fmt, ops::Range};
4
5use crate::sql::Object;
6
7use super::{Location, MessageKind};
8
9#[derive(Clone, Debug)]
10#[non_exhaustive]
11pub struct RenderedError {
12	pub errors: Vec<String>,
13	pub snippets: Vec<Snippet>,
14}
15
16impl RenderedError {
17	/// Offset the snippet locations within the rendered error by a given number of lines and
18	/// columns.
19	///
20	/// The column offset is only applied to the any snippet which is at line 1
21	pub fn offset_location(mut self, line: usize, col: usize) -> Self {
22		for s in self.snippets.iter_mut() {
23			if s.location.line == 1 {
24				s.location.column += col;
25			}
26			s.location.line += line
27		}
28		self
29	}
30}
31
32impl fmt::Display for RenderedError {
33	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34		match self.errors.len().cmp(&1) {
35			Ordering::Equal => writeln!(f, "{}", self.errors[0])?,
36			Ordering::Greater => {
37				writeln!(f, "- {}", self.errors[0])?;
38				writeln!(f, "caused by:")?;
39				for e in &self.errors[2..] {
40					writeln!(f, "    - {}", e)?
41				}
42			}
43			Ordering::Less => {}
44		}
45		for s in &self.snippets {
46			writeln!(f, "{s}")?;
47		}
48		Ok(())
49	}
50}
51
52/// Whether the snippet was truncated.
53#[derive(Clone, Copy, Eq, PartialEq, Debug)]
54pub enum Truncation {
55	/// The snippet wasn't truncated
56	None,
57	/// The snippet was truncated at the start
58	Start,
59	/// The snippet was truncated at the end
60	End,
61	/// Both sided of the snippet where truncated.
62	Both,
63}
64
65impl Truncation {
66	pub fn as_str(&self) -> &str {
67		match self {
68			Truncation::None => "none",
69			Truncation::Start => "start",
70			Truncation::End => "end",
71			Truncation::Both => "both",
72		}
73	}
74}
75
76/// A piece of the source code with a location and an optional explanation.
77#[derive(Clone, Debug)]
78pub struct Snippet {
79	/// The part of the original source code,
80	source: String,
81	/// Whether part of the source line was truncated.
82	truncation: Truncation,
83	/// The location of the snippet in the original source code.
84	location: Location,
85	/// The offset, in chars, into the snippet where the location is.
86	offset: usize,
87	/// The amount of characters that are part of area to be pointed to.
88	length: usize,
89	/// A possible explanation for this snippet.
90	label: Option<String>,
91	/// The kind of snippet,
92	// Unused for now but could in the future be used to color snippets.
93	#[allow(dead_code)]
94	kind: MessageKind,
95}
96
97impl Snippet {
98	/// How long with the source line have to be before it gets truncated.
99	const MAX_SOURCE_DISPLAY_LEN: usize = 80;
100	/// How far the will have to be in the source line before everything before it gets truncated.
101	const MAX_ERROR_LINE_OFFSET: usize = 50;
102
103	pub fn from_source_location(
104		source: &str,
105		location: Location,
106		explain: Option<&'static str>,
107		kind: MessageKind,
108	) -> Self {
109		let line = source.split('\n').nth(location.line - 1).unwrap();
110		let (line, truncation, offset) = Self::truncate_line(line, location.column - 1);
111
112		Snippet {
113			source: line.to_owned(),
114			truncation,
115			location,
116			offset,
117			length: 1,
118			label: explain.map(|x| x.into()),
119			kind,
120		}
121	}
122
123	pub fn from_source_location_range(
124		source: &str,
125		location: Range<Location>,
126		explain: Option<&str>,
127		kind: MessageKind,
128	) -> Self {
129		let line = source.split('\n').nth(location.start.line - 1).unwrap();
130		let (line, truncation, offset) = Self::truncate_line(line, location.start.column - 1);
131		let length = if location.start.line == location.end.line {
132			location.end.column - location.start.column
133		} else {
134			1
135		};
136		Snippet {
137			source: line.to_owned(),
138			truncation,
139			location: location.start,
140			offset,
141			length,
142			label: explain.map(|x| x.into()),
143			kind,
144		}
145	}
146
147	/// Trims whitespace of an line and additionally truncates the string around the target_col_offset if it is too long.
148	///
149	/// returns the trimmed string, how it is truncated, and the offset into truncated the string where the target_col is located.
150	fn truncate_line(mut line: &str, target_col: usize) -> (&str, Truncation, usize) {
151		// offset in characters from the start of the string.
152		let mut offset = 0;
153		for (i, (idx, c)) in line.char_indices().enumerate() {
154			// if i == target_col the error is in the leading whitespace. so return early.
155			if i == target_col || !c.is_whitespace() {
156				line = &line[idx..];
157				offset = target_col - i;
158				break;
159			}
160		}
161
162		line = line.trim_end();
163		// truncation none because only truncated non-whitespace counts.
164		let mut truncation = Truncation::None;
165
166		if offset > Self::MAX_ERROR_LINE_OFFSET {
167			// Actual error is to far to the right, just truncated everything to the left.
168			// show some prefix for some extra context.
169			let too_much_offset = offset - 10;
170			let mut chars = line.chars();
171			for _ in 0..too_much_offset {
172				chars.next();
173			}
174			offset = 10;
175			line = chars.as_str();
176			truncation = Truncation::Start;
177		}
178
179		if line.chars().count() > Self::MAX_SOURCE_DISPLAY_LEN {
180			// Line is too long, truncate to source
181			let mut size = Self::MAX_SOURCE_DISPLAY_LEN - 3;
182			if truncation == Truncation::Start {
183				truncation = Truncation::Both;
184				size -= 3;
185			} else {
186				truncation = Truncation::End
187			}
188
189			// Unwrap because we just checked if the line length is longer then this.
190			let truncate_index = line.char_indices().nth(size).unwrap().0;
191			line = &line[..truncate_index];
192		}
193
194		(line, truncation, offset)
195	}
196
197	pub fn to_object(&self) -> Object {
198		let mut obj = Object::default();
199		obj.insert("source".to_owned(), self.source.clone().into());
200		obj.insert("truncation".to_owned(), self.truncation.as_str().into());
201		obj.insert("line".to_owned(), self.location.line.into());
202		obj.insert("column".to_owned(), self.location.column.into());
203		obj.insert("length".to_owned(), self.length.into());
204
205		if let Some(x) = &self.label {
206			obj.insert("label".to_owned(), x.clone().into());
207		}
208
209		obj.insert("kind".to_owned(), self.kind.as_str().into());
210
211		obj
212	}
213}
214
215impl fmt::Display for Snippet {
216	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217		// extra spacing for the line number
218		let spacing = self.location.line.ilog10() as usize + 1;
219		for _ in 0..spacing {
220			f.write_str(" ")?;
221		}
222		writeln!(f, "--> [{}:{}]", self.location.line, self.location.column)?;
223
224		for _ in 0..spacing {
225			f.write_str(" ")?;
226		}
227		f.write_str(" |\n")?;
228		write!(f, "{:>spacing$} | ", self.location.line)?;
229		match self.truncation {
230			Truncation::None => {
231				writeln!(f, "{}", self.source)?;
232			}
233			Truncation::Start => {
234				writeln!(f, "...{}", self.source)?;
235			}
236			Truncation::End => {
237				writeln!(f, "{}...", self.source)?;
238			}
239			Truncation::Both => {
240				writeln!(f, "...{}...", self.source)?;
241			}
242		}
243
244		let error_offset = self.offset
245			+ if matches!(self.truncation, Truncation::Start | Truncation::Both) {
246				3
247			} else {
248				0
249			};
250		for _ in 0..spacing {
251			f.write_str(" ")?;
252		}
253		f.write_str(" | ")?;
254		for _ in 0..error_offset {
255			f.write_str(" ")?;
256		}
257		for _ in 0..self.length {
258			write!(f, "^")?;
259		}
260		write!(f, " ")?;
261		if let Some(ref explain) = self.label {
262			write!(f, "{explain}")?;
263		}
264		Ok(())
265	}
266}
267
268#[cfg(test)]
269mod test {
270	use super::{RenderedError, Snippet, Truncation};
271	use crate::syn::{
272		error::{Location, MessageKind},
273		token::Span,
274	};
275
276	#[test]
277	fn truncate_whitespace() {
278		let source = "\n\n\n\t      $     \t";
279		let offset = source.char_indices().find(|(_, c)| *c == '$').unwrap().0;
280
281		let location = Location::from_span(
282			source,
283			Span {
284				offset: offset as u32,
285				len: 1,
286			},
287		);
288
289		let snippet =
290			Snippet::from_source_location(source, location.start, None, MessageKind::Error);
291		assert_eq!(snippet.truncation, Truncation::None);
292		assert_eq!(snippet.offset, 0);
293		assert_eq!(snippet.source.as_str(), "$");
294	}
295
296	#[test]
297	fn truncate_start() {
298		let source = "     aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa $     \t";
299		let offset = source.char_indices().find(|(_, c)| *c == '$').unwrap().0;
300
301		let location = Location::from_span(
302			source,
303			Span {
304				offset: offset as u32,
305				len: 1,
306			},
307		);
308
309		let snippet =
310			Snippet::from_source_location(source, location.start, None, MessageKind::Error);
311		assert_eq!(snippet.truncation, Truncation::Start);
312		assert_eq!(snippet.offset, 10);
313		assert_eq!(snippet.source.as_str(), "aaaaaaaaa $");
314	}
315
316	#[test]
317	fn truncate_end() {
318		let source = "\n\n  a $ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa    \t";
319		let offset = source.char_indices().find(|(_, c)| *c == '$').unwrap().0;
320
321		let location = Location::from_span(
322			source,
323			Span {
324				offset: offset as u32,
325				len: 1,
326			},
327		);
328
329		let snippet =
330			Snippet::from_source_location(source, location.start, None, MessageKind::Error);
331		assert_eq!(snippet.truncation, Truncation::End);
332		assert_eq!(snippet.offset, 2);
333		assert_eq!(
334			snippet.source.as_str(),
335			"a $ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
336		);
337	}
338
339	#[test]
340	fn truncate_both() {
341		let source = "\n\n\n\n  aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa $ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa   \t";
342		let offset = source.char_indices().find(|(_, c)| *c == '$').unwrap().0;
343
344		let location = Location::from_span(
345			source,
346			Span {
347				offset: offset as u32,
348				len: 1,
349			},
350		);
351
352		let snippet =
353			Snippet::from_source_location(source, location.start, None, MessageKind::Error);
354		assert_eq!(snippet.truncation, Truncation::Both);
355		assert_eq!(snippet.offset, 10);
356		assert_eq!(
357			snippet.source.as_str(),
358			"aaaaaaaaa $ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
359		);
360	}
361
362	#[test]
363	fn render() {
364		let error = RenderedError {
365			errors: vec!["some_error".to_string()],
366			snippets: vec![Snippet {
367				source: "hallo error".to_owned(),
368				truncation: Truncation::Both,
369				location: Location {
370					line: 4,
371					column: 10,
372				},
373				offset: 6,
374				length: 5,
375				label: Some("this is wrong".to_owned()),
376				kind: MessageKind::Error,
377			}],
378		};
379
380		let error_string = format!("{}", error);
381		let expected = r#"some_error
382 --> [4:10]
383  |
3844 | ...hallo error...
385  |          ^^^^^ this is wrong
386"#;
387		assert_eq!(error_string, expected)
388	}
389}