1use std::path::Path;
19
20use ariadne::Source;
21use bynk_syntax::error::Severity;
22use bynk_syntax::{CompileError, span};
23
24pub use bynk_syntax::error::LabelHome;
27
28pub fn render_errors(errors: &[CompileError], source: &str, filename: &str) -> String {
31 String::from_utf8_lossy(&render_all(errors, source, filename, true)).into_owned()
32}
33
34pub fn render_errors_plain(errors: &[CompileError], source: &str, filename: &str) -> String {
39 String::from_utf8_lossy(&render_all(errors, source, filename, false)).into_owned()
40}
41
42pub fn print_errors(errors: &[CompileError], source: &str, filename: &str) {
45 use std::io::Write;
46 let out = render_all(errors, source, filename, stderr_color());
47 let _ = std::io::stderr().lock().write_all(&out);
48}
49
50fn render_all(errors: &[CompileError], source: &str, filename: &str, color: bool) -> Vec<u8> {
54 fn write<'a>(
55 err: &'a CompileError,
56 filename: &'a str,
57 cache: &mut (&'a str, Source<&str>),
58 color: bool,
59 out: &mut Vec<u8>,
60 ) {
61 let source = cache.1.text();
62 let report = if color {
63 err.report_for(filename, source)
64 } else {
65 err.report_plain_for(filename, source)
66 };
67 report
68 .write(&mut *cache, out)
69 .expect("write to Vec<u8> cannot fail");
70 }
71 let long = long_lines(source);
72 let mut cache = (filename, Source::from(source));
73 let mut index = None;
74 let mut out = Vec::new();
75 for err in errors {
76 let touches_long = std::iter::once(&err.span)
77 .chain(err.labels.iter().map(|(s, _)| s))
78 .filter(|s| fits(s, source))
79 .any(|s| {
80 long.iter()
81 .any(|&(start, end)| s.start <= end && s.end >= start)
82 });
83 if !touches_long {
84 write(err, filename, &mut cache, color, &mut out);
85 continue;
86 }
87 let (shown, shown_err) = cropped(err, source);
88 let mut one = Vec::new();
89 write(
90 &shown_err,
91 filename,
92 &mut (filename, Source::from(&*shown)),
93 color,
94 &mut one,
95 );
96 let cut = span::line_col(&shown, shown_err.span.start);
100 let real = index
101 .get_or_insert_with(|| span::LineIndex::new(source))
102 .line_col(source, err.span.start);
103 if cut != real {
104 one = fix_header(
105 &one,
106 &format!("{filename}:{}:{}", cut.0, cut.1),
107 &format!("{filename}:{}:{}", real.0, real.1),
108 );
109 }
110 out.extend(one);
111 }
112 out
113}
114
115pub fn render_error_in(
120 err: &CompileError,
121 source: &str,
122 filename: &str,
123 homes: &[LabelHome<'_>],
124) -> String {
125 String::from_utf8_lossy(&render_in(err, source, filename, homes, true)).into_owned()
126}
127
128pub fn render_error_in_plain(
131 err: &CompileError,
132 source: &str,
133 filename: &str,
134 homes: &[LabelHome<'_>],
135) -> String {
136 String::from_utf8_lossy(&render_in(err, source, filename, homes, false)).into_owned()
137}
138
139pub fn print_error_in(err: &CompileError, source: &str, filename: &str, homes: &[LabelHome<'_>]) {
141 use std::io::Write;
142 let out = render_in(err, source, filename, homes, stderr_color());
143 let _ = std::io::stderr().lock().write_all(&out);
144}
145
146fn render_in(
152 err: &CompileError,
153 source: &str,
154 filename: &str,
155 homes: &[LabelHome<'_>],
156 color: bool,
157) -> Vec<u8> {
158 let home = |i: usize| homes.get(i).copied().unwrap_or(LabelHome::Primary);
159 if (0..err.labels.len()).all(|i| home(i) == LabelHome::Primary) {
160 return render_all(std::slice::from_ref(err), source, filename, color);
161 }
162 let mut local = err.clone();
163 local.labels.clear();
164 let mut elsewhere = Vec::new();
165 for (i, label) in err.labels.iter().enumerate() {
166 match home(i) {
167 LabelHome::Primary => local.labels.push(label.clone()),
168 other => elsewhere.push((label.clone(), other)),
169 }
170 }
171 let touches_long = |s: &span::Span, text: &str| {
172 fits(s, text)
173 && long_lines(text)
174 .iter()
175 .any(|&(start, end)| s.start <= end && s.end >= start)
176 };
177 let cut = std::iter::once(&local.span)
178 .chain(local.labels.iter().map(|(s, _)| s))
179 .any(|s| touches_long(s, source));
180 let (shown, mut shown_err) = if cut {
181 cropped(&local, source)
182 } else {
183 (source.to_string(), local)
184 };
185 let mut shown_homes = vec![LabelHome::Primary; shown_err.labels.len()];
186 for ((span, label), home) in elsewhere {
187 let home = match home {
188 LabelHome::File { source: text, .. } if touches_long(&span, text) => {
189 LabelHome::Unavailable
190 }
191 other => other,
192 };
193 shown_err.labels.push((span, label));
194 shown_homes.push(home);
195 }
196 let mut cache = Files(vec![(filename, Source::from(shown.as_str()))]);
197 for home in &shown_homes {
198 if let LabelHome::File { filename, source } = *home
199 && !cache.0.iter().any(|(f, _)| *f == filename)
200 {
201 cache.0.push((filename, Source::from(source)));
202 }
203 }
204 let mut out = Vec::new();
205 shown_err
206 .report_in(filename, &shown, &shown_homes, color)
207 .write(&mut cache, &mut out)
208 .expect("write to Vec<u8> cannot fail");
209 if cut {
210 let at_cut = span::line_col(&shown, shown_err.span.start);
212 let real = span::line_col(source, err.span.start);
213 if at_cut != real {
214 out = fix_header(
215 &out,
216 &format!("{filename}:{}:{}", at_cut.0, at_cut.1),
217 &format!("{filename}:{}:{}", real.0, real.1),
218 );
219 }
220 }
221 out
222}
223
224struct Files<'a>(Vec<(&'a str, Source<&'a str>)>);
227
228impl<'a> ariadne::Cache<&'a str> for Files<'a> {
229 type Storage = &'a str;
230
231 fn fetch(&mut self, id: &&'a str) -> Result<&Source<&'a str>, impl std::fmt::Debug> {
232 self.0
233 .iter()
234 .find(|(name, _)| name == id)
235 .map(|(_, source)| source)
236 .ok_or_else(|| format!("no source for `{id}`"))
237 }
238
239 fn display<'b>(&self, id: &'b &'a str) -> Option<impl std::fmt::Display + 'b> {
240 Some(*id)
241 }
242}
243
244fn long_lines(source: &str) -> Vec<(usize, usize)> {
247 let mut long = Vec::new();
248 let mut start = 0;
249 for raw in source.split_inclusive('\n') {
250 let len = raw.trim_end_matches(['\n', '\r']).len();
251 if len > MAX_LINE {
252 long.push((start, start + len));
253 }
254 start += raw.len();
255 }
256 long
257}
258
259fn fix_header(report: &[u8], cut: &str, real: &str) -> Vec<u8> {
263 let text = String::from_utf8_lossy(report);
264 let Some(corner) = text.find('╭') else {
265 return report.to_vec();
266 };
267 let start = text[..corner].rfind('\n').map_or(0, |i| i + 1);
268 let end = text[corner..].find('\n').map_or(text.len(), |i| corner + i);
269 let header = text[start..end].replacen(cut, real, 1);
270 format!("{}{header}{}", &text[..start], &text[end..]).into_bytes()
271}
272
273pub const MAX_LINE: usize = 400;
277
278const CONTEXT: usize = 80;
280
281struct LineWindow {
285 end: usize,
286 win_start: usize,
287 win_end: usize,
288 new_text_start: usize,
289}
290
291fn cropped(err: &CompileError, source: &str) -> (String, CompileError) {
300 let spans: Vec<(usize, usize)> = std::iter::once(&err.span)
301 .chain(err.labels.iter().map(|(s, _)| s))
302 .filter(|s| fits(s, source))
303 .map(|s| (s.start, s.end))
304 .collect();
305 let mut out = String::with_capacity(source.len().min(64 * 1024));
306 let mut lines: Vec<LineWindow> = Vec::new();
307 let mut start = 0;
308 for raw in source.split_inclusive('\n') {
309 let line_len = raw.trim_end_matches(['\n', '\r']).len();
310 let end = start + line_len;
311 let (win_start, win_end) = if line_len <= MAX_LINE {
312 (start, end)
313 } else {
314 let mut lo = usize::MAX;
315 let mut hi = 0;
316 for &(s, e) in &spans {
317 if e < start || s > end {
318 continue;
319 }
320 let s = s.max(start);
321 let e = e.min(end).min(s + MAX_LINE);
322 lo = lo.min(s);
323 hi = hi.max(e);
324 }
325 let (lo, hi) = if lo == usize::MAX {
326 (start, start + MAX_LINE)
327 } else {
328 (
329 lo.saturating_sub(CONTEXT).max(start),
330 (hi + CONTEXT).min(end),
331 )
332 };
333 let (lo, hi) = if hi - lo > MAX_LINE + 2 * CONTEXT {
336 let s = err.span.start.clamp(start, end);
337 (
338 s.saturating_sub(CONTEXT).max(start),
339 (s + MAX_LINE).min(end),
340 )
341 } else {
342 (lo, hi)
343 };
344 (floor_char(source, lo), ceil_char(source, hi))
345 };
346 if win_start > start {
347 out.push('…');
348 }
349 let new_text_start = out.len();
350 out.push_str(&source[win_start..win_end]);
351 if win_end < end {
352 out.push('…');
353 }
354 out.push_str(&raw[line_len..]);
355 lines.push(LineWindow {
356 end,
357 win_start,
358 win_end,
359 new_text_start,
360 });
361 start += raw.len();
362 }
363 let line_of = |offset: usize| lines.get(lines.partition_point(|l| l.end < offset));
364 let map = |offset: usize| -> usize {
365 let Some(l) = line_of(offset) else {
366 return out.len();
367 };
368 l.new_text_start + offset.clamp(l.win_start, l.win_end) - l.win_start
370 };
371 let mut err = err.clone();
372 err.span.start = map(err.span.start);
373 err.span.end = map(err.span.end).max(err.span.start);
374 for (span, _) in &mut err.labels {
375 let outside = line_of(span.start).is_some_and(|l| {
378 (span.start >= l.win_end && l.win_end < l.end)
379 || if span.start == span.end {
380 span.start < l.win_start
381 } else {
382 span.end <= l.win_start
383 }
384 });
385 if !fits(span, source) || outside {
386 span.start = usize::MAX;
389 span.end = usize::MAX;
390 continue;
391 }
392 span.start = map(span.start);
393 span.end = map(span.end).max(span.start);
394 }
395 (out, err)
396}
397
398fn fits(span: &span::Span, source: &str) -> bool {
401 span.start <= span.end
402 && span.end <= source.len()
403 && source.is_char_boundary(span.start)
404 && source.is_char_boundary(span.end)
405}
406
407fn floor_char(s: &str, mut i: usize) -> usize {
408 while !s.is_char_boundary(i) {
409 i -= 1;
410 }
411 i
412}
413
414fn ceil_char(s: &str, mut i: usize) -> usize {
415 while !s.is_char_boundary(i) {
416 i += 1;
417 }
418 i
419}
420
421pub fn stderr_color() -> bool {
434 use std::io::IsTerminal;
435 color_allowed(std::io::stderr().is_terminal(), |name| {
436 std::env::var_os(name)
437 })
438}
439
440fn color_allowed(is_terminal: bool, var: impl Fn(&str) -> Option<std::ffi::OsString>) -> bool {
443 let set = |name| var(name).is_some_and(|v| !v.is_empty());
444 let forced = |name| var(name).is_some_and(|v| !v.is_empty() && v != "0");
445 if set("NO_COLOR") {
446 false
447 } else {
448 is_terminal || forced("FORCE_COLOR") || forced("CLICOLOR_FORCE")
449 }
450}
451
452pub fn print_project_errors(root: &Path, errors: &[CompileError]) {
456 let _ = root;
457 for err in errors {
458 eprintln!("[{}] {}", err.category, err.message);
459 for note in &err.notes {
460 eprintln!(" note: {note}");
461 }
462 for (_, label) in &err.labels {
465 eprintln!(" label: {label}");
466 }
467 }
468}
469
470pub fn print_errors_short(errors: &[CompileError], source: &str, filename: &str) {
476 eprint!("{}", render_errors_short(errors, source, filename));
477}
478
479pub fn render_errors_short(errors: &[CompileError], source: &str, filename: &str) -> String {
489 let mut out = String::new();
490 for err in errors {
491 out.push_str(&short_line(filename, source, err));
492 out.push('\n');
493 }
494 out
495}
496
497pub fn short_line(filename: &str, source: &str, err: &CompileError) -> String {
501 let (line, col) = span::line_col(source, err.span.start);
502 format!(
503 "{filename}:{line}:{col}: {}[{}]: {}",
504 severity_word(err),
505 err.category,
506 err.message
507 )
508}
509
510pub fn severity_word(err: &CompileError) -> &'static str {
512 match Severity::for_error(err) {
513 Severity::Error => "error",
514 Severity::Warning => "warning",
515 }
516}
517
518pub fn render_project_errors(errors: &[CompileError]) -> String {
521 let mut out = String::new();
522 for err in errors {
523 out.push_str(&format!("[{}] {}\n", err.category, err.message));
524 for note in &err.notes {
525 out.push_str(&format!(" note: {note}\n"));
526 }
527 for (_, label) in &err.labels {
528 out.push_str(&format!(" label: {label}\n"));
529 }
530 }
531 out
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use bynk_syntax::span::Span;
538
539 #[test]
544 fn underline_is_byte_indexed_on_non_ascii_lines() {
545 let source = "// caféxyz bad\n";
548 let start = source.find("bad").unwrap();
549 let err = CompileError::new(
550 "bynk.test.example",
551 Span::new(start, start + 3),
552 "bad thing",
553 );
554 let rendered = render_errors_plain(&[err], source, "probe.bynk");
555 let source_line = rendered
556 .lines()
557 .find(|l| l.contains("caféxyz"))
558 .expect("snippet line present");
559 let marker_line = rendered
560 .lines()
561 .find(|l| l.contains('┬'))
562 .expect("marker line present");
563 let col_of = |line: &str, target: char| line.chars().take_while(|&c| c != target).count();
564 let b_col = col_of(source_line, 'b');
567 let caret_col = col_of(marker_line, '┬');
568 assert!(
569 (b_col..b_col + 3).contains(&caret_col),
570 "caret at display column {caret_col}, expected within `bad` at {b_col}..{}:\n{rendered}",
571 b_col + 3
572 );
573 }
574
575 #[test]
579 fn out_of_bounds_label_demotes_to_note() {
580 let source = "commons demo\n";
581 let err = CompileError::new("bynk.test.example", Span::new(0, 7), "problem here")
582 .with_label(
583 Span::new(5_000, 5_010),
584 "parameter declared here (in another file)",
585 );
586 let rendered = render_errors_plain(&[err], source, "probe.bynk");
587 assert!(
588 rendered.contains("parameter declared here"),
589 "label text survives as a note:\n{rendered}"
590 );
591 }
592
593 #[test]
599 fn mid_codepoint_label_demotes_to_note() {
600 let source = "café ☕\n"; let err = CompileError::new("bynk.test.example", Span::new(0, 3), "problem here")
602 .with_label(
603 Span::new(4, 5),
604 "declared here (mid-codepoint, another file)",
605 );
606 let rendered = render_errors_plain(&[err], source, "probe.bynk");
608 assert!(
609 rendered.contains("declared here (mid-codepoint, another file)"),
610 "a mid-codepoint label must survive as a note:\n{rendered}"
611 );
612 }
613
614 #[test]
619 fn color_needs_a_terminal_or_a_force_and_no_no_color() {
620 use std::ffi::OsString;
621 let allowed = |is_terminal, vars: &[(&str, &str)]| {
622 color_allowed(is_terminal, |name| {
623 vars.iter()
624 .find(|(n, _)| *n == name)
625 .map(|(_, v)| OsString::from(v))
626 })
627 };
628 assert!(allowed(true, &[]));
629 assert!(allowed(true, &[("NO_COLOR", "")]));
630 assert!(!allowed(true, &[("NO_COLOR", "1")]));
631 assert!(!allowed(false, &[]));
632 assert!(!allowed(false, &[("NO_COLOR", "")]));
633
634 for force in ["FORCE_COLOR", "CLICOLOR_FORCE"] {
635 assert!(allowed(false, &[(force, "1")]), "{force}=1");
636 assert!(allowed(false, &[(force, "true")]), "{force}=true");
637 assert!(!allowed(false, &[(force, "")]), "{force}= is unset");
638 assert!(!allowed(false, &[(force, "0")]), "{force}=0 doesn't force");
639 assert!(allowed(true, &[(force, "0")]), "{force}=0 doesn't forbid");
640 assert!(allowed(false, &[(force, "1"), ("NO_COLOR", "")]));
641 assert!(!allowed(false, &[(force, "1"), ("NO_COLOR", "1")]));
642 assert!(!allowed(true, &[(force, "1"), ("NO_COLOR", "1")]));
643 }
644 }
645
646 #[test]
649 fn a_long_line_is_cut_around_the_label() {
650 let pad = "x".repeat(500_000);
651 let source = format!("commons c\n{pad}bad{pad}\n");
652 let start = source.find("bad").unwrap();
653 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
654 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
655 assert!(
656 rendered.len() < 4_000,
657 "{} bytes:\n{rendered}",
658 rendered.len()
659 );
660 assert!(
661 rendered.contains("probe.bynk:2:500001 "),
662 "the header keeps the column in the file:\n{rendered}"
663 );
664 let line = rendered.lines().find(|l| l.contains("xbadx")).unwrap();
665 assert!(line.contains('…') && line.ends_with('…'), "{line}");
666 let caret = rendered.lines().find(|l| l.contains('┬')).unwrap();
667 let col = |l: &str, s: &str| l[..l.find(s).unwrap()].chars().count();
668 assert!((col(line, "bad")..col(line, "bad") + 3).contains(&col(caret, "┬")));
669 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
671 assert!(render_errors(&[err], &source, "probe.bynk").len() < 8_000);
672 }
673
674 #[test]
677 fn cuts_respect_chars_and_short_lines_are_kept() {
678 let wide = "é".repeat(300); let short = "y".repeat(MAX_LINE);
680 let source = format!("commons c\n{short}\n{wide}bad{wide}\n");
681 let start = source.find("bad").unwrap();
682 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
683 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
684 assert!(rendered.contains("probe.bynk:3:301 "), "{rendered}");
685 let line = rendered.lines().find(|l| l.contains("ébadé")).unwrap();
686 assert!(line.contains("…éé") && line.contains("éé…"), "{line}");
687
688 let span = Span::new(10, 11);
689 let err = CompileError::new("bynk.test.example", span, "here");
690 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
691 assert!(rendered.contains(&short), "{rendered}");
692 }
693
694 #[test]
696 fn a_foreign_label_stays_a_note_on_a_cut_line() {
697 let source = format!("commons c\n{}\n", "z".repeat(1_000));
698 let err = CompileError::new("bynk.test.example", Span::new(10, 11), "here")
699 .with_label(Span::new(5_000, 5_010), "declared in another file");
700 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
701 assert!(
702 rendered.contains("Note: declared in another file"),
703 "{rendered}"
704 );
705 }
706
707 #[test]
711 fn a_label_cut_from_the_window_becomes_a_note() {
712 let source = format!("commons c\nhere{}there\n", "1 ".repeat(100_000));
713 let there = source.find("there").unwrap();
714 let err = CompileError::new("bynk.test.example", Span::new(10, 14), "primary")
715 .with_label(Span::new(there, there + 5), "far label");
716 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
717 assert!(rendered.len() < 4_000, "{rendered}");
718 assert!(rendered.contains("Note: far label"), "{rendered}");
719 assert!(rendered.contains("probe.bynk:2:1 "), "{rendered}");
720 }
721
722 #[test]
724 fn a_cut_crlf_line_keeps_its_line_ending() {
725 let source = format!("commons c\r\n{}bad\r\n", "w".repeat(1_000));
726 let start = source.find("bad").unwrap();
727 let (shown, _) = cropped(
728 &CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad"),
729 &source,
730 );
731 assert!(shown.starts_with("commons c\r\n…"), "{shown:?}");
732 assert!(shown.ends_with("wbad\r\n"), "{shown:?}");
733 }
734
735 #[test]
740 fn a_label_touching_the_window_edge_from_outside_is_a_note() {
741 let source = format!("commons c\n{}\n", "q".repeat(20_000));
742 let p = 10 + 1_000;
743 let err = CompileError::new("bynk.test.example", Span::new(p, p + 1), "primary")
744 .with_label(Span::new(p + MAX_LINE, p + MAX_LINE + 3), "after the cut")
745 .with_label(Span::new(p - CONTEXT - 3, p - CONTEXT), "before the cut")
746 .with_label(Span::new(p + 10_000, p + 10_003), "far away");
747 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
748 for (i, label) in ["after the cut", "before the cut", "far away"]
749 .iter()
750 .enumerate()
751 {
752 let note = format!("Note {}: {label}", i + 1);
753 assert!(rendered.contains(¬e), "{rendered}");
754 }
755 }
756
757 #[test]
761 fn several_errors_and_long_lines_render_independently() {
762 let pad = "x".repeat(5_000);
763 let source = format!("commons c\n{pad}one{pad}\nshort\n{pad}two{pad}\n");
764 let at = |word: &str| source.find(word).unwrap();
765 let err = |word: &str| {
766 CompileError::new("bynk.test.example", Span::new(at(word), at(word) + 3), word)
767 };
768 let errors = [
769 err("one"),
770 CompileError::new(
771 "bynk.test.example",
772 Span::new(at("short"), at("short") + 5),
773 "s",
774 ),
775 err("two"),
776 ];
777 let rendered = render_errors_plain(&errors, &source, "probe.bynk");
778 assert!(rendered.len() < 8_000, "{rendered}");
779 for header in [
780 "probe.bynk:2:5001 ",
781 "probe.bynk:3:1 ",
782 "probe.bynk:4:5001 ",
783 ] {
784 assert!(rendered.contains(header), "{header}:\n{rendered}");
785 }
786 assert!(
787 rendered
788 .lines()
789 .any(|l| l.contains("xonex") && l.ends_with('…'))
790 );
791 assert!(
792 rendered
793 .lines()
794 .any(|l| l.contains("xtwox") && l.ends_with('…'))
795 );
796 }
797
798 #[test]
801 fn the_coloured_header_keeps_the_column_in_the_file() {
802 let source = format!("commons c\n{}bad\n", "x".repeat(50_000));
803 let start = source.find("bad").unwrap();
804 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
805 let rendered = render_errors(&[err], &source, "probe.bynk");
806 assert!(rendered.contains("probe.bynk:2:50001 "), "{rendered}");
807 }
808
809 #[test]
812 fn the_header_fix_leaves_the_message_alone() {
813 let source = format!("commons c\n{}bad\n", "x".repeat(1_000));
814 let start = source.find("bad").unwrap();
815 let err = CompileError::new(
817 "bynk.test.example",
818 Span::new(start, start + 3),
819 "see probe.bynk:2:82",
820 );
821 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
822 assert!(
823 rendered.contains("Error: see probe.bynk:2:82"),
824 "{rendered}"
825 );
826 assert!(rendered.contains("[ probe.bynk:2:1001 ]"), "{rendered}");
827 }
828
829 #[test]
832 fn a_cut_crlf_report_keeps_line_numbers() {
833 let source = format!("commons c\r\n\r\n{}bad\r\nnext\r\n", "w".repeat(1_000));
834 let start = source.find("bad").unwrap();
835 let err = CompileError::new("bynk.test.example", Span::new(start, start + 3), "bad");
836 let rendered = render_errors_plain(&[err], &source, "probe.bynk");
837 assert!(rendered.contains("[ probe.bynk:3:1001 ]"), "{rendered}");
838 assert!(
839 rendered
840 .lines()
841 .any(|l| l.starts_with(" 3 │ …") && l.contains("wbad")),
842 "{rendered}"
843 );
844 }
845
846 #[test]
850 fn a_label_in_another_file_renders_against_that_file() {
851 let primary = "context app\n\n\nservice api {}\n";
852 let sibling = "context app\nservice api {}\n";
853 let at = |text: &str| text.find("api").unwrap();
854 let err = CompileError::new(
855 "bynk.resolve.duplicate_service",
856 Span::new(at(primary), at(primary) + 3),
857 "already declared",
858 )
859 .with_label(
860 Span::new(at(sibling), at(sibling) + 3),
861 "previously declared here",
862 )
863 .with_label(Span::new(0, 1), "in an injected unit");
864 let homes = [
865 LabelHome::File {
866 filename: "a.bynk",
867 source: sibling,
868 },
869 LabelHome::Unavailable,
870 ];
871 let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
872 let (z, a) = rendered.split_once("─[ a.bynk:2:9 ]").expect(&rendered);
873 assert!(z.contains("─[ z.bynk:4:9 ]"), "{rendered}");
874 assert!(!z.contains("previously declared here"), "{rendered}");
875 assert!(a.contains(" 2 │ service api {}"), "{rendered}");
876 assert!(a.contains("previously declared here"), "{rendered}");
877 assert!(rendered.contains("Note: in an injected unit"), "{rendered}");
878
879 let long = format!("context app\n{} service api {{}}\n", "x".repeat(MAX_LINE));
880 let err = CompileError::new("bynk.test", Span::new(0, 7), "msg")
881 .with_label(Span::new(at(&long), at(&long) + 3), "on a long line");
882 let homes = [LabelHome::File {
883 filename: "a.bynk",
884 source: &long,
885 }];
886 let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
887 assert!(!rendered.contains("a.bynk"), "{rendered}");
888 assert!(rendered.contains("Note: on a long line"), "{rendered}");
889 }
890
891 #[test]
897 fn a_label_in_another_file_after_non_ascii_text_is_headed_by_its_own_position() {
898 let primary = "context app\n\n\nservice api {}\n";
899 let sibling = "context app\n// ———\nservice api {} // é\n";
900 let at = |text: &str| text.find("api").unwrap();
901 let err = CompileError::new("bynk.test", Span::new(at(primary), at(primary) + 3), "msg")
902 .with_label(
903 Span::new(at(sibling), at(sibling) + 3),
904 "previously declared here",
905 );
906 let homes = [LabelHome::File {
907 filename: "a.bynk",
908 source: sibling,
909 }];
910 let rendered = render_error_in_plain(&err, primary, "z.bynk", &homes);
911 let (z, a) = rendered.split_once("─[ a.bynk:3:9 ]").expect(&rendered);
912 assert!(z.contains("─[ z.bynk:4:9 ]"), "{rendered}");
913 assert!(a.contains(" 3 │ service api {} // é"), "{rendered}");
914 assert!(a.contains("previously declared here"), "{rendered}");
915 }
916
917 #[test]
921 fn a_cut_primary_line_keeps_its_column_beside_a_label_in_another_file() {
922 let primary = format!(
923 "context app\n{} service api {{}}\n",
924 "x".repeat(2 * MAX_LINE)
925 );
926 let sibling = "context app\nservice api {}\n";
927 let at = |text: &str| text.find("api").unwrap();
928 let err = CompileError::new(
929 "bynk.resolve.duplicate_service",
930 Span::new(at(&primary), at(&primary) + 3),
931 "already declared",
932 )
933 .with_label(
934 Span::new(at(sibling), at(sibling) + 3),
935 "previously declared here",
936 );
937 let homes = [LabelHome::File {
938 filename: "a.bynk",
939 source: sibling,
940 }];
941 let rendered = render_error_in_plain(&err, &primary, "z.bynk", &homes);
942 let col = at(&primary) - primary.find('\n').unwrap();
943 let (z, a) = rendered.split_once("─[ a.bynk:2:9 ]").expect(&rendered);
944 assert!(z.contains(&format!("─[ z.bynk:2:{col} ]")), "{rendered}");
945 assert!(z.contains('…'), "the long primary line is cut: {rendered}");
946 assert!(!z.contains("previously declared here"), "{rendered}");
947 assert!(a.contains("previously declared here"), "{rendered}");
948 }
949}