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