Skip to content

Commit fcbc7bc

Browse files
committed
fix: CRLF newline handling and error span rendering
Previously, CRLF was treated as two newlines, causing incorrect line numbers, extra blank lines, and misaligned spans on Windows. - treat CRLF as a single newline - use consistent newline handling for line/column calculation and display - fix trailing space rendering on empty lines - preserve UTF-16 column alignment and span rendering
1 parent e5296bf commit fcbc7bc

File tree

1 file changed

+152
-20
lines changed

1 file changed

+152
-20
lines changed

src/error.rs

Lines changed: 152 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ use std::sync::Arc;
55
use chumsky::error::Error as ChumskyError;
66
use chumsky::input::ValueInput;
77
use chumsky::label::LabelError;
8-
use chumsky::text::Char;
98
use chumsky::util::MaybeRef;
109
use chumsky::DefaultExpected;
1110

@@ -192,22 +191,61 @@ impl RichError {
192191

193192
impl fmt::Display for RichError {
194193
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195-
fn get_line_col(file: &str, offset: usize) -> (usize, usize) {
196-
let mut line = 1;
197-
let mut col = 0;
194+
fn next_newline(s: &str) -> Option<(usize, usize)> {
195+
let mut it = s.char_indices().peekable();
198196

199-
let slice = file.get(0..offset).unwrap_or_default();
197+
while let Some((i, ch)) = it.next() {
198+
// Treat CRLF as one logical newline.
199+
if ch == '\r' && matches!(it.peek(), Some((_, c)) if *c == '\n') {
200+
return Some((i, ch.len_utf8() + '\n'.len_utf8()));
201+
}
200202

201-
for char in slice.chars() {
202-
if char.is_newline() {
203-
line += 1;
204-
col = 0;
205-
} else {
206-
col += char.len_utf16();
203+
// Support LF.
204+
if ch == '\n' {
205+
return Some((i, ch.len_utf8()));
207206
}
207+
208+
// Unicode separator support
209+
if ch == '\u{2028}' || ch == '\u{2029}' {
210+
return Some((i, ch.len_utf8()));
211+
}
212+
}
213+
214+
None
215+
}
216+
fn get_line_col(file: &str, offset: usize) -> (usize, usize) {
217+
let s = file.get(..offset).unwrap_or_default();
218+
let mut line = 1usize;
219+
let mut last_line_start = 0usize;
220+
let mut rest = s;
221+
let mut consumed = 0usize;
222+
223+
while let Some((i, nl_len)) = next_newline(rest) {
224+
line += 1;
225+
consumed += i + nl_len;
226+
last_line_start = consumed;
227+
rest = &rest[i + nl_len..];
228+
}
229+
230+
let col = 1 + s[last_line_start..]
231+
.chars()
232+
.map(char::len_utf16)
233+
.sum::<usize>();
234+
235+
(line, col)
236+
}
237+
238+
fn split_lines_preserving_crlf(file: &str) -> Vec<&str> {
239+
let mut out = Vec::new();
240+
let mut rest = file;
241+
242+
while let Some((i, nl_len)) = next_newline(rest) {
243+
out.push(&rest[..i]);
244+
rest = &rest[i + nl_len..];
208245
}
209246

210-
(line, col + 1)
247+
out.push(rest);
248+
out
211249
}
212250

213251
match self.file {
@@ -222,24 +260,28 @@ impl fmt::Display for RichError {
222260

223261
writeln!(f, "{:width$} |", " ", width = line_num_width)?;
224262

225-
let mut lines = file
226-
.split(|c: char| c.is_newline())
227-
.skip(start_line_index)
228-
.peekable();
263+
let split_lines = split_lines_preserving_crlf(file);
264+
let mut lines = split_lines.into_iter().skip(start_line_index).peekable();
229265

230266
let start_line_len = lines
231267
.peek()
232-
.map_or(0, |l| l.chars().map(char::len_utf16).sum());
268+
.map_or(0, |l| l.chars().map(char::len_utf16).sum::<usize>());
233269

234270
for (relative_line_index, line_str) in lines.take(n_spanned_lines).enumerate() {
235271
let line_num = start_line_index + relative_line_index + 1;
236-
writeln!(f, "{line_num:line_num_width$} | {line_str}")?;
272+
write!(f, "{line_num:line_num_width$} |")?;
273+
if !line_str.is_empty() {
274+
write!(f, " {line_str}")?;
275+
}
276+
writeln!(f)?;
237277
}
238278

239279
let is_multiline = end_line > start_line;
240280

241281
let (underline_start, underline_length) = match is_multiline {
242-
true => (0, start_line_len),
282+
// For multiline spans, preserve the existing display style:
283+
// underline the full first displayed line.
284+
true => (1, start_line_len),
243285
false => (start_col, (end_col - start_col).max(1)),
244286
};
245287
write!(f, "{:width$} |", " ", width = line_num_width)?;
@@ -764,6 +806,81 @@ let x: u32 = Left(
764806
assert_eq!(&expected[1..], &error.to_string());
765807
}
766808

809+
#[test]
810+
fn display_with_windows_crlf_newlines() {
811+
let file = "let a: u8 = 65536;\r\nlet b: u8 = 0;";
812+
let error = Error::CannotParse("number too large to fit in target type".to_string())
813+
.with_span(Span::new(12, 17))
814+
.with_file(Arc::from(file));
815+
816+
let expected = r#"
817+
|
818+
1 | let a: u8 = 65536;
819+
| ^^^^^ Cannot parse: number too large to fit in target type"#;
820+
821+
assert_eq!(&expected[1..], &error.to_string());
822+
}
823+
824+
#[test]
825+
fn display_with_unix_lf_newlines() {
826+
let file = "let a: u8 = 65536;\nlet b: u8 = 0;";
827+
let error = Error::CannotParse("number too large to fit in target type".to_string())
828+
.with_span(Span::new(12, 17))
829+
.with_file(Arc::from(file));
830+
831+
let expected = r#"
832+
|
833+
1 | let a: u8 = 65536;
834+
| ^^^^^ Cannot parse: number too large to fit in target type"#;
835+
836+
assert_eq!(&expected[1..], &error.to_string());
837+
}
838+
839+
#[test]
840+
fn display_with_mixed_newlines_on_second_line() {
841+
let file = "line1\r\nline2\nline3";
842+
let error = Error::CannotParse("err".to_string())
843+
.with_span(Span::new(7, 12))
844+
.with_file(Arc::from(file));
845+
846+
let expected = r#"
847+
|
848+
2 | line2
849+
| ^^^^^ Cannot parse: err"#;
850+
851+
assert_eq!(&expected[1..], &error.to_string());
852+
}
853+
854+
#[test]
855+
fn display_does_not_insert_extra_blank_line_for_crlf() {
856+
let file = "a\r\nb";
857+
let error = Error::CannotParse("err".to_string())
858+
.with_span(Span::new(3, 4))
859+
.with_file(Arc::from(file));
860+
861+
let expected = r#"
862+
|
863+
2 | b
864+
| ^ Cannot parse: err"#;
865+
866+
assert_eq!(&expected[1..], &error.to_string());
867+
}
868+
869+
#[test]
870+
fn display_handles_utf16_columns_after_newline() {
871+
let file = "x\r\n😀ab";
872+
let error = Error::CannotParse("err".to_string())
873+
.with_span(Span::new(7, 9))
874+
.with_file(Arc::from(file));
875+
876+
let expected = r#"
877+
|
878+
2 | 😀ab
879+
| ^^ Cannot parse: err"#;
880+
881+
assert_eq!(&expected[1..], &error.to_string());
882+
}
883+
767884
#[test]
768885
fn display_span_as_point() {
769886
let file = "fn main()";
@@ -787,9 +904,24 @@ let x: u32 = Left(
787904

788905
let expected = r#"
789906
|
790-
3 |
907+
3 |
791908
| ^ Cannot parse: eof"#;
792909

793910
assert_eq!(&expected[1..], &error.to_string());
794911
}
912+
913+
#[test]
914+
fn display_zero_length_span_shows_single_caret() {
915+
let file = "let a: u8 = 1;";
916+
let error = Error::CannotParse("err".to_string())
917+
.with_span(Span::new(12, 12))
918+
.with_file(Arc::from(file));
919+
920+
let expected = r#"
921+
|
922+
1 | let a: u8 = 1;
923+
| ^ Cannot parse: err"#;
924+
925+
assert_eq!(&expected[1..], &error.to_string());
926+
}
795927
}

0 commit comments

Comments
 (0)