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