1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::diagnostic_codes::Repair;
7use crate::ParserError;
8
9pub struct RelatedSpanLabel<'a> {
10 pub span: &'a Span,
11 pub label: &'a str,
12}
13
14pub fn normalize_diagnostic_path(path: &str) -> String {
20 let posix = path.replace('\\', "/");
21 if posix.is_empty() {
22 return String::new();
23 }
24
25 let bytes = posix.as_bytes();
26 let mut drive = "";
27 let mut rest = posix.as_str();
28 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
29 drive = &posix[..2];
30 rest = &posix[2..];
31 }
32
33 let absolute = rest.starts_with('/');
34 let mut stack: Vec<&str> = Vec::new();
35 for segment in rest.split('/').filter(|segment| !segment.is_empty()) {
36 match segment {
37 "." => {}
38 ".." => {
39 if let Some(top) = stack.last() {
40 if *top != ".." {
41 stack.pop();
42 continue;
43 }
44 }
45 if !absolute {
46 stack.push("..");
47 }
48 }
49 _ => stack.push(segment),
50 }
51 }
52
53 let mut normalized = String::new();
54 normalized.push_str(drive);
55 if absolute {
56 normalized.push('/');
57 }
58 normalized.push_str(&stack.join("/"));
59 if normalized.is_empty() {
60 ".".to_string()
61 } else {
62 normalized
63 }
64}
65
66pub fn edit_distance(a: &str, b: &str) -> usize {
68 let a_chars: Vec<char> = a.chars().collect();
69 let b_chars: Vec<char> = b.chars().collect();
70 let n = b_chars.len();
71 let mut prev = (0..=n).collect::<Vec<_>>();
72 let mut curr = vec![0; n + 1];
73 for (i, ac) in a_chars.iter().enumerate() {
74 curr[0] = i + 1;
75 for (j, bc) in b_chars.iter().enumerate() {
76 let cost = usize::from(ac != bc);
77 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
78 }
79 std::mem::swap(&mut prev, &mut curr);
80 }
81 prev[n]
82}
83
84fn has_same_snake_case_segments(a: &str, b: &str) -> bool {
85 if !a.contains('_') || !b.contains('_') {
86 return false;
87 }
88 let mut a_segments: Vec<_> = a.split('_').collect();
89 let mut b_segments: Vec<_> = b.split('_').collect();
90 if a_segments.len() < 2
91 || a_segments.len() != b_segments.len()
92 || a_segments.iter().any(|segment| segment.is_empty())
93 || b_segments.iter().any(|segment| segment.is_empty())
94 {
95 return false;
96 }
97 a_segments.sort_unstable();
98 b_segments.sort_unstable();
99 a_segments == b_segments
100}
101
102pub fn find_closest_match<'a>(
106 name: &str,
107 candidates: impl Iterator<Item = &'a str>,
108 max_dist: usize,
109) -> Option<&'a str> {
110 candidates
111 .filter(|candidate| *candidate != name)
112 .filter_map(|candidate| {
113 let reordered = has_same_snake_case_segments(name, candidate);
114 if candidate.len().abs_diff(name.len()) > max_dist && !reordered {
115 return None;
116 }
117 let distance = edit_distance(name, candidate);
118 (distance <= max_dist || reordered).then_some((distance, candidate))
119 })
120 .min_by_key(|(distance, _)| *distance)
121 .map(|(_, candidate)| candidate)
122}
123
124pub fn renamed_stdlib_symbol(name: &str) -> Option<&'static str> {
126 match name {
127 "retry_with_backoff" => Some("retry_predicate_with_backoff"),
128 "print" => Some("harness.stdio.print"),
129 "println" => Some("harness.stdio.println"),
130 "eprint" => Some("harness.stdio.eprint"),
131 "eprintln" => Some("harness.stdio.eprintln"),
132 "read_line" => Some("harness.stdio.read_line"),
133 "prompt_user" => Some("harness.stdio.prompt"),
134 _ => None,
135 }
136}
137
138pub fn harness_clock_replacement(name: &str) -> Option<&'static str> {
145 match name {
146 "now_ms" => Some("harness.clock.now_ms"),
147 "monotonic_ms" => Some("harness.clock.monotonic_ms"),
148 "sleep_ms" => Some("harness.clock.sleep_ms"),
149 "timestamp" => Some("harness.clock.timestamp"),
150 "elapsed" => Some("harness.clock.elapsed"),
151 _ => None,
152 }
153}
154
155pub fn harness_stdio_replacement(name: &str) -> Option<&'static str> {
159 match name {
160 "print" => Some("harness.stdio.print"),
161 "println" => Some("harness.stdio.println"),
162 "eprint" => Some("harness.stdio.eprint"),
163 "eprintln" => Some("harness.stdio.eprintln"),
164 "read_line" => Some("harness.stdio.read_line"),
165 "prompt_user" => Some("harness.stdio.prompt"),
166 _ => None,
167 }
168}
169
170pub fn harness_fs_replacement(name: &str) -> Option<&'static str> {
174 crate::harness_methods::harness_fs_replacement(name)
175}
176
177pub fn harness_env_replacement(name: &str) -> Option<&'static str> {
180 match name {
181 "env" => Some("harness.env.get"),
182 "env_or" => Some("harness.env.get_or"),
183 _ => None,
184 }
185}
186
187pub fn harness_random_replacement(name: &str) -> Option<&'static str> {
190 match name {
191 "random" => Some("harness.random.gen_f64"),
192 "random_int" => Some("harness.random.gen_range"),
193 "random_choice" => Some("harness.random.choice"),
194 "random_shuffle" => Some("harness.random.shuffle"),
195 _ => None,
196 }
197}
198
199pub fn harness_net_replacement(name: &str) -> Option<&'static str> {
204 match name {
205 "http_get" => Some("harness.net.get"),
206 "http_post" => Some("harness.net.post"),
207 "http_put" => Some("harness.net.put"),
208 "http_patch" => Some("harness.net.patch"),
209 "http_delete" => Some("harness.net.delete"),
210 "http_request" => Some("harness.net.request"),
211 "http_download" => Some("harness.net.download"),
212 _ => None,
213 }
214}
215
216pub fn render_diagnostic(
227 source: &str,
228 filename: &str,
229 span: &Span,
230 severity: &str,
231 message: &str,
232 label: Option<&str>,
233 help: Option<&str>,
234) -> String {
235 render_diagnostic_inner(RenderDiagnostic {
236 source,
237 filename,
238 span,
239 severity,
240 code: None,
241 message,
242 label,
243 help,
244 related: &[],
245 repair: None,
246 })
247}
248
249pub fn render_diagnostic_with_code(
250 source: &str,
251 filename: &str,
252 span: &Span,
253 severity: &str,
254 code: crate::diagnostic_codes::Code,
255 message: &str,
256 label: Option<&str>,
257 help: Option<&str>,
258) -> String {
259 let repair_owned = code.repair_template().map(Repair::from_template);
260 render_diagnostic_inner(RenderDiagnostic {
261 source,
262 filename,
263 span,
264 severity,
265 code: Some(code.as_str()),
266 message,
267 label,
268 help,
269 related: &[],
270 repair: repair_owned.as_ref(),
271 })
272}
273
274pub fn render_diagnostic_with_related(
275 source: &str,
276 filename: &str,
277 span: &Span,
278 severity: &str,
279 message: &str,
280 label: Option<&str>,
281 help: Option<&str>,
282 related: &[RelatedSpanLabel<'_>],
283) -> String {
284 render_diagnostic_inner(RenderDiagnostic {
285 source,
286 filename,
287 span,
288 severity,
289 code: None,
290 message,
291 label,
292 help,
293 related,
294 repair: None,
295 })
296}
297
298struct RenderDiagnostic<'a> {
299 source: &'a str,
300 filename: &'a str,
301 span: &'a Span,
302 severity: &'a str,
303 code: Option<&'a str>,
304 message: &'a str,
305 label: Option<&'a str>,
306 help: Option<&'a str>,
307 related: &'a [RelatedSpanLabel<'a>],
308 repair: Option<&'a Repair>,
309}
310
311fn render_diagnostic_inner(input: RenderDiagnostic<'_>) -> String {
312 let mut out = String::new();
313 let source = input.source;
314 let span = input.span;
315 let severity = input.severity;
316 let message = input.message;
317 let label = input.label;
318 let help = input.help;
319 let related = input.related;
320 let filename = normalize_diagnostic_path(input.filename);
321 let severity_color = severity_color(severity);
322 let gutter = style_fragment("|", Color::Blue, false);
323 let arrow = style_fragment("-->", Color::Blue, true);
324 let help_prefix = style_fragment("help", Color::Cyan, true);
325 let note_prefix = style_fragment("note", Color::Magenta, true);
326
327 out.push_str(&style_fragment(severity, severity_color, true));
328 if let Some(code) = input.code {
329 out.push('[');
330 out.push_str(code);
331 out.push(']');
332 }
333 out.push_str(": ");
334 out.push_str(message);
335 out.push('\n');
336
337 let line_num = span.line;
338 let col_num = span.column;
339
340 let gutter_width = line_num.to_string().len();
341
342 out.push_str(&format!(
343 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
344 " ",
345 width = gutter_width + 1,
346 ));
347
348 out.push_str(&format!(
349 "{:>width$} {gutter}\n",
350 " ",
351 width = gutter_width + 1,
352 ));
353
354 let source_line_opt = line_num.checked_sub(1).and_then(|n| source.lines().nth(n));
355 if let Some(source_line) = source_line_opt {
356 out.push_str(&format!(
357 "{:>width$} {gutter} {source_line}\n",
358 line_num,
359 width = gutter_width + 1,
360 ));
361
362 if let Some(label_text) = label {
363 let span_len = if span.end > span.start && span.start <= source.len() {
365 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
366 span_text.chars().count().max(1)
367 } else {
368 1
369 };
370 let col_num = col_num.max(1);
371 let padding = " ".repeat(col_num - 1);
372 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
373 out.push_str(&format!(
374 "{:>width$} {gutter} {padding}{carets} {label_text}\n",
375 " ",
376 width = gutter_width + 1,
377 ));
378 }
379 }
380
381 if let Some(help_text) = help {
382 out.push_str(&format!(
383 "{:>width$} = {help_prefix}: {help_text}\n",
384 " ",
385 width = gutter_width + 1,
386 ));
387 }
388
389 if let Some(repair) = input.repair {
390 let repair_prefix = style_fragment("repair", Color::Cyan, true);
391 out.push_str(&format!(
392 "{:>width$} = {repair_prefix}: {} [{}] — {}\n",
393 " ",
394 repair.id,
395 repair.safety,
396 repair.summary,
397 width = gutter_width + 1,
398 ));
399 }
400
401 for item in related {
402 out.push_str(&format!(
403 "{:>width$} = {note_prefix}: {}\n",
404 " ",
405 item.label,
406 width = gutter_width + 1,
407 ));
408 render_related_span(
409 &mut out,
410 source,
411 &filename,
412 item.span,
413 item.label,
414 gutter_width,
415 );
416 }
417
418 if let Some(note_text) = fun_note(severity) {
419 out.push_str(&format!(
420 "{:>width$} = {note_prefix}: {note_text}\n",
421 " ",
422 width = gutter_width + 1,
423 ));
424 }
425
426 out
427}
428
429pub fn render_type_diagnostic(
430 source: &str,
431 filename: &str,
432 diag: &crate::typechecker::TypeDiagnostic,
433) -> String {
434 let severity = match diag.severity {
435 crate::typechecker::DiagnosticSeverity::Error => "error",
436 crate::typechecker::DiagnosticSeverity::Warning => "warning",
437 };
438 let related = diag
439 .related
440 .iter()
441 .map(|related| RelatedSpanLabel {
442 span: &related.span,
443 label: &related.message,
444 })
445 .collect::<Vec<_>>();
446 let primary_label = type_diagnostic_primary_label(diag);
447 match &diag.span {
448 Some(span) => render_diagnostic_inner(RenderDiagnostic {
449 source,
450 filename,
451 span,
452 severity,
453 code: Some(diag.code.as_str()),
454 message: &diag.message,
455 label: primary_label.as_deref(),
456 help: diag.help.as_deref(),
457 related: &related,
458 repair: diag.repair.as_ref(),
459 }),
460 None => match diag.repair.as_ref() {
461 Some(repair) => format!(
462 "{severity}[{}]: {}\n = repair: {} [{}] — {}\n",
463 diag.code, diag.message, repair.id, repair.safety, repair.summary,
464 ),
465 None => format!("{severity}[{}]: {}\n", diag.code, diag.message),
466 },
467 }
468}
469
470pub fn lexer_error_code(err: &harn_lexer::LexerError) -> crate::diagnostic_codes::Code {
471 match err {
472 harn_lexer::LexerError::UnexpectedCharacter(_, _) => {
473 crate::diagnostic_codes::Code::ParserUnexpectedCharacter
474 }
475 harn_lexer::LexerError::UnterminatedString(_) => {
476 crate::diagnostic_codes::Code::ParserUnterminatedString
477 }
478 harn_lexer::LexerError::UnterminatedBlockComment(_) => {
479 crate::diagnostic_codes::Code::ParserUnterminatedBlockComment
480 }
481 harn_lexer::LexerError::IntegerLiteralOutOfRange(_, _) => {
482 crate::diagnostic_codes::Code::ParserIntegerLiteralOutOfRange
483 }
484 }
485}
486
487pub fn parser_error_code(err: &crate::parser::ParserError) -> crate::diagnostic_codes::Code {
488 match err {
489 crate::parser::ParserError::Unexpected { .. } => {
490 crate::diagnostic_codes::Code::ParserUnexpectedToken
491 }
492 crate::parser::ParserError::UnexpectedEof { .. } => {
493 crate::diagnostic_codes::Code::ParserUnexpectedEof
494 }
495 }
496}
497
498fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
499 match &diag.details {
500 Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
501 Some(format!("lint[{rule}]"))
502 }
503 Some(crate::typechecker::DiagnosticDetails::TypeMismatch) => {
504 Some("found this type".to_string())
505 }
506 _ => None,
507 }
508}
509
510fn render_related_span(
511 out: &mut String,
512 source: &str,
513 filename: &str,
514 span: &Span,
515 label: &str,
516 primary_gutter_width: usize,
517) {
518 let filename = normalize_diagnostic_path(filename);
519 let severity_color = Color::Magenta;
520 let gutter = style_fragment("|", Color::Blue, false);
521 let arrow = style_fragment("-->", Color::Blue, true);
522 let line_num = span.line;
523 let col_num = span.column;
524 let gutter_width = primary_gutter_width.max(line_num.to_string().len());
525
526 out.push_str(&format!(
527 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
528 " ",
529 width = gutter_width + 1,
530 ));
531 out.push_str(&format!(
532 "{:>width$} {gutter}\n",
533 " ",
534 width = gutter_width + 1,
535 ));
536
537 if let Some(source_line) = line_num.checked_sub(1).and_then(|n| source.lines().nth(n)) {
538 out.push_str(&format!(
539 "{:>width$} {gutter} {source_line}\n",
540 line_num,
541 width = gutter_width + 1,
542 ));
543 let span_len = if span.end > span.start && span.start <= source.len() {
544 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
545 span_text.chars().count().max(1)
546 } else {
547 1
548 };
549 let padding = " ".repeat(col_num.max(1) - 1);
550 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
551 out.push_str(&format!(
552 "{:>width$} {gutter} {padding}{carets} {label}\n",
553 " ",
554 width = gutter_width + 1,
555 ));
556 }
557}
558
559fn severity_color(severity: &str) -> Color {
560 match severity {
561 "error" => Color::Red,
562 "warning" => Color::Yellow,
563 "note" => Color::Magenta,
564 _ => Color::Cyan,
565 }
566}
567
568fn style_fragment(text: &str, color: Color, bold: bool) -> String {
569 if !colors_enabled() {
570 return text.to_string();
571 }
572
573 let mut paint = Paint::new(text).fg(color);
574 if bold {
575 paint = paint.bold();
576 }
577 paint.to_string()
578}
579
580thread_local! {
581 static COLOR_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
587}
588
589#[cfg(test)]
592pub(crate) fn set_color_override(force: Option<bool>) {
593 COLOR_OVERRIDE.with(|cell| cell.set(force));
594}
595
596fn colors_enabled() -> bool {
597 if let Some(forced) = COLOR_OVERRIDE.with(std::cell::Cell::get) {
598 return forced;
599 }
600 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
601}
602
603fn fun_note(severity: &str) -> Option<&'static str> {
604 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
605 return None;
606 }
607
608 Some(match severity {
609 "error" => "the compiler stepped on a rake here.",
610 "warning" => "this still runs, but it has strong 'double-check me' energy.",
611 _ => "a tiny gremlin has left a note in the margins.",
612 })
613}
614
615pub fn parser_error_message(err: &ParserError) -> String {
616 match err {
617 ParserError::Unexpected { got, expected, .. } => {
618 format!("expected {expected}, found {got}")
619 }
620 ParserError::UnexpectedEof { expected, .. } => {
621 format!("unexpected end of file, expected {expected}")
622 }
623 }
624}
625
626pub fn parser_error_label(err: &ParserError) -> &'static str {
627 match err {
628 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
629 ParserError::Unexpected { .. } => "unexpected token",
630 ParserError::UnexpectedEof { .. } => "file ends here",
631 }
632}
633
634pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
635 match err {
636 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
637 match expected.as_str() {
638 "}" => Some("add a closing `}` to finish this block"),
639 ")" => Some("add a closing `)` to finish this expression or parameter list"),
640 "]" => Some("add a closing `]` to finish this list or subscript"),
641 "fn, struct, enum, or pipeline after pub" => {
642 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
643 }
644 "fn, tool, skill, eval_pack, struct, enum, type, pipeline, const, let, or import after pub" => Some(
645 "use `pub` with `fn`, `tool`, `skill`, `eval_pack`, `struct`, `enum`, `type`, `pipeline`, `const`, `let`, or `import`",
646 ),
647 _ => None,
648 }
649 }
650 }
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 fn disable_colors() {
662 set_color_override(Some(false));
663 }
664
665 #[test]
666 fn test_basic_diagnostic() {
667 disable_colors();
668 let source = "pipeline default(task) {\n const y = x + 1\n}";
669 let span = Span {
670 start: 28,
671 end: 29,
672 line: 2,
673 column: 13,
674 end_line: 2,
675 };
676 let output = render_diagnostic(
677 source,
678 "example.harn",
679 &span,
680 "error",
681 "undefined variable `x`",
682 Some("not found in this scope"),
683 None,
684 );
685 assert!(output.contains("error: undefined variable `x`"));
686 assert!(output.contains("--> example.harn:2:13"));
687 assert!(output.contains("const y = x + 1"));
688 assert!(output.contains("^ not found in this scope"));
689 }
690
691 #[test]
692 fn test_diagnostic_normalizes_filename() {
693 disable_colors();
694 let source = "const value = thing";
695 let span = Span {
696 start: 12,
697 end: 17,
698 line: 1,
699 column: 13,
700 end_line: 1,
701 };
702 let output = render_diagnostic(
703 source,
704 "/workspace/pipelines/mode/../lib/runtime/loop.harn",
705 &span,
706 "error",
707 "bad value",
708 Some("here"),
709 None,
710 );
711 assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
712 assert!(!output.contains("/../"));
713 }
714
715 #[test]
716 fn test_diagnostic_with_help() {
717 disable_colors();
718 let source = "const y = xx + 1";
719 let span = Span {
720 start: 8,
721 end: 10,
722 line: 1,
723 column: 9,
724 end_line: 1,
725 };
726 let output = render_diagnostic(
727 source,
728 "test.harn",
729 &span,
730 "error",
731 "undefined variable `xx`",
732 Some("not found in this scope"),
733 Some("did you mean `x`?"),
734 );
735 assert!(output.contains("help: did you mean `x`?"));
736 }
737
738 #[test]
739 fn test_multiline_source() {
740 disable_colors();
741 let source = "line1\nline2\nline3";
742 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
744 source,
745 "test.harn",
746 &span,
747 "error",
748 "bad line",
749 Some("here"),
750 None,
751 );
752 assert!(result.contains("line2"));
753 assert!(result.contains("^^^^^"));
754 }
755
756 #[test]
757 fn test_single_char_span() {
758 disable_colors();
759 let source = "const x = 42";
760 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
762 source,
763 "test.harn",
764 &span,
765 "warning",
766 "unused",
767 Some("never used"),
768 None,
769 );
770 assert!(result.contains('^'));
771 assert!(result.contains("never used"));
772 }
773
774 #[test]
775 fn test_with_help() {
776 disable_colors();
777 let source = "const y = reponse";
778 let span = Span::with_offsets(8, 15, 1, 9);
779 let result = render_diagnostic(
780 source,
781 "test.harn",
782 &span,
783 "error",
784 "undefined",
785 None,
786 Some("did you mean `response`?"),
787 );
788 assert!(result.contains("help:"));
789 assert!(result.contains("response"));
790 }
791
792 #[test]
793 fn closest_match_suggests_reordered_snake_case_segments() {
794 assert_eq!(
795 find_closest_match("parse_json", ["json_parse"].into_iter(), 2),
796 Some("json_parse")
797 );
798 assert_eq!(
799 find_closest_match("read_file", ["file_read"].into_iter(), 2),
800 Some("file_read")
801 );
802 assert_eq!(
803 find_closest_match("parse_json", ["parse_yaml"].into_iter(), 2),
804 None
805 );
806 }
807
808 #[test]
809 fn closest_match_suggests_plain_typo() {
810 assert_eq!(
811 find_closest_match("json_pars", ["json_parse"].into_iter(), 2),
812 Some("json_parse")
813 );
814 }
815
816 #[test]
817 fn test_parser_error_helpers_for_eof() {
818 disable_colors();
819 let err = ParserError::UnexpectedEof {
820 expected: "}".into(),
821 span: Span::with_offsets(10, 10, 3, 1),
822 };
823 assert_eq!(
824 parser_error_message(&err),
825 "unexpected end of file, expected }"
826 );
827 assert_eq!(parser_error_label(&err), "file ends here");
828 assert_eq!(
829 parser_error_help(&err),
830 Some("add a closing `}` to finish this block")
831 );
832 }
833}