1use std::path::Path;
19
20use ariadne::Source;
21use bynk_syntax::error::Severity;
22use bynk_syntax::{CompileError, span};
23
24pub fn render_errors(errors: &[CompileError], source: &str, filename: &str) -> String {
27 String::from_utf8_lossy(&render_all(errors, source, filename, true)).into_owned()
28}
29
30pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
35 String::from_utf8_lossy(&render_all(errors, source, filename, false)).into_owned()
36}
37
38pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
41 use std::io::Write;
42 let out = render_all(errors, source, filename, stderr_color());
43 let _ = std::io::stderr().lock().write_all(&out);
44}
45
46fn render_all(errors: &[CompileError], source: &str, filename: &str, color: bool) -> Vec<u8> {
50 fn write<'a>(
51 err: &'a CompileError,
52 filename: &'a str,
53 cache: &mut (&'a str, Source<&str>),
54 color: bool,
55 out: &mut Vec<u8>,
56 ) {
57 let source = cache.1.text();
58 let report = if color {
59 err.report_for(filename, source)
60 } else {
61 err.report_plain_for(filename, source)
62 };
63 report
64 .write(&mut *cache, out)
65 .expect("write to Vec<u8> cannot fail");
66 }
67 let long = long_lines(source);
68 let mut cache = (filename, Source::from(source));
69 let mut index = None;
70 let mut out = Vec::new();
71 for err in errors {
72 let touches_long = std::iter::once(&err.span)
73 .chain(err.labels.iter().map(|(s, _)| s))
74 .filter(|s| fits(s, source))
75 .any(|s| {
76 long.iter()
77 .any(|&(start, end)| s.start <= end && s.end >= start)
78 });
79 if !touches_long {
80 write(err, filename, &mut cache, color, &mut out);
81 continue;
82 }
83 let (shown, shown_err) = cropped(err, source);
84 let mut one = Vec::new();
85 write(
86 &shown_err,
87 filename,
88 &mut (filename, Source::from(&*shown)),
89 color,
90 &mut one,
91 );
92 let cut = span::line_col(&shown, shown_err.span.start);
96 let real = index
97 .get_or_insert_with(|| span::LineIndex::new(source))
98 .line_col(source, err.span.start);
99 if cut != real {
100 one = fix_header(
101 &one,
102 &format!("{filename}:{}:{}", cut.0, cut.1),
103 &format!("{filename}:{}:{}", real.0, real.1),
104 );
105 }
106 out.extend(one);
107 }
108 out
109}
110
111fn long_lines(source: &str) -> Vec<(usize, usize)> {
114 let mut long = Vec::new();
115 let mut start = 0;
116 for raw in source.split_inclusive('\n') {
117 let len = raw.trim_end_matches(['\n', '\r']).len();
118 if len > MAX_LINE {
119 long.push((start, start + len));
120 }
121 start += raw.len();
122 }
123 long
124}
125
126fn fix_header(report: &[u8], cut: &str, real: &str) -> Vec<u8> {
130 let text = String::from_utf8_lossy(report);
131 let Some(corner) = text.find('╭') else {
132 return report.to_vec();
133 };
134 let start = text[..corner].rfind('\n').map_or(0, |i| i + 1);
135 let end = text[corner..].find('\n').map_or(text.len(), |i| corner + i);
136 let header = text[start..end].replacen(cut, real, 1);
137 format!("{}{header}{}", &text[..start], &text[end..]).into_bytes()
138}
139
140pub const MAX_LINE: usize = 400;
144
145const CONTEXT: usize = 80;
147
148struct LineWindow {
152 end: usize,
153 win_start: usize,
154 win_end: usize,
155 new_text_start: usize,
156}
157
158fn cropped(err: &CompileError, source: &str) -> (String, CompileError) {
167 let spans: Vec<(usize, usize)> = std::iter::once(&err.span)
168 .chain(err.labels.iter().map(|(s, _)| s))
169 .filter(|s| fits(s, source))
170 .map(|s| (s.start, s.end))
171 .collect();
172 let mut out = String::with_capacity(source.len().min(64 * 1024));
173 let mut lines: Vec<LineWindow> = Vec::new();
174 let mut start = 0;
175 for raw in source.split_inclusive('\n') {
176 let line_len = raw.trim_end_matches(['\n', '\r']).len();
177 let end = start + line_len;
178 let (win_start, win_end) = if line_len <= MAX_LINE {
179 (start, end)
180 } else {
181 let mut lo = usize::MAX;
182 let mut hi = 0;
183 for &(s, e) in &spans {
184 if e < start || s > end {
185 continue;
186 }
187 let s = s.max(start);
188 let e = e.min(end).min(s + MAX_LINE);
189 lo = lo.min(s);
190 hi = hi.max(e);
191 }
192 let (lo, hi) = if lo == usize::MAX {
193 (start, start + MAX_LINE)
194 } else {
195 (
196 lo.saturating_sub(CONTEXT).max(start),
197 (hi + CONTEXT).min(end),
198 )
199 };
200 let (lo, hi) = if hi - lo > MAX_LINE + 2 * CONTEXT {
203 let s = err.span.start.clamp(start, end);
204 (
205 s.saturating_sub(CONTEXT).max(start),
206 (s + MAX_LINE).min(end),
207 )
208 } else {
209 (lo, hi)
210 };
211 (floor_char(source, lo), ceil_char(source, hi))
212 };
213 if win_start > start {
214 out.push('…');
215 }
216 let new_text_start = out.len();
217 out.push_str(&source[win_start..win_end]);
218 if win_end < end {
219 out.push('…');
220 }
221 out.push_str(&raw[line_len..]);
222 lines.push(LineWindow {
223 end,
224 win_start,
225 win_end,
226 new_text_start,
227 });
228 start += raw.len();
229 }
230 let line_of = |offset: usize| lines.get(lines.partition_point(|l| l.end < offset));
231 let map = |offset: usize| -> usize {
232 let Some(l) = line_of(offset) else {
233 return out.len();
234 };
235 l.new_text_start + offset.clamp(l.win_start, l.win_end) - l.win_start
237 };
238 let mut err = err.clone();
239 err.span.start = map(err.span.start);
240 err.span.end = map(err.span.end).max(err.span.start);
241 for (span, _) in &mut err.labels {
242 let outside = line_of(span.start).is_some_and(|l| {
245 (span.start >= l.win_end && l.win_end < l.end)
246 || if span.start == span.end {
247 span.start < l.win_start
248 } else {
249 span.end <= l.win_start
250 }
251 });
252 if !fits(span, source) || outside {
253 span.start = usize::MAX;
256 span.end = usize::MAX;
257 continue;
258 }
259 span.start = map(span.start);
260 span.end = map(span.end).max(span.start);
261 }
262 (out, err)
263}
264
265fn fits(span: &span::Span, source: &str) -> bool {
268 span.start <= span.end
269 && span.end <= source.len()
270 && source.is_char_boundary(span.start)
271 && source.is_char_boundary(span.end)
272}
273
274fn floor_char(s: &str, mut i: usize) -> usize {
275 while !s.is_char_boundary(i) {
276 i -= 1;
277 }
278 i
279}
280
281fn ceil_char(s: &str, mut i: usize) -> usize {
282 while !s.is_char_boundary(i) {
283 i += 1;
284 }
285 i
286}
287
288pub fn stderr_color() -> bool {
301 use std::io::IsTerminal;
302 color_allowed(std::io::stderr().is_terminal(), |name| {
303 std::env::var_os(name)
304 })
305}
306
307fn color_allowed(is_terminal: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
310 let set = |name| var(name).is_some_and(|v| !v.is_empty());
311 let forced = |name| var(name).is_some_and(|v| !v.is_empty() && v != "0");
312 if set("NO_COLOR") {
313 false
314 } else {
315 is_terminal || forced("FORCE_COLOR") || forced("CLICOLOR_FORCE")
316 }
317}
318
319pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
323 let _ = root;
324 for err in errors {
325 eprintln!("[{}] {}", err.category, err.message);
326 for note in &err.notes {
327 eprintln!(" note: {note}");
328 }
329 for (_, label) in &err.labels {
332 eprintln!(" label: {label}");
333 }
334 }
335}
336
337pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
343 eprint!("{}", render_errors_short(errors, source, filename));
344}
345
346pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
356 let mut out = String::new();
357 for err in errors {
358 out.push_str(&short_line(filename, source, err));
359 out.push('\n');
360 }
361 out
362}
363
364pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
368 let (line, col) = span::line_col(source, err.span.start);
369 format!(
370 "{filename}:{line}:{col}: {}[{}]: {}",
371 severity_word(err),
372 err.category,
373 err.message
374 )
375}
376
377pub fn severity_word(err: &CompileError) -> &'static str {
379 match Severity::for_error(err) {
380 Severity::Error => "error",
381 Severity::Warning => "warning",
382 }
383}
384
385pub fn render_project_errors(errors: &[CompileError]) -> String {
388 let mut out = String::new();
389 for err in errors {
390 out.push_str(&format!("[{}] {}\n", err.category, err.message));
391 for note in &err.notes {
392 out.push_str(&format!(" note: {note}\n"));
393 }
394 for (_, label) in &err.labels {
395 out.push_str(&format!(" label: {label}\n"));
396 }
397 }
398 out
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use bynk_syntax::span::Span;
405
406 #[test]
411 fn underline_is_byte_indexed_on_non_ascii_lines() {
412 let source = "-- caféxyz bad\n";
415 let start = source.find("bad").unwrap();
416 let err = CompileError::new(
417 "bynk.test.example",
418 Span::new(start, start + 3),
419 "bad thing",
420 );
421 let rendered = render_errors_plain(&[err], source, "probe.bynk");
422 let source_line = rendered
423 .lines()
424 .find(|l| l.contains("caféxyz"))
425 .expect("snippet line present");
426 let marker_line = rendered
427 .lines()
428 .find(|l| l.contains('┬'))
429 .expect("marker line present");
430 let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
431 let b_col = col_of(source_line, 'b');
434 let caret_col = col_of(marker_line, '┬');
435 assert!(
436 (b_col..b_col + 3).contains(&caret_col),
437 "caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
438 b_col + 3
439 );
440 }
441
442 #[test]
446 fn out_of_bounds_label_demotes_to_note() {
447 let source = "commons demo\n";
448 let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
449 .with_label(
450 Span::new(5_000, 5_010),
451 "parameter declared here (in another file)",
452 );
453 let rendered = render_errors_plain(&[err], source, "probe.bynk");
454 assert!(
455 rendered.contains("parameter declared here"),
456 "label text survives as a note:\n{rendered}"
457 );
458 }
459
460 #[test]
466 fn mid_codepoint_label_demotes_to_note() {
467 let source = "café ☕\n"; let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
469 .with_label(
470 Span::new(4, 5),
471 "declared here (mid-codepoint, another file)",
472 );
473 let rendered = render_errors_plain(&[err], source, "probe.bynk");
475 assert!(
476 rendered.contains("declared here (mid-codepoint, another file)"),
477 "a mid-codepoint label must survive as a note:\n{rendered}"
478 );
479 }
480
481 #[test]
486 fn color_needs_a_terminal_or_a_force_and_no_no_color() {
487 use std::ffi::OsString;
488 let allowed = |is_terminal, vars: &[(&str, &str)]| {
489 color_allowed(is_terminal, |name| {
490 vars.iter()
491 .find(|(n, _)| *n == name)
492 .map(|(_, v)| OsString::from(v))
493 })
494 };
495 assert!(allowed(true, &[]));
496 assert!(allowed(true, &[("NO_COLOR", "")]));
497 assert!(!allowed(true, &[("NO_COLOR", "1")]));
498 assert!(!allowed(false, &[]));
499 assert!(!allowed(false, &[("NO_COLOR", "")]));
500
501 for force in ["FORCE_COLOR", "CLICOLOR_FORCE"] {
502 assert!(allowed(false, &[(force, "1")]), "{force}=1");
503 assert!(allowed(false, &[(force, "true")]), "{force}=true");
504 assert!(!allowed(false, &[(force, "")]), "{force}= is unset");
505 assert!(!allowed(false, &[(force, "0")]), "{force}=0 doesn't force");
506 assert!(allowed(true, &[(force, "0")]), "{force}=0 doesn't forbid");
507 assert!(allowed(false, &[(force, "1"), ("NO_COLOR", "")]));
508 assert!(!allowed(false, &[(force, "1"), ("NO_COLOR", "1")]));
509 assert!(!allowed(true, &[(force, "1"), ("NO_COLOR", "1")]));
510 }
511 }
512
513 #[test]
516 fn a_long_line_is_cut_around_the_label() {
517 let pad = "x".repeat(500_000);
518 let source = format!("commons c\n{pad}bad{pad}\n");
519 let start = source.find("bad").unwrap();
520 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
521 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
522 assert!(
523 rendered.len() < 4_000,
524 "{} bytes:\n{rendered}",
525 rendered.len()
526 );
527 assert!(
528 rendered.contains("probe.bynk:2:500001 "),
529 "the header keeps the column in the file:\n{rendered}"
530 );
531 let line = rendered.lines().find(|l| l.contains("xbadx")).unwrap();
532 assert!(line.contains('…') && line.ends_with('…'), "{line}");
533 let caret = rendered.lines().find(|l| l.contains('┬')).unwrap();
534 let col = |l: &str, s: &str| l[..l.find(s).unwrap()].chars().count();
535 assert!((col(line, "bad")..col(line, "bad") + 3).contains(&col(caret, "┬")));
536 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
538 assert!(render_errors(&[err], &source, "probe.bynk").len() < 8_000);
539 }
540
541 #[test]
544 fn cuts_respect_chars_and_short_lines_are_kept() {
545 let wide = "é".repeat(300); let short = "y".repeat(MAX_LINE);
547 let source = format!("commons c\n{short}\n{wide}bad{wide}\n");
548 let start = source.find("bad").unwrap();
549 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
550 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
551 assert!(rendered.contains("probe.bynk:3:301 "), "{rendered}");
552 let line = rendered.lines().find(|l| l.contains("ébadé")).unwrap();
553 assert!(line.contains("…éé") && line.contains("éé…"), "{line}");
554
555 let span = Span::new(10, 11);
556 let err = CompileError::new("bynk.test.example", span, "here");
557 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
558 assert!(rendered.contains(&short), "{rendered}");
559 }
560
561 #[test]
563 fn a_foreign_label_stays_a_note_on_a_cut_line() {
564 let source = format!("commons c\n{}\n", "z".repeat(1_000));
565 let err = CompileError::new("bynk.test.example", Span::new(10, 11), "here")
566 .with_label(Span::new(5_000, 5_010), "declared in another file");
567 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
568 assert!(
569 rendered.contains("Note: declared in another file"),
570 "{rendered}"
571 );
572 }
573
574 #[test]
578 fn a_label_cut_from_the_window_becomes_a_note() {
579 let source = format!("commons c\nhere{}there\n", "1 ".repeat(100_000));
580 let there = source.find("there").unwrap();
581 let err = CompileError::new("bynk.test.example", Span::new(10, 14), "primary")
582 .with_label(Span::new(there, there + 5), "far label");
583 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
584 assert!(rendered.len() < 4_000, "{rendered}");
585 assert!(rendered.contains("Note: far label"), "{rendered}");
586 assert!(rendered.contains("probe.bynk:2:1 "), "{rendered}");
587 }
588
589 #[test]
591 fn a_cut_crlf_line_keeps_its_line_ending() {
592 let source = format!("commons c\r\n{}bad\r\n", "w".repeat(1_000));
593 let start = source.find("bad").unwrap();
594 let (shown, _) = cropped(
595 &CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad"),
596 &source,
597 );
598 assert!(shown.starts_with("commons c\r\n…"), "{shown:?}");
599 assert!(shown.ends_with("wbad\r\n"), "{shown:?}");
600 }
601
602 #[test]
607 fn a_label_touching_the_window_edge_from_outside_is_a_note() {
608 let source = format!("commons c\n{}\n", "q".repeat(20_000));
609 let p = 10 + 1_000;
610 let err = CompileError::new("bynk.test.example", Span::new(p, p + 1), "primary")
611 .with_label(Span::new(p + MAX_LINE, p + MAX_LINE + 3), "after the cut")
612 .with_label(Span::new(p - CONTEXT - 3, p - CONTEXT), "before the cut")
613 .with_label(Span::new(p + 10_000, p + 10_003), "far away");
614 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
615 for (i, label) in ["after the cut", "before the cut", "far away"]
616 .iter()
617 .enumerate()
618 {
619 let note = format!("Note {}: {label}", i + 1);
620 assert!(rendered.contains(¬e), "{rendered}");
621 }
622 }
623
624 #[test]
628 fn several_errors_and_long_lines_render_independently() {
629 let pad = "x".repeat(5_000);
630 let source = format!("commons c\n{pad}one{pad}\nshort\n{pad}two{pad}\n");
631 let at = |word: &str| source.find(word).unwrap();
632 let err = |word: &str| {
633 CompileError::new("bynk.test.example", Span::new(at(word), at(word) + 3), word)
634 };
635 let errors = [
636 err("one"),
637 CompileError::new(
638 "bynk.test.example",
639 Span::new(at("short"), at("short") + 5),
640 "s",
641 ),
642 err("two"),
643 ];
644 let rendered = render_errors_plain(&errors, &source, "probe.bynk");
645 assert!(rendered.len() < 8_000, "{rendered}");
646 for header in [
647 "probe.bynk:2:5001 ",
648 "probe.bynk:3:1 ",
649 "probe.bynk:4:5001 ",
650 ] {
651 assert!(rendered.contains(header), "{header}:\n{rendered}");
652 }
653 assert!(
654 rendered
655 .lines()
656 .any(|l| l.contains("xonex") && l.ends_with('…'))
657 );
658 assert!(
659 rendered
660 .lines()
661 .any(|l| l.contains("xtwox") && l.ends_with('…'))
662 );
663 }
664
665 #[test]
668 fn the_coloured_header_keeps_the_column_in_the_file() {
669 let source = format!("commons c\n{}bad\n", "x".repeat(50_000));
670 let start = source.find("bad").unwrap();
671 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
672 let rendered = render_errors(&[err], &source, "probe.bynk");
673 assert!(rendered.contains("probe.bynk:2:50001 "), "{rendered}");
674 }
675
676 #[test]
679 fn the_header_fix_leaves_the_message_alone() {
680 let source = format!("commons c\n{}bad\n", "x".repeat(1_000));
681 let start = source.find("bad").unwrap();
682 let err = CompileError::new(
684 "bynk.test.example",
685 Span::new(start, start + 3),
686 "see probe.bynk:2:82",
687 );
688 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
689 assert!(
690 rendered.contains("Error: see probe.bynk:2:82"),
691 "{rendered}"
692 );
693 assert!(rendered.contains("[ probe.bynk:2:1001 ]"), "{rendered}");
694 }
695
696 #[test]
699 fn a_cut_crlf_report_keeps_line_numbers() {
700 let source = format!("commons c\r\n\r\n{}bad\r\nnext\r\n", "w".repeat(1_000));
701 let start = source.find("bad").unwrap();
702 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
703 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
704 assert!(rendered.contains("[ probe.bynk:3:1001 ]"), "{rendered}");
705 assert!(
706 rendered
707 .lines()
708 .any(|l| l.starts_with(" 3 │ …") && l.contains("wbad")),
709 "{rendered}"
710 );
711 }
712}