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