1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::diagnostic_codes::Repair;
7use crate::ParserError;
8
9pub struct RelatedSpanLabel<'a> {
10 pub span: &'a Span,
11 pub label: &'a str,
12}
13
14pub fn normalize_diagnostic_path(path: &str) -> String {
20 let posix = path.replace('\\', "/");
21 if posix.is_empty() {
22 return String::new();
23 }
24
25 let bytes = posix.as_bytes();
26 let mut drive = "";
27 let mut rest = posix.as_str();
28 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
29 drive = &posix[..2];
30 rest = &posix[2..];
31 }
32
33 let absolute = rest.starts_with('/');
34 let mut stack: Vec<&str> = Vec::new();
35 for segment in rest.split('/').filter(|segment| !segment.is_empty()) {
36 match segment {
37 "." => {}
38 ".." => {
39 if let Some(top) = stack.last() {
40 if *top != ".." {
41 stack.pop();
42 continue;
43 }
44 }
45 if !absolute {
46 stack.push("..");
47 }
48 }
49 _ => stack.push(segment),
50 }
51 }
52
53 let mut normalized = String::new();
54 normalized.push_str(drive);
55 if absolute {
56 normalized.push('/');
57 }
58 normalized.push_str(&stack.join("/"));
59 if normalized.is_empty() {
60 ".".to_string()
61 } else {
62 normalized
63 }
64}
65
66fn has_same_snake_case_segments(a: &str, b: &str) -> bool {
67 if !a.contains('_') || !b.contains('_') {
68 return false;
69 }
70 let mut a_segments: Vec<_> = a.split('_').collect();
71 let mut b_segments: Vec<_> = b.split('_').collect();
72 if a_segments.len() < 2
73 || a_segments.len() != b_segments.len()
74 || a_segments.iter().any(|segment| segment.is_empty())
75 || b_segments.iter().any(|segment| segment.is_empty())
76 {
77 return false;
78 }
79 a_segments.sort_unstable();
80 b_segments.sort_unstable();
81 a_segments == b_segments
82}
83
84pub fn find_closest_match<'a>(
88 name: &str,
89 candidates: impl Iterator<Item = &'a str>,
90 max_dist: usize,
91) -> Option<&'a str> {
92 candidates
93 .filter(|candidate| *candidate != name)
94 .filter_map(|candidate| {
95 let reordered = has_same_snake_case_segments(name, candidate);
96 if candidate.len().abs_diff(name.len()) > max_dist && !reordered {
97 return None;
98 }
99 let distance = strsim::levenshtein(name, candidate);
100 (distance <= max_dist || reordered).then_some((distance, candidate))
101 })
102 .min_by_key(|(distance, _)| *distance)
103 .map(|(_, candidate)| candidate)
104}
105
106pub fn renamed_stdlib_symbol(name: &str) -> Option<&'static str> {
108 match name {
109 "retry_with_backoff" => Some("retry_predicate_with_backoff"),
110 "print" => Some("harness.stdio.print"),
111 "println" => Some("harness.stdio.println"),
112 "eprint" => Some("harness.stdio.eprint"),
113 "eprintln" => Some("harness.stdio.eprintln"),
114 "read_line" => Some("harness.stdio.read_line"),
115 "prompt_user" => Some("harness.stdio.prompt"),
116 "agent_session_open" => Some("harness.agent.open"),
117 "agent_session_workspace_anchor" => Some("harness.agent.workspace_anchor"),
118 "agent_session_set_workspace_anchor" => Some("harness.agent.set_workspace_anchor"),
119 "agent_session_workspace_policy" => Some("harness.agent.workspace_policy"),
120 "agent_session_set_workspace_policy" => Some("harness.agent.set_workspace_policy"),
121 "agent_session_add_root" => Some("harness.agent.add_root"),
122 "agent_session_remove_root" => Some("harness.agent.remove_root"),
123 "agent_session_list_roots" => Some("harness.agent.list_roots"),
124 "agent_session_exists" => Some("harness.agent.exists"),
125 "agent_session_length" => Some("harness.agent.length"),
126 "agent_session_snapshot" => Some("harness.agent.snapshot"),
127 "agent_session_ancestry" => Some("harness.agent.ancestry"),
128 "agent_session_current_id" => Some("harness.agent.current_id"),
129 "agent_session_record_changed_path" => Some("harness.agent.record_changed_path"),
130 "agent_session_actor_chain" => Some("harness.agent.actor_chain"),
131 "agent_session_tool_format" => Some("harness.agent.tool_format"),
132 "agent_session_system_prompt" => Some("harness.agent.system_prompt"),
133 "agent_session_scratchpad" => Some("harness.agent.scratchpad"),
134 "agent_session_set_scratchpad" => Some("harness.agent.set_scratchpad"),
135 "agent_session_clear_scratchpad" => Some("harness.agent.clear_scratchpad"),
136 "agent_session_claim_tool_format" => Some("harness.agent.claim_tool_format"),
137 "agent_session_reset" => Some("harness.agent.reset"),
138 "agent_session_fork" => Some("harness.agent.fork"),
139 "agent_session_fork_at" => Some("harness.agent.fork_at"),
140 "agent_session_rollback" => Some("harness.agent.rollback"),
141 "agent_session_redo" => Some("harness.agent.redo"),
142 "agent_session_close" => Some("harness.agent.close"),
143 "agent_session_trim" => Some("harness.agent.trim"),
144 "agent_session_attach" => Some("harness.agent.attach"),
145 "agent_session_takeover" => Some("harness.agent.takeover"),
146 "agent_session_detach" => Some("harness.agent.detach"),
147 "agent_session_heartbeat" => Some("harness.agent.heartbeat"),
148 "agent_session_live_clients" => Some("harness.agent.live_clients"),
149 "agent_session_client_inject_prompt" => Some("harness.agent.client_inject_prompt"),
150 "agent_session_route_permission" => Some("harness.agent.route_permission"),
151 "agent_session_inject" => Some("harness.agent.inject"),
152 "agent_session_post_event" => Some("harness.agent.post_event"),
153 "agent_session_drain_inbox" => Some("harness.agent.drain_inbox"),
154 "agent_session_seed_from_jsonl" => Some("harness.agent.seed_from_jsonl"),
155 "agent_session_reanchor" => Some("harness.agent.reanchor"),
156 "agent_session_compact" => Some("harness.agent.compact"),
157 _ => None,
158 }
159}
160
161pub fn harness_clock_replacement(name: &str) -> Option<&'static str> {
168 match name {
169 "now_ms" => Some("harness.clock.now_ms"),
170 "monotonic_ms" => Some("harness.clock.monotonic_ms"),
171 "sleep_ms" => Some("harness.clock.sleep_ms"),
172 "sleep" => Some("harness.clock.sleep_ms"),
173 "timestamp" => Some("harness.clock.timestamp"),
174 "elapsed" => Some("harness.clock.monotonic_ms"),
175 "date_now" => Some("harness.clock.now"),
176 "date_now_iso" => Some("harness.clock.date_iso"),
177 _ => None,
178 }
179}
180
181pub fn harness_stdio_replacement(name: &str) -> Option<&'static str> {
185 match name {
186 "print" => Some("harness.stdio.print"),
187 "println" => Some("harness.stdio.println"),
188 "eprint" => Some("harness.stdio.eprint"),
189 "eprintln" => Some("harness.stdio.eprintln"),
190 "read_line" => Some("harness.stdio.read_line"),
191 "prompt_user" => Some("harness.stdio.prompt"),
192 _ => None,
193 }
194}
195
196pub fn harness_fs_replacement(name: &str) -> Option<&'static str> {
200 match name {
201 "read_file" => Some("harness.fs.read_text"),
202 "read_file_result" => Some("harness.fs.read_text_result"),
203 "read_file_bytes" => Some("harness.fs.read_bytes"),
204 "write_file" => Some("harness.fs.write_text"),
205 "write_file_bytes" => Some("harness.fs.write_bytes"),
206 "replace_file" => Some("harness.fs.replace_text"),
207 "replace_file_result" => Some("harness.fs.replace_text_result"),
208 "replace_file_bytes" => Some("harness.fs.replace_bytes"),
209 "replace_file_bytes_result" => Some("harness.fs.replace_bytes_result"),
210 "file_exists" => Some("harness.fs.exists"),
211 "path_status" => Some("harness.fs.status"),
212 "delete_file" => Some("harness.fs.delete"),
213 "append_file" => Some("harness.fs.append"),
214 "append_file_locked" => Some("harness.fs.append_locked"),
215 "list_dir" => Some("harness.fs.list_dir"),
216 "mkdir" => Some("harness.fs.mkdir"),
217 "copy_file" => Some("harness.fs.copy"),
218 "temp_dir" => Some("harness.fs.temp_dir"),
219 "workspace_temp_dir" => Some("harness.fs.workspace_temp_dir"),
220 "mkdtemp" => Some("harness.fs.mkdtemp"),
221 "mkdtemp_in_workspace" => Some("harness.fs.mkdtemp_in_workspace"),
222 "stat" => Some("harness.fs.stat"),
223 "move_file" => Some("harness.fs.rename"),
224 "read_lines" => Some("harness.fs.read_lines"),
225 "read_lines_page_result" => Some("harness.fs.read_lines_page_result"),
226 "walk_dir" => Some("harness.fs.walk"),
227 "glob" => Some("harness.fs.glob"),
228 "find_text" => Some("harness.fs.find_text"),
229 "find_evidence" => Some("harness.fs.find_evidence"),
230 "cwd" => Some("harness.fs.cwd"),
231 _ => None,
232 }
233}
234
235pub fn harness_env_replacement(name: &str) -> Option<&'static str> {
238 match name {
239 "env" => Some("harness.env.get"),
240 "env_or" => Some("harness.env.get_or"),
241 _ => None,
242 }
243}
244
245pub fn harness_random_replacement(name: &str) -> Option<&'static str> {
248 match name {
249 "random" => Some("harness.random.f64"),
250 "random_int" => Some("harness.random.range"),
251 "random_choice" => Some("harness.random.choice"),
252 "random_shuffle" => Some("harness.random.shuffle"),
253 _ => None,
254 }
255}
256
257pub fn harness_net_replacement(name: &str) -> Option<&'static str> {
262 match name {
263 "http_get" => Some("harness.net.get"),
264 "http_post" => Some("harness.net.post"),
265 "http_put" => Some("harness.net.put"),
266 "http_patch" => Some("harness.net.patch"),
267 "http_delete" => Some("harness.net.delete"),
268 "http_request" => Some("harness.net.request"),
269 "http_download" => Some("harness.net.download"),
270 "http_server" => Some("harness.net.server"),
271 "http_server_after" => Some("harness.net.server_after"),
272 "http_server_before" => Some("harness.net.server_before"),
273 "http_server_on_shutdown" => Some("harness.net.server_on_shutdown"),
274 "http_server_readiness" => Some("harness.net.server_readiness"),
275 "http_server_ready" => Some("harness.net.server_ready"),
276 "http_server_request" => Some("harness.net.server_request"),
277 "http_server_route" => Some("harness.net.server_route"),
278 "http_server_security_headers" => Some("harness.net.server_security_headers"),
279 "http_server_set_ready" => Some("harness.net.server_set_ready"),
280 "http_server_shutdown" => Some("harness.net.server_shutdown"),
281 "http_server_test" => Some("harness.net.server_test"),
282 "http_server_tls_edge" => Some("harness.net.server_tls_edge"),
283 "http_server_tls_pem" => Some("harness.net.server_tls_pem"),
284 "http_server_tls_plain" => Some("harness.net.server_tls_plain"),
285 "http_server_tls_self_signed_dev" => Some("harness.net.server_tls_self_signed_dev"),
286 "http_session" => Some("harness.net.session"),
287 "http_session_close" => Some("harness.net.session_close"),
288 "http_session_request" => Some("harness.net.session_request"),
289 "http_stream_close" => Some("harness.net.stream_close"),
290 "http_stream_info" => Some("harness.net.stream_info"),
291 "http_stream_open" => Some("harness.net.stream_open"),
292 "http_stream_read" => Some("harness.net.stream_read"),
293 "sse_close" => Some("harness.net.sse_close"),
294 "sse_connect" => Some("harness.net.sse_connect"),
295 "sse_receive" => Some("harness.net.sse_receive"),
296 "sse_server_cancel" => Some("harness.net.sse_server_cancel"),
297 "sse_server_cancelled" => Some("harness.net.sse_server_cancelled"),
298 "sse_server_close" => Some("harness.net.sse_server_close"),
299 "sse_server_disconnected" => Some("harness.net.sse_server_disconnected"),
300 "sse_server_flush" => Some("harness.net.sse_server_flush"),
301 "sse_server_heartbeat" => Some("harness.net.sse_server_heartbeat"),
302 "sse_server_response" => Some("harness.net.sse_server_response"),
303 "sse_server_send" => Some("harness.net.sse_server_send"),
304 "sse_server_status" => Some("harness.net.sse_server_status"),
305 "websocket_accept" => Some("harness.net.websocket_accept"),
306 "websocket_close" => Some("harness.net.websocket_close"),
307 "websocket_connect" => Some("harness.net.websocket_connect"),
308 "websocket_receive" => Some("harness.net.websocket_receive"),
309 "websocket_route" => Some("harness.net.websocket_route"),
310 "websocket_send" => Some("harness.net.websocket_send"),
311 "websocket_server" => Some("harness.net.websocket_server"),
312 "websocket_server_close" => Some("harness.net.websocket_server_close"),
313 _ => None,
314 }
315}
316
317pub fn render_diagnostic(
328 source: &str,
329 filename: &str,
330 span: &Span,
331 severity: &str,
332 message: &str,
333 label: Option<&str>,
334 help: Option<&str>,
335) -> String {
336 render_diagnostic_inner(RenderDiagnostic {
337 source,
338 filename,
339 span,
340 severity,
341 code: None,
342 message,
343 label,
344 help,
345 related: &[],
346 repair: None,
347 })
348}
349
350pub fn render_diagnostic_with_code(
351 source: &str,
352 filename: &str,
353 span: &Span,
354 severity: &str,
355 code: crate::diagnostic_codes::Code,
356 message: &str,
357 label: Option<&str>,
358 help: Option<&str>,
359) -> String {
360 let repair_owned = code.repair_template().map(Repair::from_template);
361 render_diagnostic_inner(RenderDiagnostic {
362 source,
363 filename,
364 span,
365 severity,
366 code: Some(code.as_str()),
367 message,
368 label,
369 help,
370 related: &[],
371 repair: repair_owned.as_ref(),
372 })
373}
374
375pub fn render_diagnostic_with_related(
376 source: &str,
377 filename: &str,
378 span: &Span,
379 severity: &str,
380 message: &str,
381 label: Option<&str>,
382 help: Option<&str>,
383 related: &[RelatedSpanLabel<'_>],
384) -> String {
385 render_diagnostic_inner(RenderDiagnostic {
386 source,
387 filename,
388 span,
389 severity,
390 code: None,
391 message,
392 label,
393 help,
394 related,
395 repair: None,
396 })
397}
398
399struct RenderDiagnostic<'a> {
400 source: &'a str,
401 filename: &'a str,
402 span: &'a Span,
403 severity: &'a str,
404 code: Option<&'a str>,
405 message: &'a str,
406 label: Option<&'a str>,
407 help: Option<&'a str>,
408 related: &'a [RelatedSpanLabel<'a>],
409 repair: Option<&'a Repair>,
410}
411
412fn render_diagnostic_inner(input: RenderDiagnostic<'_>) -> String {
413 let mut out = String::new();
414 let source = input.source;
415 let span = input.span;
416 let severity = input.severity;
417 let message = input.message;
418 let label = input.label;
419 let help = input.help;
420 let related = input.related;
421 let filename = normalize_diagnostic_path(input.filename);
422 let severity_color = severity_color(severity);
423 let gutter = style_fragment("|", Color::Blue, false);
424 let arrow = style_fragment("-->", Color::Blue, true);
425 let help_prefix = style_fragment("help", Color::Cyan, true);
426 let note_prefix = style_fragment("note", Color::Magenta, true);
427
428 out.push_str(&style_fragment(severity, severity_color, true));
429 if let Some(code) = input.code {
430 out.push('[');
431 out.push_str(code);
432 out.push(']');
433 }
434 out.push_str(": ");
435 out.push_str(message);
436 out.push('\n');
437
438 let line_num = span.line;
439 let col_num = span.column;
440
441 let gutter_width = line_num.to_string().len();
442
443 out.push_str(&format!(
444 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
445 " ",
446 width = gutter_width + 1,
447 ));
448
449 out.push_str(&format!(
450 "{:>width$} {gutter}\n",
451 " ",
452 width = gutter_width + 1,
453 ));
454
455 let source_line_opt = line_num.checked_sub(1).and_then(|n| source.lines().nth(n));
456 if let Some(source_line) = source_line_opt {
457 out.push_str(&format!(
458 "{:>width$} {gutter} {source_line}\n",
459 line_num,
460 width = gutter_width + 1,
461 ));
462
463 if let Some(label_text) = label {
464 let span_len = diagnostic_span_char_len(source, span);
466 let col_num = col_num.max(1);
467 let padding = " ".repeat(col_num - 1);
468 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
469 out.push_str(&format!(
470 "{:>width$} {gutter} {padding}{carets} {label_text}\n",
471 " ",
472 width = gutter_width + 1,
473 ));
474 }
475 }
476
477 if let Some(help_text) = help {
478 out.push_str(&format!(
479 "{:>width$} = {help_prefix}: {help_text}\n",
480 " ",
481 width = gutter_width + 1,
482 ));
483 }
484
485 if let Some(repair) = input.repair {
486 let repair_prefix = style_fragment("repair", Color::Cyan, true);
487 out.push_str(&format!(
488 "{:>width$} = {repair_prefix}: {} [{}] — {}\n",
489 " ",
490 repair.id,
491 repair.safety,
492 repair.summary,
493 width = gutter_width + 1,
494 ));
495 }
496
497 for item in related {
498 out.push_str(&format!(
499 "{:>width$} = {note_prefix}: {}\n",
500 " ",
501 item.label,
502 width = gutter_width + 1,
503 ));
504 render_related_span(
505 &mut out,
506 source,
507 &filename,
508 item.span,
509 item.label,
510 gutter_width,
511 );
512 }
513
514 if let Some(note_text) = fun_note(severity) {
515 out.push_str(&format!(
516 "{:>width$} = {note_prefix}: {note_text}\n",
517 " ",
518 width = gutter_width + 1,
519 ));
520 }
521
522 out
523}
524
525pub fn render_type_diagnostic(
526 source: &str,
527 filename: &str,
528 diag: &crate::typechecker::TypeDiagnostic,
529) -> String {
530 let severity = match diag.severity {
531 crate::typechecker::DiagnosticSeverity::Error => "error",
532 crate::typechecker::DiagnosticSeverity::Warning => "warning",
533 };
534 let related = diag
535 .related
536 .iter()
537 .map(|related| RelatedSpanLabel {
538 span: &related.span,
539 label: &related.message,
540 })
541 .collect::<Vec<_>>();
542 let primary_label = type_diagnostic_primary_label(diag);
543 match &diag.span {
544 Some(span) => render_diagnostic_inner(RenderDiagnostic {
545 source,
546 filename,
547 span,
548 severity,
549 code: Some(diag.code.as_str()),
550 message: &diag.message,
551 label: primary_label.as_deref(),
552 help: diag.help.as_deref(),
553 related: &related,
554 repair: diag.repair.as_ref(),
555 }),
556 None => match diag.repair.as_ref() {
557 Some(repair) => format!(
558 "{severity}[{}]: {}\n = repair: {} [{}] — {}\n",
559 diag.code, diag.message, repair.id, repair.safety, repair.summary,
560 ),
561 None => format!("{severity}[{}]: {}\n", diag.code, diag.message),
562 },
563 }
564}
565
566pub fn lexer_error_code(err: &harn_lexer::LexerError) -> crate::diagnostic_codes::Code {
567 match err {
568 harn_lexer::LexerError::UnexpectedCharacter(_, _) => {
569 crate::diagnostic_codes::Code::ParserUnexpectedCharacter
570 }
571 harn_lexer::LexerError::UnterminatedString(_) => {
572 crate::diagnostic_codes::Code::ParserUnterminatedString
573 }
574 harn_lexer::LexerError::UnterminatedBlockComment(_) => {
575 crate::diagnostic_codes::Code::ParserUnterminatedBlockComment
576 }
577 harn_lexer::LexerError::IntegerLiteralOutOfRange(_, _) => {
578 crate::diagnostic_codes::Code::ParserIntegerLiteralOutOfRange
579 }
580 }
581}
582
583pub fn parser_error_code(err: &crate::parser::ParserError) -> crate::diagnostic_codes::Code {
584 match err {
585 crate::parser::ParserError::Unexpected { .. } => {
586 crate::diagnostic_codes::Code::ParserUnexpectedToken
587 }
588 crate::parser::ParserError::UnexpectedEof { .. } => {
589 crate::diagnostic_codes::Code::ParserUnexpectedEof
590 }
591 }
592}
593
594fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
595 match &diag.details {
596 Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
597 Some(format!("lint[{rule}]"))
598 }
599 Some(crate::typechecker::DiagnosticDetails::TypeMismatch) => {
600 Some("found this type".to_string())
601 }
602 _ => None,
603 }
604}
605
606fn render_related_span(
607 out: &mut String,
608 source: &str,
609 filename: &str,
610 span: &Span,
611 label: &str,
612 primary_gutter_width: usize,
613) {
614 let filename = normalize_diagnostic_path(filename);
615 let severity_color = Color::Magenta;
616 let gutter = style_fragment("|", Color::Blue, false);
617 let arrow = style_fragment("-->", Color::Blue, true);
618 let line_num = span.line;
619 let col_num = span.column;
620 let gutter_width = primary_gutter_width.max(line_num.to_string().len());
621
622 out.push_str(&format!(
623 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
624 " ",
625 width = gutter_width + 1,
626 ));
627 out.push_str(&format!(
628 "{:>width$} {gutter}\n",
629 " ",
630 width = gutter_width + 1,
631 ));
632
633 if let Some(source_line) = line_num.checked_sub(1).and_then(|n| source.lines().nth(n)) {
634 out.push_str(&format!(
635 "{:>width$} {gutter} {source_line}\n",
636 line_num,
637 width = gutter_width + 1,
638 ));
639 let span_len = diagnostic_span_char_len(source, span);
640 let padding = " ".repeat(col_num.max(1) - 1);
641 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
642 out.push_str(&format!(
643 "{:>width$} {gutter} {padding}{carets} {label}\n",
644 " ",
645 width = gutter_width + 1,
646 ));
647 }
648}
649
650fn diagnostic_span_char_len(source: &str, span: &Span) -> usize {
651 if span.end <= span.start || span.start >= source.len() {
652 return 1;
653 }
654 let mut start = span.start.min(source.len());
655 while start > 0 && !source.is_char_boundary(start) {
656 start -= 1;
657 }
658 let mut end = span.end.min(source.len());
659 while end < source.len() && !source.is_char_boundary(end) {
660 end += 1;
661 }
662 source
663 .get(start..end)
664 .map(|text| text.chars().count().max(1))
665 .unwrap_or(1)
666}
667
668fn severity_color(severity: &str) -> Color {
669 match severity {
670 "error" => Color::Red,
671 "warning" => Color::Yellow,
672 "note" => Color::Magenta,
673 _ => Color::Cyan,
674 }
675}
676
677fn style_fragment(text: &str, color: Color, bold: bool) -> String {
678 if !colors_enabled() {
679 return text.to_string();
680 }
681
682 let mut paint = Paint::new(text).fg(color);
683 if bold {
684 paint = paint.bold();
685 }
686 paint.to_string()
687}
688
689thread_local! {
690 static COLOR_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
696}
697
698#[cfg(test)]
701pub(crate) fn set_color_override(force: Option<bool>) {
702 COLOR_OVERRIDE.with(|cell| cell.set(force));
703}
704
705fn colors_enabled() -> bool {
706 if let Some(forced) = COLOR_OVERRIDE.with(std::cell::Cell::get) {
707 return forced;
708 }
709 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
710}
711
712fn fun_note(severity: &str) -> Option<&'static str> {
713 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
714 return None;
715 }
716
717 Some(match severity {
718 "error" => "the compiler stepped on a rake here.",
719 "warning" => "this still runs, but it has strong 'double-check me' energy.",
720 _ => "a tiny gremlin has left a note in the margins.",
721 })
722}
723
724pub fn parser_error_message(err: &ParserError) -> String {
725 match err {
726 ParserError::Unexpected { got, expected, .. } => {
727 format!("expected {expected}, found {got}")
728 }
729 ParserError::UnexpectedEof { expected, .. } => {
730 format!("unexpected end of file, expected {expected}")
731 }
732 }
733}
734
735pub fn parser_error_label(err: &ParserError) -> &'static str {
736 match err {
737 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
738 ParserError::Unexpected { .. } => "unexpected token",
739 ParserError::UnexpectedEof { .. } => "file ends here",
740 }
741}
742
743pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
744 match err {
745 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
746 match expected.as_str() {
747 "}" => Some("add a closing `}` to finish this block"),
748 ")" => Some("add a closing `)` to finish this expression or parameter list"),
749 "]" => Some("add a closing `]` to finish this list or subscript"),
750 "fn, struct, enum, or pipeline after pub" => {
751 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
752 }
753 "fn, tool, skill, eval_pack, struct, enum, type, pipeline, const, let, or import after pub" => Some(
754 "use `pub` with `fn`, `tool`, `skill`, `eval_pack`, `struct`, `enum`, `type`, `pipeline`, `const`, `let`, or `import`",
755 ),
756 _ => None,
757 }
758 }
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn disable_colors() {
771 set_color_override(Some(false));
772 }
773
774 #[test]
775 fn test_basic_diagnostic() {
776 disable_colors();
777 let source = "pipeline default(task) {\n const y = x + 1\n}";
778 let span = Span {
779 start: 28,
780 end: 29,
781 line: 2,
782 column: 13,
783 end_line: 2,
784 };
785 let output = render_diagnostic(
786 source,
787 "example.harn",
788 &span,
789 "error",
790 "undefined variable `x`",
791 Some("not found in this scope"),
792 None,
793 );
794 assert!(output.contains("error: undefined variable `x`"));
795 assert!(output.contains("--> example.harn:2:13"));
796 assert!(output.contains("const y = x + 1"));
797 assert!(output.contains("^ not found in this scope"));
798 }
799
800 #[test]
801 fn test_diagnostic_normalizes_filename() {
802 disable_colors();
803 let source = "const value = thing";
804 let span = Span {
805 start: 12,
806 end: 17,
807 line: 1,
808 column: 13,
809 end_line: 1,
810 };
811 let output = render_diagnostic(
812 source,
813 "/workspace/pipelines/mode/../lib/runtime/loop.harn",
814 &span,
815 "error",
816 "bad value",
817 Some("here"),
818 None,
819 );
820 assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
821 assert!(!output.contains("/../"));
822 }
823
824 #[test]
825 fn test_diagnostic_with_help() {
826 disable_colors();
827 let source = "const y = xx + 1";
828 let span = Span {
829 start: 8,
830 end: 10,
831 line: 1,
832 column: 9,
833 end_line: 1,
834 };
835 let output = render_diagnostic(
836 source,
837 "test.harn",
838 &span,
839 "error",
840 "undefined variable `xx`",
841 Some("not found in this scope"),
842 Some("did you mean `x`?"),
843 );
844 assert!(output.contains("help: did you mean `x`?"));
845 }
846
847 #[test]
848 fn test_multiline_source() {
849 disable_colors();
850 let source = "line1\nline2\nline3";
851 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
853 source,
854 "test.harn",
855 &span,
856 "error",
857 "bad line",
858 Some("here"),
859 None,
860 );
861 assert!(result.contains("line2"));
862 assert!(result.contains("^^^^^"));
863 }
864
865 #[test]
866 fn diagnostic_rendering_tolerates_offsets_inside_utf8_codepoints() {
867 disable_colors();
868 let source = "// capability owner — narrow helper";
869 let em_dash = source.find('—').expect("em dash");
870 let span = Span::with_offsets(0, em_dash + 1, 1, 1);
871 let output = render_diagnostic(
872 source,
873 "unicode.harn",
874 &span,
875 "warning",
876 "legacy comment",
877 Some("rewrite this comment"),
878 None,
879 );
880 assert!(output.contains("capability owner — narrow helper"));
881 assert!(output.contains("rewrite this comment"));
882 }
883
884 #[test]
885 fn test_single_char_span() {
886 disable_colors();
887 let source = "const x = 42";
888 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
890 source,
891 "test.harn",
892 &span,
893 "warning",
894 "unused",
895 Some("never used"),
896 None,
897 );
898 assert!(result.contains('^'));
899 assert!(result.contains("never used"));
900 }
901
902 #[test]
903 fn test_with_help() {
904 disable_colors();
905 let source = "const y = reponse";
906 let span = Span::with_offsets(8, 15, 1, 9);
907 let result = render_diagnostic(
908 source,
909 "test.harn",
910 &span,
911 "error",
912 "undefined",
913 None,
914 Some("did you mean `response`?"),
915 );
916 assert!(result.contains("help:"));
917 assert!(result.contains("response"));
918 }
919
920 #[test]
921 fn closest_match_suggests_reordered_snake_case_segments() {
922 assert_eq!(
923 find_closest_match("parse_json", ["json_parse"].into_iter(), 2),
924 Some("json_parse")
925 );
926 assert_eq!(
927 find_closest_match("read_file", ["file_read"].into_iter(), 2),
928 Some("file_read")
929 );
930 assert_eq!(
931 find_closest_match("parse_json", ["parse_yaml"].into_iter(), 2),
932 None
933 );
934 }
935
936 #[test]
937 fn closest_match_suggests_plain_typo() {
938 assert_eq!(
939 find_closest_match("json_pars", ["json_parse"].into_iter(), 2),
940 Some("json_parse")
941 );
942 }
943
944 #[test]
945 fn test_parser_error_helpers_for_eof() {
946 disable_colors();
947 let err = ParserError::UnexpectedEof {
948 expected: "}".into(),
949 span: Span::with_offsets(10, 10, 3, 1),
950 };
951 assert_eq!(
952 parser_error_message(&err),
953 "unexpected end of file, expected }"
954 );
955 assert_eq!(parser_error_label(&err), "file ends here");
956 assert_eq!(
957 parser_error_help(&err),
958 Some("add a closing `}` to finish this block")
959 );
960 }
961}