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