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