1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::ParserError;
7
8pub struct RelatedSpanLabel<'a> {
9 pub span: &'a Span,
10 pub label: &'a str,
11}
12
13pub fn normalize_diagnostic_path(path: &str) -> String {
19 let posix = path.replace('\\', "/");
20 if posix.is_empty() {
21 return String::new();
22 }
23
24 let bytes = posix.as_bytes();
25 let mut drive = "";
26 let mut rest = posix.as_str();
27 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
28 drive = &posix[..2];
29 rest = &posix[2..];
30 }
31
32 let absolute = rest.starts_with('/');
33 let mut stack: Vec<&str> = Vec::new();
34 for segment in rest.split('/').filter(|segment| !segment.is_empty()) {
35 match segment {
36 "." => {}
37 ".." => {
38 if let Some(top) = stack.last() {
39 if *top != ".." {
40 stack.pop();
41 continue;
42 }
43 }
44 if !absolute {
45 stack.push("..");
46 }
47 }
48 _ => stack.push(segment),
49 }
50 }
51
52 let mut normalized = String::new();
53 normalized.push_str(drive);
54 if absolute {
55 normalized.push('/');
56 }
57 normalized.push_str(&stack.join("/"));
58 if normalized.is_empty() {
59 ".".to_string()
60 } else {
61 normalized
62 }
63}
64
65pub fn edit_distance(a: &str, b: &str) -> usize {
67 let a_chars: Vec<char> = a.chars().collect();
68 let b_chars: Vec<char> = b.chars().collect();
69 let n = b_chars.len();
70 let mut prev = (0..=n).collect::<Vec<_>>();
71 let mut curr = vec![0; n + 1];
72 for (i, ac) in a_chars.iter().enumerate() {
73 curr[0] = i + 1;
74 for (j, bc) in b_chars.iter().enumerate() {
75 let cost = if ac == bc { 0 } else { 1 };
76 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
77 }
78 std::mem::swap(&mut prev, &mut curr);
79 }
80 prev[n]
81}
82
83pub fn find_closest_match<'a>(
85 name: &str,
86 candidates: impl Iterator<Item = &'a str>,
87 max_dist: usize,
88) -> Option<&'a str> {
89 candidates
90 .filter(|c| c.len().abs_diff(name.len()) <= max_dist)
91 .min_by_key(|c| edit_distance(name, c))
92 .filter(|c| edit_distance(name, c) <= max_dist && *c != name)
93}
94
95pub fn renamed_stdlib_symbol(name: &str) -> Option<&'static str> {
97 match name {
98 "retry_with_backoff" => Some("retry_predicate_with_backoff"),
99 _ => None,
100 }
101}
102
103pub fn render_diagnostic(
114 source: &str,
115 filename: &str,
116 span: &Span,
117 severity: &str,
118 message: &str,
119 label: Option<&str>,
120 help: Option<&str>,
121) -> String {
122 render_diagnostic_with_related(source, filename, span, severity, message, label, help, &[])
123}
124
125pub fn render_diagnostic_with_related(
126 source: &str,
127 filename: &str,
128 span: &Span,
129 severity: &str,
130 message: &str,
131 label: Option<&str>,
132 help: Option<&str>,
133 related: &[RelatedSpanLabel<'_>],
134) -> String {
135 let mut out = String::new();
136 let filename = normalize_diagnostic_path(filename);
137 let severity_color = severity_color(severity);
138 let gutter = style_fragment("|", Color::Blue, false);
139 let arrow = style_fragment("-->", Color::Blue, true);
140 let help_prefix = style_fragment("help", Color::Cyan, true);
141 let note_prefix = style_fragment("note", Color::Magenta, true);
142
143 out.push_str(&style_fragment(severity, severity_color, true));
144 out.push_str(": ");
145 out.push_str(message);
146 out.push('\n');
147
148 let line_num = span.line;
149 let col_num = span.column;
150
151 let gutter_width = line_num.to_string().len();
152
153 out.push_str(&format!(
154 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
155 " ",
156 width = gutter_width + 1,
157 ));
158
159 out.push_str(&format!(
160 "{:>width$} {gutter}\n",
161 " ",
162 width = gutter_width + 1,
163 ));
164
165 let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
166 if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
167 out.push_str(&format!(
168 "{:>width$} {gutter} {source_line}\n",
169 line_num,
170 width = gutter_width + 1,
171 ));
172
173 if let Some(label_text) = label {
174 let span_len = if span.end > span.start && span.start <= source.len() {
176 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
177 span_text.chars().count().max(1)
178 } else {
179 1
180 };
181 let col_num = col_num.max(1);
182 let padding = " ".repeat(col_num - 1);
183 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
184 out.push_str(&format!(
185 "{:>width$} {gutter} {padding}{carets} {label_text}\n",
186 " ",
187 width = gutter_width + 1,
188 ));
189 }
190 }
191
192 if let Some(help_text) = help {
193 out.push_str(&format!(
194 "{:>width$} = {help_prefix}: {help_text}\n",
195 " ",
196 width = gutter_width + 1,
197 ));
198 }
199
200 for item in related {
201 out.push_str(&format!(
202 "{:>width$} = {note_prefix}: {}\n",
203 " ",
204 item.label,
205 width = gutter_width + 1,
206 ));
207 render_related_span(
208 &mut out,
209 source,
210 &filename,
211 item.span,
212 item.label,
213 gutter_width,
214 );
215 }
216
217 if let Some(note_text) = fun_note(severity) {
218 out.push_str(&format!(
219 "{:>width$} = {note_prefix}: {note_text}\n",
220 " ",
221 width = gutter_width + 1,
222 ));
223 }
224
225 out
226}
227
228pub fn render_type_diagnostic(
229 source: &str,
230 filename: &str,
231 diag: &crate::typechecker::TypeDiagnostic,
232) -> String {
233 let severity = match diag.severity {
234 crate::typechecker::DiagnosticSeverity::Error => "error",
235 crate::typechecker::DiagnosticSeverity::Warning => "warning",
236 };
237 let related = diag
238 .related
239 .iter()
240 .map(|related| RelatedSpanLabel {
241 span: &related.span,
242 label: &related.message,
243 })
244 .collect::<Vec<_>>();
245 let primary_label = type_diagnostic_primary_label(diag);
246 match &diag.span {
247 Some(span) => render_diagnostic_with_related(
248 source,
249 filename,
250 span,
251 severity,
252 &diag.message,
253 primary_label.as_deref(),
254 diag.help.as_deref(),
255 &related,
256 ),
257 None => format!("{severity}: {}\n", diag.message),
258 }
259}
260
261fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
262 match &diag.details {
263 Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
264 Some(format!("lint[{rule}]"))
265 }
266 Some(crate::typechecker::DiagnosticDetails::TypeMismatch) => {
267 Some("found this type".to_string())
268 }
269 _ => None,
270 }
271}
272
273fn render_related_span(
274 out: &mut String,
275 source: &str,
276 filename: &str,
277 span: &Span,
278 label: &str,
279 primary_gutter_width: usize,
280) {
281 let filename = normalize_diagnostic_path(filename);
282 let severity_color = Color::Magenta;
283 let gutter = style_fragment("|", Color::Blue, false);
284 let arrow = style_fragment("-->", Color::Blue, true);
285 let line_num = span.line;
286 let col_num = span.column;
287 let gutter_width = primary_gutter_width.max(line_num.to_string().len());
288
289 out.push_str(&format!(
290 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
291 " ",
292 width = gutter_width + 1,
293 ));
294 out.push_str(&format!(
295 "{:>width$} {gutter}\n",
296 " ",
297 width = gutter_width + 1,
298 ));
299
300 if let Some(source_line) = source
301 .lines()
302 .nth(line_num.wrapping_sub(1))
303 .filter(|_| line_num > 0)
304 {
305 out.push_str(&format!(
306 "{:>width$} {gutter} {source_line}\n",
307 line_num,
308 width = gutter_width + 1,
309 ));
310 let span_len = if span.end > span.start && span.start <= source.len() {
311 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
312 span_text.chars().count().max(1)
313 } else {
314 1
315 };
316 let padding = " ".repeat(col_num.max(1) - 1);
317 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
318 out.push_str(&format!(
319 "{:>width$} {gutter} {padding}{carets} {label}\n",
320 " ",
321 width = gutter_width + 1,
322 ));
323 }
324}
325
326fn severity_color(severity: &str) -> Color {
327 match severity {
328 "error" => Color::Red,
329 "warning" => Color::Yellow,
330 "note" => Color::Magenta,
331 _ => Color::Cyan,
332 }
333}
334
335fn style_fragment(text: &str, color: Color, bold: bool) -> String {
336 if !colors_enabled() {
337 return text.to_string();
338 }
339
340 let mut paint = Paint::new(text).fg(color);
341 if bold {
342 paint = paint.bold();
343 }
344 paint.to_string()
345}
346
347fn colors_enabled() -> bool {
348 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
349}
350
351fn fun_note(severity: &str) -> Option<&'static str> {
352 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
353 return None;
354 }
355
356 Some(match severity {
357 "error" => "the compiler stepped on a rake here.",
358 "warning" => "this still runs, but it has strong 'double-check me' energy.",
359 _ => "a tiny gremlin has left a note in the margins.",
360 })
361}
362
363pub fn parser_error_message(err: &ParserError) -> String {
364 match err {
365 ParserError::Unexpected { got, expected, .. } => {
366 format!("expected {expected}, found {got}")
367 }
368 ParserError::UnexpectedEof { expected, .. } => {
369 format!("unexpected end of file, expected {expected}")
370 }
371 }
372}
373
374pub fn parser_error_label(err: &ParserError) -> &'static str {
375 match err {
376 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
377 ParserError::Unexpected { .. } => "unexpected token",
378 ParserError::UnexpectedEof { .. } => "file ends here",
379 }
380}
381
382pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
383 match err {
384 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
385 match expected.as_str() {
386 "}" => Some("add a closing `}` to finish this block"),
387 ")" => Some("add a closing `)` to finish this expression or parameter list"),
388 "]" => Some("add a closing `]` to finish this list or subscript"),
389 "fn, struct, enum, or pipeline after pub" => {
390 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
391 }
392 _ => None,
393 }
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn disable_colors() {
405 std::env::set_var("NO_COLOR", "1");
406 }
407
408 #[test]
409 fn test_basic_diagnostic() {
410 disable_colors();
411 let source = "pipeline default(task) {\n let y = x + 1\n}";
412 let span = Span {
413 start: 28,
414 end: 29,
415 line: 2,
416 column: 13,
417 end_line: 2,
418 };
419 let output = render_diagnostic(
420 source,
421 "example.harn",
422 &span,
423 "error",
424 "undefined variable `x`",
425 Some("not found in this scope"),
426 None,
427 );
428 assert!(output.contains("error: undefined variable `x`"));
429 assert!(output.contains("--> example.harn:2:13"));
430 assert!(output.contains("let y = x + 1"));
431 assert!(output.contains("^ not found in this scope"));
432 }
433
434 #[test]
435 fn test_diagnostic_normalizes_filename() {
436 disable_colors();
437 let source = "let value = thing";
438 let span = Span {
439 start: 12,
440 end: 17,
441 line: 1,
442 column: 13,
443 end_line: 1,
444 };
445 let output = render_diagnostic(
446 source,
447 "/workspace/pipelines/mode/../lib/runtime/loop.harn",
448 &span,
449 "error",
450 "bad value",
451 Some("here"),
452 None,
453 );
454 assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
455 assert!(!output.contains("/../"));
456 }
457
458 #[test]
459 fn test_diagnostic_with_help() {
460 disable_colors();
461 let source = "let y = xx + 1";
462 let span = Span {
463 start: 8,
464 end: 10,
465 line: 1,
466 column: 9,
467 end_line: 1,
468 };
469 let output = render_diagnostic(
470 source,
471 "test.harn",
472 &span,
473 "error",
474 "undefined variable `xx`",
475 Some("not found in this scope"),
476 Some("did you mean `x`?"),
477 );
478 assert!(output.contains("help: did you mean `x`?"));
479 }
480
481 #[test]
482 fn test_multiline_source() {
483 disable_colors();
484 let source = "line1\nline2\nline3";
485 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
487 source,
488 "test.harn",
489 &span,
490 "error",
491 "bad line",
492 Some("here"),
493 None,
494 );
495 assert!(result.contains("line2"));
496 assert!(result.contains("^^^^^"));
497 }
498
499 #[test]
500 fn test_single_char_span() {
501 disable_colors();
502 let source = "let x = 42";
503 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
505 source,
506 "test.harn",
507 &span,
508 "warning",
509 "unused",
510 Some("never used"),
511 None,
512 );
513 assert!(result.contains("^"));
514 assert!(result.contains("never used"));
515 }
516
517 #[test]
518 fn test_with_help() {
519 disable_colors();
520 let source = "let y = reponse";
521 let span = Span::with_offsets(8, 15, 1, 9);
522 let result = render_diagnostic(
523 source,
524 "test.harn",
525 &span,
526 "error",
527 "undefined",
528 None,
529 Some("did you mean `response`?"),
530 );
531 assert!(result.contains("help:"));
532 assert!(result.contains("response"));
533 }
534
535 #[test]
536 fn test_parser_error_helpers_for_eof() {
537 disable_colors();
538 let err = ParserError::UnexpectedEof {
539 expected: "}".into(),
540 span: Span::with_offsets(10, 10, 3, 1),
541 };
542 assert_eq!(
543 parser_error_message(&err),
544 "unexpected end of file, expected }"
545 );
546 assert_eq!(parser_error_label(&err), "file ends here");
547 assert_eq!(
548 parser_error_help(&err),
549 Some("add a closing `}` to finish this block")
550 );
551 }
552}