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