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