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 let primary_label = type_diagnostic_primary_label(diag);
238 match &diag.span {
239 Some(span) => render_diagnostic_with_related(
240 source,
241 filename,
242 span,
243 severity,
244 &diag.message,
245 primary_label.as_deref(),
246 diag.help.as_deref(),
247 &related,
248 ),
249 None => format!("{severity}: {}\n", diag.message),
250 }
251}
252
253fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
254 match &diag.details {
255 Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
256 Some(format!("lint[{rule}]"))
257 }
258 Some(crate::typechecker::DiagnosticDetails::TypeMismatch) => {
259 Some("found this type".to_string())
260 }
261 _ => None,
262 }
263}
264
265fn render_related_span(
266 out: &mut String,
267 source: &str,
268 filename: &str,
269 span: &Span,
270 label: &str,
271 primary_gutter_width: usize,
272) {
273 let filename = normalize_diagnostic_path(filename);
274 let severity_color = Color::Magenta;
275 let gutter = style_fragment("|", Color::Blue, false);
276 let arrow = style_fragment("-->", Color::Blue, true);
277 let line_num = span.line;
278 let col_num = span.column;
279 let gutter_width = primary_gutter_width.max(line_num.to_string().len());
280
281 out.push_str(&format!(
282 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
283 " ",
284 width = gutter_width + 1,
285 ));
286 out.push_str(&format!(
287 "{:>width$} {gutter}\n",
288 " ",
289 width = gutter_width + 1,
290 ));
291
292 if let Some(source_line) = source
293 .lines()
294 .nth(line_num.wrapping_sub(1))
295 .filter(|_| line_num > 0)
296 {
297 out.push_str(&format!(
298 "{:>width$} {gutter} {source_line}\n",
299 line_num,
300 width = gutter_width + 1,
301 ));
302 let span_len = if span.end > span.start && span.start <= source.len() {
303 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
304 span_text.chars().count().max(1)
305 } else {
306 1
307 };
308 let padding = " ".repeat(col_num.max(1) - 1);
309 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
310 out.push_str(&format!(
311 "{:>width$} {gutter} {padding}{carets} {label}\n",
312 " ",
313 width = gutter_width + 1,
314 ));
315 }
316}
317
318fn severity_color(severity: &str) -> Color {
319 match severity {
320 "error" => Color::Red,
321 "warning" => Color::Yellow,
322 "note" => Color::Magenta,
323 _ => Color::Cyan,
324 }
325}
326
327fn style_fragment(text: &str, color: Color, bold: bool) -> String {
328 if !colors_enabled() {
329 return text.to_string();
330 }
331
332 let mut paint = Paint::new(text).fg(color);
333 if bold {
334 paint = paint.bold();
335 }
336 paint.to_string()
337}
338
339fn colors_enabled() -> bool {
340 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
341}
342
343fn fun_note(severity: &str) -> Option<&'static str> {
344 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
345 return None;
346 }
347
348 Some(match severity {
349 "error" => "the compiler stepped on a rake here.",
350 "warning" => "this still runs, but it has strong 'double-check me' energy.",
351 _ => "a tiny gremlin has left a note in the margins.",
352 })
353}
354
355pub fn parser_error_message(err: &ParserError) -> String {
356 match err {
357 ParserError::Unexpected { got, expected, .. } => {
358 format!("expected {expected}, found {got}")
359 }
360 ParserError::UnexpectedEof { expected, .. } => {
361 format!("unexpected end of file, expected {expected}")
362 }
363 }
364}
365
366pub fn parser_error_label(err: &ParserError) -> &'static str {
367 match err {
368 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
369 ParserError::Unexpected { .. } => "unexpected token",
370 ParserError::UnexpectedEof { .. } => "file ends here",
371 }
372}
373
374pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
375 match err {
376 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
377 match expected.as_str() {
378 "}" => Some("add a closing `}` to finish this block"),
379 ")" => Some("add a closing `)` to finish this expression or parameter list"),
380 "]" => Some("add a closing `]` to finish this list or subscript"),
381 "fn, struct, enum, or pipeline after pub" => {
382 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
383 }
384 _ => None,
385 }
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn disable_colors() {
397 std::env::set_var("NO_COLOR", "1");
398 }
399
400 #[test]
401 fn test_basic_diagnostic() {
402 disable_colors();
403 let source = "pipeline default(task) {\n let y = x + 1\n}";
404 let span = Span {
405 start: 28,
406 end: 29,
407 line: 2,
408 column: 13,
409 end_line: 2,
410 };
411 let output = render_diagnostic(
412 source,
413 "example.harn",
414 &span,
415 "error",
416 "undefined variable `x`",
417 Some("not found in this scope"),
418 None,
419 );
420 assert!(output.contains("error: undefined variable `x`"));
421 assert!(output.contains("--> example.harn:2:13"));
422 assert!(output.contains("let y = x + 1"));
423 assert!(output.contains("^ not found in this scope"));
424 }
425
426 #[test]
427 fn test_diagnostic_normalizes_filename() {
428 disable_colors();
429 let source = "let value = thing";
430 let span = Span {
431 start: 12,
432 end: 17,
433 line: 1,
434 column: 13,
435 end_line: 1,
436 };
437 let output = render_diagnostic(
438 source,
439 "/workspace/pipelines/mode/../lib/runtime/loop.harn",
440 &span,
441 "error",
442 "bad value",
443 Some("here"),
444 None,
445 );
446 assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
447 assert!(!output.contains("/../"));
448 }
449
450 #[test]
451 fn test_diagnostic_with_help() {
452 disable_colors();
453 let source = "let y = xx + 1";
454 let span = Span {
455 start: 8,
456 end: 10,
457 line: 1,
458 column: 9,
459 end_line: 1,
460 };
461 let output = render_diagnostic(
462 source,
463 "test.harn",
464 &span,
465 "error",
466 "undefined variable `xx`",
467 Some("not found in this scope"),
468 Some("did you mean `x`?"),
469 );
470 assert!(output.contains("help: did you mean `x`?"));
471 }
472
473 #[test]
474 fn test_multiline_source() {
475 disable_colors();
476 let source = "line1\nline2\nline3";
477 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
479 source,
480 "test.harn",
481 &span,
482 "error",
483 "bad line",
484 Some("here"),
485 None,
486 );
487 assert!(result.contains("line2"));
488 assert!(result.contains("^^^^^"));
489 }
490
491 #[test]
492 fn test_single_char_span() {
493 disable_colors();
494 let source = "let x = 42";
495 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
497 source,
498 "test.harn",
499 &span,
500 "warning",
501 "unused",
502 Some("never used"),
503 None,
504 );
505 assert!(result.contains("^"));
506 assert!(result.contains("never used"));
507 }
508
509 #[test]
510 fn test_with_help() {
511 disable_colors();
512 let source = "let y = reponse";
513 let span = Span::with_offsets(8, 15, 1, 9);
514 let result = render_diagnostic(
515 source,
516 "test.harn",
517 &span,
518 "error",
519 "undefined",
520 None,
521 Some("did you mean `response`?"),
522 );
523 assert!(result.contains("help:"));
524 assert!(result.contains("response"));
525 }
526
527 #[test]
528 fn test_parser_error_helpers_for_eof() {
529 disable_colors();
530 let err = ParserError::UnexpectedEof {
531 expected: "}".into(),
532 span: Span::with_offsets(10, 10, 3, 1),
533 };
534 assert_eq!(
535 parser_error_message(&err),
536 "unexpected end of file, expected }"
537 );
538 assert_eq!(parser_error_label(&err), "file ends here");
539 assert_eq!(
540 parser_error_help(&err),
541 Some("add a closing `}` to finish this block")
542 );
543 }
544}