1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::ParserError;
7
8pub fn edit_distance(a: &str, b: &str) -> usize {
10 let a_chars: Vec<char> = a.chars().collect();
11 let b_chars: Vec<char> = b.chars().collect();
12 let n = b_chars.len();
13 let mut prev = (0..=n).collect::<Vec<_>>();
14 let mut curr = vec![0; n + 1];
15 for (i, ac) in a_chars.iter().enumerate() {
16 curr[0] = i + 1;
17 for (j, bc) in b_chars.iter().enumerate() {
18 let cost = if ac == bc { 0 } else { 1 };
19 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
20 }
21 std::mem::swap(&mut prev, &mut curr);
22 }
23 prev[n]
24}
25
26pub fn find_closest_match<'a>(
28 name: &str,
29 candidates: impl Iterator<Item = &'a str>,
30 max_dist: usize,
31) -> Option<&'a str> {
32 candidates
33 .filter(|c| c.len().abs_diff(name.len()) <= max_dist)
34 .min_by_key(|c| edit_distance(name, c))
35 .filter(|c| edit_distance(name, c) <= max_dist && *c != name)
36}
37
38pub fn render_diagnostic(
49 source: &str,
50 filename: &str,
51 span: &Span,
52 severity: &str,
53 message: &str,
54 label: Option<&str>,
55 help: Option<&str>,
56) -> String {
57 let mut out = String::new();
58 let severity_color = severity_color(severity);
59 let gutter = style_fragment("|", Color::Blue, false);
60 let arrow = style_fragment("-->", Color::Blue, true);
61 let help_prefix = style_fragment("help", Color::Cyan, true);
62 let note_prefix = style_fragment("note", Color::Magenta, true);
63
64 out.push_str(&style_fragment(severity, severity_color, true));
65 out.push_str(": ");
66 out.push_str(message);
67 out.push('\n');
68
69 let line_num = span.line;
70 let col_num = span.column;
71
72 let gutter_width = line_num.to_string().len();
73
74 out.push_str(&format!(
75 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
76 " ",
77 width = gutter_width + 1,
78 ));
79
80 out.push_str(&format!(
81 "{:>width$} {gutter}\n",
82 " ",
83 width = gutter_width + 1,
84 ));
85
86 let source_line_opt = source.lines().nth(line_num.wrapping_sub(1));
87 if let Some(source_line) = source_line_opt.filter(|_| line_num > 0) {
88 out.push_str(&format!(
89 "{:>width$} {gutter} {source_line}\n",
90 line_num,
91 width = gutter_width + 1,
92 ));
93
94 if let Some(label_text) = label {
95 let span_len = if span.end > span.start && span.start <= source.len() {
97 let span_text = &source[span.start.min(source.len())..span.end.min(source.len())];
98 span_text.chars().count().max(1)
99 } else {
100 1
101 };
102 let col_num = col_num.max(1);
103 let padding = " ".repeat(col_num - 1);
104 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
105 out.push_str(&format!(
106 "{:>width$} {gutter} {padding}{carets} {label_text}\n",
107 " ",
108 width = gutter_width + 1,
109 ));
110 }
111 }
112
113 if let Some(help_text) = help {
114 out.push_str(&format!(
115 "{:>width$} = {help_prefix}: {help_text}\n",
116 " ",
117 width = gutter_width + 1,
118 ));
119 }
120
121 if let Some(note_text) = fun_note(severity) {
122 out.push_str(&format!(
123 "{:>width$} = {note_prefix}: {note_text}\n",
124 " ",
125 width = gutter_width + 1,
126 ));
127 }
128
129 out
130}
131
132fn severity_color(severity: &str) -> Color {
133 match severity {
134 "error" => Color::Red,
135 "warning" => Color::Yellow,
136 "note" => Color::Magenta,
137 _ => Color::Cyan,
138 }
139}
140
141fn style_fragment(text: &str, color: Color, bold: bool) -> String {
142 if !colors_enabled() {
143 return text.to_string();
144 }
145
146 let mut paint = Paint::new(text).fg(color);
147 if bold {
148 paint = paint.bold();
149 }
150 paint.to_string()
151}
152
153fn colors_enabled() -> bool {
154 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
155}
156
157fn fun_note(severity: &str) -> Option<&'static str> {
158 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
159 return None;
160 }
161
162 Some(match severity {
163 "error" => "the compiler stepped on a rake here.",
164 "warning" => "this still runs, but it has strong 'double-check me' energy.",
165 _ => "a tiny gremlin has left a note in the margins.",
166 })
167}
168
169pub fn parser_error_message(err: &ParserError) -> String {
170 match err {
171 ParserError::Unexpected { got, expected, .. } => {
172 format!("expected {expected}, found {got}")
173 }
174 ParserError::UnexpectedEof { expected, .. } => {
175 format!("unexpected end of file, expected {expected}")
176 }
177 }
178}
179
180pub fn parser_error_label(err: &ParserError) -> &'static str {
181 match err {
182 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
183 ParserError::Unexpected { .. } => "unexpected token",
184 ParserError::UnexpectedEof { .. } => "file ends here",
185 }
186}
187
188pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
189 match err {
190 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
191 match expected.as_str() {
192 "}" => Some("add a closing `}` to finish this block"),
193 ")" => Some("add a closing `)` to finish this expression or parameter list"),
194 "]" => Some("add a closing `]` to finish this list or subscript"),
195 "fn, struct, enum, or pipeline after pub" => {
196 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
197 }
198 _ => None,
199 }
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn disable_colors() {
211 std::env::set_var("NO_COLOR", "1");
212 }
213
214 #[test]
215 fn test_basic_diagnostic() {
216 disable_colors();
217 let source = "pipeline default(task) {\n let y = x + 1\n}";
218 let span = Span {
219 start: 28,
220 end: 29,
221 line: 2,
222 column: 13,
223 end_line: 2,
224 };
225 let output = render_diagnostic(
226 source,
227 "example.harn",
228 &span,
229 "error",
230 "undefined variable `x`",
231 Some("not found in this scope"),
232 None,
233 );
234 assert!(output.contains("error: undefined variable `x`"));
235 assert!(output.contains("--> example.harn:2:13"));
236 assert!(output.contains("let y = x + 1"));
237 assert!(output.contains("^ not found in this scope"));
238 }
239
240 #[test]
241 fn test_diagnostic_with_help() {
242 disable_colors();
243 let source = "let y = xx + 1";
244 let span = Span {
245 start: 8,
246 end: 10,
247 line: 1,
248 column: 9,
249 end_line: 1,
250 };
251 let output = render_diagnostic(
252 source,
253 "test.harn",
254 &span,
255 "error",
256 "undefined variable `xx`",
257 Some("not found in this scope"),
258 Some("did you mean `x`?"),
259 );
260 assert!(output.contains("help: did you mean `x`?"));
261 }
262
263 #[test]
264 fn test_multiline_source() {
265 disable_colors();
266 let source = "line1\nline2\nline3";
267 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
269 source,
270 "test.harn",
271 &span,
272 "error",
273 "bad line",
274 Some("here"),
275 None,
276 );
277 assert!(result.contains("line2"));
278 assert!(result.contains("^^^^^"));
279 }
280
281 #[test]
282 fn test_single_char_span() {
283 disable_colors();
284 let source = "let x = 42";
285 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
287 source,
288 "test.harn",
289 &span,
290 "warning",
291 "unused",
292 Some("never used"),
293 None,
294 );
295 assert!(result.contains("^"));
296 assert!(result.contains("never used"));
297 }
298
299 #[test]
300 fn test_with_help() {
301 disable_colors();
302 let source = "let y = reponse";
303 let span = Span::with_offsets(8, 15, 1, 9);
304 let result = render_diagnostic(
305 source,
306 "test.harn",
307 &span,
308 "error",
309 "undefined",
310 None,
311 Some("did you mean `response`?"),
312 );
313 assert!(result.contains("help:"));
314 assert!(result.contains("response"));
315 }
316
317 #[test]
318 fn test_parser_error_helpers_for_eof() {
319 disable_colors();
320 let err = ParserError::UnexpectedEof {
321 expected: "}".into(),
322 span: Span::with_offsets(10, 10, 3, 1),
323 };
324 assert_eq!(
325 parser_error_message(&err),
326 "unexpected end of file, expected }"
327 );
328 assert_eq!(parser_error_label(&err), "file ends here");
329 assert_eq!(
330 parser_error_help(&err),
331 Some("add a closing `}` to finish this block")
332 );
333 }
334}