1use std::io::IsTerminal;
2
3use harn_lexer::Span;
4use yansi::{Color, Paint};
5
6use crate::diagnostic_codes::Repair;
7use crate::ParserError;
8
9mod harness_migrations_generated;
10
11pub fn generated_harness_migration(name: &str) -> Option<&'static str> {
26 harness_migrations_generated::HARNESS_MIGRATIONS
27 .binary_search_by_key(&name, |(legacy, _)| *legacy)
28 .ok()
29 .map(|index| harness_migrations_generated::HARNESS_MIGRATIONS[index].1)
30}
31
32pub struct RelatedSpanLabel<'a> {
33 pub span: &'a Span,
34 pub label: &'a str,
35}
36
37pub fn normalize_diagnostic_path(path: &str) -> String {
43 let posix = path.replace('\\', "/");
44 if posix.is_empty() {
45 return String::new();
46 }
47
48 let bytes = posix.as_bytes();
49 let mut drive = "";
50 let mut rest = posix.as_str();
51 if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
52 drive = &posix[..2];
53 rest = &posix[2..];
54 }
55
56 let absolute = rest.starts_with('/');
57 let mut stack: Vec<&str> = Vec::new();
58 for segment in rest.split('/').filter(|segment| !segment.is_empty()) {
59 match segment {
60 "." => {}
61 ".." => {
62 if let Some(top) = stack.last() {
63 if *top != ".." {
64 stack.pop();
65 continue;
66 }
67 }
68 if !absolute {
69 stack.push("..");
70 }
71 }
72 _ => stack.push(segment),
73 }
74 }
75
76 let mut normalized = String::new();
77 normalized.push_str(drive);
78 if absolute {
79 normalized.push('/');
80 }
81 normalized.push_str(&stack.join("/"));
82 if normalized.is_empty() {
83 ".".to_string()
84 } else {
85 normalized
86 }
87}
88
89fn has_same_snake_case_segments(a: &str, b: &str) -> bool {
90 if !a.contains('_') || !b.contains('_') {
91 return false;
92 }
93 let mut a_segments: Vec<_> = a.split('_').collect();
94 let mut b_segments: Vec<_> = b.split('_').collect();
95 if a_segments.len() < 2
96 || a_segments.len() != b_segments.len()
97 || a_segments.iter().any(|segment| segment.is_empty())
98 || b_segments.iter().any(|segment| segment.is_empty())
99 {
100 return false;
101 }
102 a_segments.sort_unstable();
103 b_segments.sort_unstable();
104 a_segments == b_segments
105}
106
107pub fn find_closest_match<'a>(
111 name: &str,
112 candidates: impl Iterator<Item = &'a str>,
113 max_dist: usize,
114) -> Option<&'a str> {
115 candidates
116 .filter(|candidate| *candidate != name)
117 .filter_map(|candidate| {
118 let reordered = has_same_snake_case_segments(name, candidate);
119 if candidate.len().abs_diff(name.len()) > max_dist && !reordered {
120 return None;
121 }
122 let distance = strsim::levenshtein(name, candidate);
123 (distance <= max_dist || reordered).then_some((distance, candidate))
124 })
125 .min_by_key(|(distance, _)| *distance)
126 .map(|(_, candidate)| candidate)
127}
128
129pub fn renamed_stdlib_symbol(name: &str) -> Option<&'static str> {
131 match name {
132 "retry_with_backoff" => Some("retry_predicate_with_backoff"),
133 "print" => Some("harness.stdio.print"),
134 "println" => Some("harness.stdio.println"),
135 "eprint" => Some("harness.stdio.eprint"),
136 "eprintln" => Some("harness.stdio.eprintln"),
137 "read_line" => Some("harness.stdio.read_line"),
138 "prompt_user" => Some("harness.stdio.prompt"),
139 "agent_session_open" => Some("harness.agent.open"),
140 "agent_session_workspace_anchor" => Some("harness.agent.workspace_anchor"),
141 "agent_session_set_workspace_anchor" => Some("harness.agent.set_workspace_anchor"),
142 "agent_session_workspace_policy" => Some("harness.agent.workspace_policy"),
143 "agent_session_set_workspace_policy" => Some("harness.agent.set_workspace_policy"),
144 "agent_session_add_root" => Some("harness.agent.add_root"),
145 "agent_session_remove_root" => Some("harness.agent.remove_root"),
146 "agent_session_list_roots" => Some("harness.agent.list_roots"),
147 "agent_session_exists" => Some("harness.agent.exists"),
148 "agent_session_length" => Some("harness.agent.length"),
149 "agent_session_snapshot" => Some("harness.agent.snapshot"),
150 "agent_session_ancestry" => Some("harness.agent.ancestry"),
151 "agent_session_current_id" => Some("harness.agent.current_id"),
152 "agent_session_record_changed_path" => Some("harness.agent.record_changed_path"),
153 "agent_session_actor_chain" => Some("harness.agent.actor_chain"),
154 "agent_session_tool_format" => Some("harness.agent.tool_format"),
155 "agent_session_system_prompt" => Some("harness.agent.system_prompt"),
156 "agent_session_scratchpad" => Some("harness.agent.scratchpad"),
157 "agent_session_set_scratchpad" => Some("harness.agent.set_scratchpad"),
158 "agent_session_clear_scratchpad" => Some("harness.agent.clear_scratchpad"),
159 "agent_session_claim_tool_format" => Some("harness.agent.claim_tool_format"),
160 "agent_session_reset" => Some("harness.agent.reset"),
161 "agent_session_fork" => Some("harness.agent.fork"),
162 "agent_session_fork_at" => Some("harness.agent.fork_at"),
163 "agent_session_rollback" => Some("harness.agent.rollback"),
164 "agent_session_redo" => Some("harness.agent.redo"),
165 "agent_session_close" => Some("harness.agent.close"),
166 "agent_session_trim" => Some("harness.agent.trim"),
167 "agent_session_attach" => Some("harness.agent.attach"),
168 "agent_session_takeover" => Some("harness.agent.takeover"),
169 "agent_session_detach" => Some("harness.agent.detach"),
170 "agent_session_heartbeat" => Some("harness.agent.heartbeat"),
171 "agent_session_live_clients" => Some("harness.agent.live_clients"),
172 "agent_session_client_inject_prompt" => Some("harness.agent.client_inject_prompt"),
173 "agent_session_route_permission" => Some("harness.agent.route_permission"),
174 "agent_session_inject" => Some("harness.agent.inject"),
175 "agent_session_post_event" => Some("harness.agent.post_event"),
176 "agent_session_drain_inbox" => Some("harness.agent.drain_inbox"),
177 "agent_session_seed_from_jsonl" => Some("harness.agent.seed_from_jsonl"),
178 "agent_session_reanchor" => Some("harness.agent.reanchor"),
179 "agent_session_compact" => Some("harness.agent.compact"),
180 _ => None,
181 }
182}
183
184pub fn removed_global_replacement(name: &str) -> Option<&'static str> {
206 if let Some(replacement) = renamed_stdlib_symbol(name) {
207 return Some(replacement);
208 }
209 for family in HARNESS_REPLACEMENT_FAMILIES {
210 if let Some(replacement) = family(name) {
211 return Some(replacement);
212 }
213 }
214 generated_harness_migration(name)
215}
216
217pub const HARNESS_REPLACEMENT_FAMILIES: &[fn(&str) -> Option<&'static str>] = &[
225 harness_clock_replacement,
226 harness_stdio_replacement,
227 harness_fs_replacement,
228 harness_env_replacement,
229 harness_random_replacement,
230 harness_net_replacement,
231];
232
233pub const HARNESS_MIGRATION_DISAGREEMENTS: &[(&str, &str, &str)] = &[
240 (
241 "read_file",
242 "harness.fs.read_text",
243 "`harness.tools.read_file` is an agent tool, not the filesystem capability",
244 ),
245 (
246 "write_file",
247 "harness.fs.write_text",
248 "`harness.tools.write_file` is an agent tool, not the filesystem capability",
249 ),
250 (
251 "delete_file",
252 "harness.fs.delete",
253 "`harness.tools.delete_file` is an agent tool, not the filesystem capability",
254 ),
255 (
256 "elapsed",
257 "harness.clock.monotonic_ms",
258 "`elapsed` was renamed, so the same-named `harness.clock.elapsed` is not its successor",
259 ),
260];
261
262pub fn harness_clock_replacement(name: &str) -> Option<&'static str> {
269 match name {
270 "now_ms" => Some("harness.clock.now_ms"),
271 "monotonic_ms" => Some("harness.clock.monotonic_ms"),
272 "sleep_ms" => Some("harness.clock.sleep_ms"),
273 "sleep" => Some("harness.clock.sleep_ms"),
274 "timestamp" => Some("harness.clock.timestamp"),
275 "elapsed" => Some("harness.clock.monotonic_ms"),
276 "date_now" => Some("harness.clock.now"),
277 "date_now_iso" => Some("harness.clock.date_iso"),
278 _ => None,
279 }
280}
281
282pub fn harness_stdio_replacement(name: &str) -> Option<&'static str> {
286 match name {
287 "print" => Some("harness.stdio.print"),
288 "println" => Some("harness.stdio.println"),
289 "eprint" => Some("harness.stdio.eprint"),
290 "eprintln" => Some("harness.stdio.eprintln"),
291 "read_line" => Some("harness.stdio.read_line"),
292 "prompt_user" => Some("harness.stdio.prompt"),
293 _ => None,
294 }
295}
296
297pub fn harness_fs_replacement(name: &str) -> Option<&'static str> {
301 match name {
302 "read_file" => Some("harness.fs.read_text"),
303 "read_file_result" => Some("harness.fs.read_text_result"),
304 "read_file_bytes" => Some("harness.fs.read_bytes"),
305 "write_file" => Some("harness.fs.write_text"),
306 "write_file_bytes" => Some("harness.fs.write_bytes"),
307 "replace_file" => Some("harness.fs.replace_text"),
308 "replace_file_result" => Some("harness.fs.replace_text_result"),
309 "replace_file_bytes" => Some("harness.fs.replace_bytes"),
310 "replace_file_bytes_result" => Some("harness.fs.replace_bytes_result"),
311 "file_exists" => Some("harness.fs.exists"),
312 "path_status" => Some("harness.fs.status"),
313 "delete_file" => Some("harness.fs.delete"),
314 "append_file" => Some("harness.fs.append"),
315 "append_file_locked" => Some("harness.fs.append_locked"),
316 "list_dir" => Some("harness.fs.list_dir"),
317 "mkdir" => Some("harness.fs.mkdir"),
318 "copy_file" => Some("harness.fs.copy"),
319 "temp_dir" => Some("harness.fs.temp_dir"),
320 "workspace_temp_dir" => Some("harness.fs.workspace_temp_dir"),
321 "mkdtemp" => Some("harness.fs.mkdtemp"),
322 "mkdtemp_in_workspace" => Some("harness.fs.mkdtemp_in_workspace"),
323 "stat" => Some("harness.fs.stat"),
324 "move_file" => Some("harness.fs.rename"),
325 "read_lines" => Some("harness.fs.read_lines"),
326 "read_lines_page_result" => Some("harness.fs.read_lines_page_result"),
327 "walk_dir" => Some("harness.fs.walk"),
328 "glob" => Some("harness.fs.glob"),
329 "find_text" => Some("harness.fs.find_text"),
330 "find_evidence" => Some("harness.fs.find_evidence"),
331 "cwd" => Some("harness.fs.cwd"),
332 _ => None,
333 }
334}
335
336pub fn harness_env_replacement(name: &str) -> Option<&'static str> {
339 match name {
340 "env" => Some("harness.env.get"),
341 "env_or" => Some("harness.env.get_or"),
342 _ => None,
343 }
344}
345
346pub fn harness_random_replacement(name: &str) -> Option<&'static str> {
349 match name {
350 "random" => Some("harness.random.f64"),
351 "random_int" => Some("harness.random.range"),
352 "random_choice" => Some("harness.random.choice"),
353 "random_shuffle" => Some("harness.random.shuffle"),
354 _ => None,
355 }
356}
357
358pub fn harness_net_replacement(name: &str) -> Option<&'static str> {
363 match name {
364 "http_get" => Some("harness.net.get"),
365 "http_post" => Some("harness.net.post"),
366 "http_put" => Some("harness.net.put"),
367 "http_patch" => Some("harness.net.patch"),
368 "http_delete" => Some("harness.net.delete"),
369 "http_request" => Some("harness.net.request"),
370 "http_download" => Some("harness.net.download"),
371 "http_server" => Some("harness.net.server"),
372 "http_server_after" => Some("harness.net.server_after"),
373 "http_server_before" => Some("harness.net.server_before"),
374 "http_server_on_shutdown" => Some("harness.net.server_on_shutdown"),
375 "http_server_readiness" => Some("harness.net.server_readiness"),
376 "http_server_ready" => Some("harness.net.server_ready"),
377 "http_server_request" => Some("harness.net.server_request"),
378 "http_server_route" => Some("harness.net.server_route"),
379 "http_server_security_headers" => Some("harness.net.server_security_headers"),
380 "http_server_set_ready" => Some("harness.net.server_set_ready"),
381 "http_server_shutdown" => Some("harness.net.server_shutdown"),
382 "http_server_test" => Some("harness.net.server_test"),
383 "http_server_tls_edge" => Some("harness.net.server_tls_edge"),
384 "http_server_tls_pem" => Some("harness.net.server_tls_pem"),
385 "http_server_tls_plain" => Some("harness.net.server_tls_plain"),
386 "http_server_tls_self_signed_dev" => Some("harness.net.server_tls_self_signed_dev"),
387 "http_session" => Some("harness.net.session"),
388 "http_session_close" => Some("harness.net.session_close"),
389 "http_session_request" => Some("harness.net.session_request"),
390 "http_stream_close" => Some("harness.net.stream_close"),
391 "http_stream_info" => Some("harness.net.stream_info"),
392 "http_stream_open" => Some("harness.net.stream_open"),
393 "http_stream_read" => Some("harness.net.stream_read"),
394 "sse_close" => Some("harness.net.sse_close"),
395 "sse_connect" => Some("harness.net.sse_connect"),
396 "sse_receive" => Some("harness.net.sse_receive"),
397 "sse_server_cancel" => Some("harness.net.sse_server_cancel"),
398 "sse_server_cancelled" => Some("harness.net.sse_server_cancelled"),
399 "sse_server_close" => Some("harness.net.sse_server_close"),
400 "sse_server_disconnected" => Some("harness.net.sse_server_disconnected"),
401 "sse_server_flush" => Some("harness.net.sse_server_flush"),
402 "sse_server_heartbeat" => Some("harness.net.sse_server_heartbeat"),
403 "sse_server_response" => Some("harness.net.sse_server_response"),
404 "sse_server_send" => Some("harness.net.sse_server_send"),
405 "sse_server_status" => Some("harness.net.sse_server_status"),
406 "websocket_accept" => Some("harness.net.websocket_accept"),
407 "websocket_close" => Some("harness.net.websocket_close"),
408 "websocket_connect" => Some("harness.net.websocket_connect"),
409 "websocket_receive" => Some("harness.net.websocket_receive"),
410 "websocket_route" => Some("harness.net.websocket_route"),
411 "websocket_send" => Some("harness.net.websocket_send"),
412 "websocket_server" => Some("harness.net.websocket_server"),
413 "websocket_server_close" => Some("harness.net.websocket_server_close"),
414 _ => None,
415 }
416}
417
418pub fn render_diagnostic(
429 source: &str,
430 filename: &str,
431 span: &Span,
432 severity: &str,
433 message: &str,
434 label: Option<&str>,
435 help: Option<&str>,
436) -> String {
437 render_diagnostic_inner(RenderDiagnostic {
438 source,
439 filename,
440 span,
441 severity,
442 code: None,
443 message,
444 label,
445 help,
446 related: &[],
447 repair: None,
448 })
449}
450
451pub fn render_diagnostic_with_code(
452 source: &str,
453 filename: &str,
454 span: &Span,
455 severity: &str,
456 code: crate::diagnostic_codes::Code,
457 message: &str,
458 label: Option<&str>,
459 help: Option<&str>,
460) -> String {
461 let repair_owned = code.repair_template().map(Repair::from_template);
462 render_diagnostic_inner(RenderDiagnostic {
463 source,
464 filename,
465 span,
466 severity,
467 code: Some(code.as_str()),
468 message,
469 label,
470 help,
471 related: &[],
472 repair: repair_owned.as_ref(),
473 })
474}
475
476pub fn render_diagnostic_with_related(
477 source: &str,
478 filename: &str,
479 span: &Span,
480 severity: &str,
481 message: &str,
482 label: Option<&str>,
483 help: Option<&str>,
484 related: &[RelatedSpanLabel<'_>],
485) -> String {
486 render_diagnostic_inner(RenderDiagnostic {
487 source,
488 filename,
489 span,
490 severity,
491 code: None,
492 message,
493 label,
494 help,
495 related,
496 repair: None,
497 })
498}
499
500struct RenderDiagnostic<'a> {
501 source: &'a str,
502 filename: &'a str,
503 span: &'a Span,
504 severity: &'a str,
505 code: Option<&'a str>,
506 message: &'a str,
507 label: Option<&'a str>,
508 help: Option<&'a str>,
509 related: &'a [RelatedSpanLabel<'a>],
510 repair: Option<&'a Repair>,
511}
512
513fn render_diagnostic_inner(input: RenderDiagnostic<'_>) -> String {
514 let mut out = String::new();
515 let source = input.source;
516 let span = input.span;
517 let severity = input.severity;
518 let message = input.message;
519 let label = input.label;
520 let help = input.help;
521 let related = input.related;
522 let filename = normalize_diagnostic_path(input.filename);
523 let severity_color = severity_color(severity);
524 let gutter = style_fragment("|", Color::Blue, false);
525 let arrow = style_fragment("-->", Color::Blue, true);
526 let help_prefix = style_fragment("help", Color::Cyan, true);
527 let note_prefix = style_fragment("note", Color::Magenta, true);
528
529 out.push_str(&style_fragment(severity, severity_color, true));
530 if let Some(code) = input.code {
531 out.push('[');
532 out.push_str(code);
533 out.push(']');
534 }
535 out.push_str(": ");
536 out.push_str(message);
537 out.push('\n');
538
539 let line_num = span.line;
540 let col_num = span.column;
541
542 let gutter_width = line_num.to_string().len();
543
544 out.push_str(&format!(
545 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
546 " ",
547 width = gutter_width + 1,
548 ));
549
550 out.push_str(&format!(
551 "{:>width$} {gutter}\n",
552 " ",
553 width = gutter_width + 1,
554 ));
555
556 let source_line_opt = line_num.checked_sub(1).and_then(|n| source.lines().nth(n));
557 if let Some(source_line) = source_line_opt {
558 out.push_str(&format!(
559 "{:>width$} {gutter} {source_line}\n",
560 line_num,
561 width = gutter_width + 1,
562 ));
563
564 if let Some(label_text) = label {
565 let span_len = diagnostic_span_char_len(source, span);
567 let col_num = col_num.max(1);
568 let padding = " ".repeat(col_num - 1);
569 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
570 out.push_str(&format!(
571 "{:>width$} {gutter} {padding}{carets} {label_text}\n",
572 " ",
573 width = gutter_width + 1,
574 ));
575 }
576 }
577
578 if let Some(help_text) = help {
579 out.push_str(&format!(
580 "{:>width$} = {help_prefix}: {help_text}\n",
581 " ",
582 width = gutter_width + 1,
583 ));
584 }
585
586 if let Some(repair) = input.repair {
587 let repair_prefix = style_fragment("repair", Color::Cyan, true);
588 out.push_str(&format!(
589 "{:>width$} = {repair_prefix}: {} [{}] — {}\n",
590 " ",
591 repair.id,
592 repair.safety,
593 repair.summary,
594 width = gutter_width + 1,
595 ));
596 }
597
598 for item in related {
599 out.push_str(&format!(
600 "{:>width$} = {note_prefix}: {}\n",
601 " ",
602 item.label,
603 width = gutter_width + 1,
604 ));
605 render_related_span(
606 &mut out,
607 source,
608 &filename,
609 item.span,
610 item.label,
611 gutter_width,
612 );
613 }
614
615 if let Some(note_text) = fun_note(severity) {
616 out.push_str(&format!(
617 "{:>width$} = {note_prefix}: {note_text}\n",
618 " ",
619 width = gutter_width + 1,
620 ));
621 }
622
623 out
624}
625
626pub fn render_type_diagnostic(
627 source: &str,
628 filename: &str,
629 diag: &crate::typechecker::TypeDiagnostic,
630) -> String {
631 let severity = match diag.severity {
632 crate::typechecker::DiagnosticSeverity::Error => "error",
633 crate::typechecker::DiagnosticSeverity::Warning => "warning",
634 };
635 let related = diag
636 .related
637 .iter()
638 .map(|related| RelatedSpanLabel {
639 span: &related.span,
640 label: &related.message,
641 })
642 .collect::<Vec<_>>();
643 let primary_label = type_diagnostic_primary_label(diag);
644 match &diag.span {
645 Some(span) => render_diagnostic_inner(RenderDiagnostic {
646 source,
647 filename,
648 span,
649 severity,
650 code: Some(diag.code.as_str()),
651 message: &diag.message,
652 label: primary_label.as_deref(),
653 help: diag.help.as_deref(),
654 related: &related,
655 repair: diag.repair.as_ref(),
656 }),
657 None => match diag.repair.as_ref() {
658 Some(repair) => format!(
659 "{severity}[{}]: {}\n = repair: {} [{}] — {}\n",
660 diag.code, diag.message, repair.id, repair.safety, repair.summary,
661 ),
662 None => format!("{severity}[{}]: {}\n", diag.code, diag.message),
663 },
664 }
665}
666
667pub fn lexer_error_code(err: &harn_lexer::LexerError) -> crate::diagnostic_codes::Code {
668 match err {
669 harn_lexer::LexerError::UnexpectedCharacter(_, _) => {
670 crate::diagnostic_codes::Code::ParserUnexpectedCharacter
671 }
672 harn_lexer::LexerError::UnterminatedString(_) => {
673 crate::diagnostic_codes::Code::ParserUnterminatedString
674 }
675 harn_lexer::LexerError::UnterminatedBlockComment(_) => {
676 crate::diagnostic_codes::Code::ParserUnterminatedBlockComment
677 }
678 harn_lexer::LexerError::IntegerLiteralOutOfRange(_, _) => {
679 crate::diagnostic_codes::Code::ParserIntegerLiteralOutOfRange
680 }
681 }
682}
683
684pub fn parser_error_code(err: &crate::parser::ParserError) -> crate::diagnostic_codes::Code {
685 match err {
686 crate::parser::ParserError::Unexpected { .. } => {
687 crate::diagnostic_codes::Code::ParserUnexpectedToken
688 }
689 crate::parser::ParserError::UnexpectedEof { .. } => {
690 crate::diagnostic_codes::Code::ParserUnexpectedEof
691 }
692 }
693}
694
695fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
696 match &diag.details {
697 Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
698 Some(format!("lint[{rule}]"))
699 }
700 Some(crate::typechecker::DiagnosticDetails::TypeMismatch { .. }) => {
701 Some("found this type".to_string())
702 }
703 _ => None,
704 }
705}
706
707fn render_related_span(
708 out: &mut String,
709 source: &str,
710 filename: &str,
711 span: &Span,
712 label: &str,
713 primary_gutter_width: usize,
714) {
715 let filename = normalize_diagnostic_path(filename);
716 let severity_color = Color::Magenta;
717 let gutter = style_fragment("|", Color::Blue, false);
718 let arrow = style_fragment("-->", Color::Blue, true);
719 let line_num = span.line;
720 let col_num = span.column;
721 let gutter_width = primary_gutter_width.max(line_num.to_string().len());
722
723 out.push_str(&format!(
724 "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
725 " ",
726 width = gutter_width + 1,
727 ));
728 out.push_str(&format!(
729 "{:>width$} {gutter}\n",
730 " ",
731 width = gutter_width + 1,
732 ));
733
734 if let Some(source_line) = line_num.checked_sub(1).and_then(|n| source.lines().nth(n)) {
735 out.push_str(&format!(
736 "{:>width$} {gutter} {source_line}\n",
737 line_num,
738 width = gutter_width + 1,
739 ));
740 let span_len = diagnostic_span_char_len(source, span);
741 let padding = " ".repeat(col_num.max(1) - 1);
742 let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
743 out.push_str(&format!(
744 "{:>width$} {gutter} {padding}{carets} {label}\n",
745 " ",
746 width = gutter_width + 1,
747 ));
748 }
749}
750
751fn diagnostic_span_char_len(source: &str, span: &Span) -> usize {
752 if span.end <= span.start || span.start >= source.len() {
753 return 1;
754 }
755 let mut start = span.start.min(source.len());
756 while start > 0 && !source.is_char_boundary(start) {
757 start -= 1;
758 }
759 let mut end = span.end.min(source.len());
760 while end < source.len() && !source.is_char_boundary(end) {
761 end += 1;
762 }
763 source
764 .get(start..end)
765 .map(|text| text.chars().count().max(1))
766 .unwrap_or(1)
767}
768
769fn severity_color(severity: &str) -> Color {
770 match severity {
771 "error" => Color::Red,
772 "warning" => Color::Yellow,
773 "note" => Color::Magenta,
774 _ => Color::Cyan,
775 }
776}
777
778fn style_fragment(text: &str, color: Color, bold: bool) -> String {
779 if !colors_enabled() {
780 return text.to_string();
781 }
782
783 let mut paint = Paint::new(text).fg(color);
784 if bold {
785 paint = paint.bold();
786 }
787 paint.to_string()
788}
789
790thread_local! {
791 static COLOR_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
797}
798
799#[cfg(test)]
802pub(crate) fn set_color_override(force: Option<bool>) {
803 COLOR_OVERRIDE.with(|cell| cell.set(force));
804}
805
806fn colors_enabled() -> bool {
807 if let Some(forced) = COLOR_OVERRIDE.with(std::cell::Cell::get) {
808 return forced;
809 }
810 std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
811}
812
813fn fun_note(severity: &str) -> Option<&'static str> {
814 if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
815 return None;
816 }
817
818 Some(match severity {
819 "error" => "the compiler stepped on a rake here.",
820 "warning" => "this still runs, but it has strong 'double-check me' energy.",
821 _ => "a tiny gremlin has left a note in the margins.",
822 })
823}
824
825pub fn parser_error_message(err: &ParserError) -> String {
826 match err {
827 ParserError::Unexpected { got, expected, .. } => {
828 format!("expected {expected}, found {got}")
829 }
830 ParserError::UnexpectedEof { expected, .. } => {
831 format!("unexpected end of file, expected {expected}")
832 }
833 }
834}
835
836pub fn parser_error_label(err: &ParserError) -> &'static str {
837 match err {
838 ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
839 ParserError::Unexpected { .. } => "unexpected token",
840 ParserError::UnexpectedEof { .. } => "file ends here",
841 }
842}
843
844pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
845 match err {
846 ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
847 match expected.as_str() {
848 "}" => Some("add a closing `}` to finish this block"),
849 ")" => Some("add a closing `)` to finish this expression or parameter list"),
850 "]" => Some("add a closing `]` to finish this list or subscript"),
851 "fn, struct, enum, or pipeline after pub" => {
852 Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
853 }
854 "fn, tool, skill, eval_pack, struct, enum, type, pipeline, const, let, or import after pub" => Some(
855 "use `pub` with `fn`, `tool`, `skill`, `eval_pack`, `struct`, `enum`, `type`, `pipeline`, `const`, `let`, or `import`",
856 ),
857 _ => None,
858 }
859 }
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 fn disable_colors() {
872 set_color_override(Some(false));
873 }
874
875 #[test]
876 fn test_basic_diagnostic() {
877 disable_colors();
878 let source = "pipeline default(task) {\n const y = x + 1\n}";
879 let span = Span {
880 start: 28,
881 end: 29,
882 line: 2,
883 column: 13,
884 end_line: 2,
885 };
886 let output = render_diagnostic(
887 source,
888 "example.harn",
889 &span,
890 "error",
891 "undefined variable `x`",
892 Some("not found in this scope"),
893 None,
894 );
895 assert!(output.contains("error: undefined variable `x`"));
896 assert!(output.contains("--> example.harn:2:13"));
897 assert!(output.contains("const y = x + 1"));
898 assert!(output.contains("^ not found in this scope"));
899 }
900
901 #[test]
902 fn test_diagnostic_normalizes_filename() {
903 disable_colors();
904 let source = "const value = thing";
905 let span = Span {
906 start: 12,
907 end: 17,
908 line: 1,
909 column: 13,
910 end_line: 1,
911 };
912 let output = render_diagnostic(
913 source,
914 "/workspace/pipelines/mode/../lib/runtime/loop.harn",
915 &span,
916 "error",
917 "bad value",
918 Some("here"),
919 None,
920 );
921 assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
922 assert!(!output.contains("/../"));
923 }
924
925 #[test]
926 fn test_diagnostic_with_help() {
927 disable_colors();
928 let source = "const y = xx + 1";
929 let span = Span {
930 start: 8,
931 end: 10,
932 line: 1,
933 column: 9,
934 end_line: 1,
935 };
936 let output = render_diagnostic(
937 source,
938 "test.harn",
939 &span,
940 "error",
941 "undefined variable `xx`",
942 Some("not found in this scope"),
943 Some("did you mean `x`?"),
944 );
945 assert!(output.contains("help: did you mean `x`?"));
946 }
947
948 #[test]
949 fn test_multiline_source() {
950 disable_colors();
951 let source = "line1\nline2\nline3";
952 let span = Span::with_offsets(6, 11, 2, 1); let result = render_diagnostic(
954 source,
955 "test.harn",
956 &span,
957 "error",
958 "bad line",
959 Some("here"),
960 None,
961 );
962 assert!(result.contains("line2"));
963 assert!(result.contains("^^^^^"));
964 }
965
966 #[test]
967 fn diagnostic_rendering_tolerates_offsets_inside_utf8_codepoints() {
968 disable_colors();
969 let source = "// capability owner — narrow helper";
970 let em_dash = source.find('—').expect("em dash");
971 let span = Span::with_offsets(0, em_dash + 1, 1, 1);
972 let output = render_diagnostic(
973 source,
974 "unicode.harn",
975 &span,
976 "warning",
977 "legacy comment",
978 Some("rewrite this comment"),
979 None,
980 );
981 assert!(output.contains("capability owner — narrow helper"));
982 assert!(output.contains("rewrite this comment"));
983 }
984
985 #[test]
986 fn test_single_char_span() {
987 disable_colors();
988 let source = "const x = 42";
989 let span = Span::with_offsets(4, 5, 1, 5); let result = render_diagnostic(
991 source,
992 "test.harn",
993 &span,
994 "warning",
995 "unused",
996 Some("never used"),
997 None,
998 );
999 assert!(result.contains('^'));
1000 assert!(result.contains("never used"));
1001 }
1002
1003 #[test]
1004 fn test_with_help() {
1005 disable_colors();
1006 let source = "const y = reponse";
1007 let span = Span::with_offsets(8, 15, 1, 9);
1008 let result = render_diagnostic(
1009 source,
1010 "test.harn",
1011 &span,
1012 "error",
1013 "undefined",
1014 None,
1015 Some("did you mean `response`?"),
1016 );
1017 assert!(result.contains("help:"));
1018 assert!(result.contains("response"));
1019 }
1020
1021 #[test]
1022 fn closest_match_suggests_reordered_snake_case_segments() {
1023 assert_eq!(
1024 find_closest_match("parse_json", ["json_parse"].into_iter(), 2),
1025 Some("json_parse")
1026 );
1027 assert_eq!(
1028 find_closest_match("read_file", ["file_read"].into_iter(), 2),
1029 Some("file_read")
1030 );
1031 assert_eq!(
1032 find_closest_match("parse_json", ["parse_yaml"].into_iter(), 2),
1033 None
1034 );
1035 }
1036
1037 #[test]
1038 fn closest_match_suggests_plain_typo() {
1039 assert_eq!(
1040 find_closest_match("json_pars", ["json_parse"].into_iter(), 2),
1041 Some("json_parse")
1042 );
1043 }
1044
1045 #[test]
1046 fn test_parser_error_helpers_for_eof() {
1047 disable_colors();
1048 let err = ParserError::UnexpectedEof {
1049 expected: "}".into(),
1050 span: Span::with_offsets(10, 10, 3, 1),
1051 };
1052 assert_eq!(
1053 parser_error_message(&err),
1054 "unexpected end of file, expected }"
1055 );
1056 assert_eq!(parser_error_label(&err), "file ends here");
1057 assert_eq!(
1058 parser_error_help(&err),
1059 Some("add a closing `}` to finish this block")
1060 );
1061 }
1062}
1063
1064#[cfg(test)]
1065mod harness_migration_tests {
1066 use super::{
1067 generated_harness_migration, removed_global_replacement, renamed_stdlib_symbol,
1068 HARNESS_MIGRATION_DISAGREEMENTS, HARNESS_REPLACEMENT_FAMILIES,
1069 };
1070
1071 #[test]
1075 fn a_migrated_global_resolves_instead_of_falling_through_to_a_guess() {
1076 assert_eq!(
1077 removed_global_replacement("uuid_v7"),
1078 Some("harness.random.uuid_v7")
1079 );
1080 assert_eq!(renamed_stdlib_symbol("uuid_v7"), None);
1083 }
1084
1085 #[test]
1089 fn a_name_collision_resolves_to_the_migration_not_the_same_named_method() {
1090 for (legacy, migration, reason) in HARNESS_MIGRATION_DISAGREEMENTS {
1091 assert_eq!(
1092 removed_global_replacement(legacy),
1093 Some(*migration),
1094 "`{legacy}` must resolve to its migration — {reason}"
1095 );
1096 assert_ne!(
1097 generated_harness_migration(legacy),
1098 Some(*migration),
1099 "`{legacy}` is pinned as a disagreement but the generated table now \
1100 agrees; drop it from HARNESS_MIGRATION_DISAGREEMENTS"
1101 );
1102 }
1103 }
1104
1105 #[test]
1109 fn no_unreviewed_disagreement_between_the_hand_written_and_generated_tables() {
1110 let pinned: Vec<&str> = HARNESS_MIGRATION_DISAGREEMENTS
1111 .iter()
1112 .map(|(legacy, _, _)| *legacy)
1113 .collect();
1114 let mut unreviewed = Vec::new();
1115 for (legacy, generated) in super::harness_migrations_generated::HARNESS_MIGRATIONS {
1116 if pinned.contains(legacy) {
1117 continue;
1118 }
1119 for family in HARNESS_REPLACEMENT_FAMILIES {
1120 if let Some(hand_written) = family(legacy) {
1121 if hand_written != *generated {
1122 unreviewed.push(format!(
1123 "`{legacy}`: hand-written={hand_written} generated={generated}"
1124 ));
1125 }
1126 }
1127 }
1128 }
1129 assert!(
1130 unreviewed.is_empty(),
1131 "these names disagree without a reviewed reason:\n{}",
1132 unreviewed.join("\n")
1133 );
1134 }
1135
1136 #[test]
1138 fn the_generated_table_is_sorted_and_unique() {
1139 let table = super::harness_migrations_generated::HARNESS_MIGRATIONS;
1140 assert!(table.len() > 100, "expected the whole migrated surface");
1141 for pair in table.windows(2) {
1142 assert!(
1143 pair[0].0 < pair[1].0,
1144 "`{}` and `{}` are out of order or duplicated",
1145 pair[0].0,
1146 pair[1].0
1147 );
1148 }
1149 }
1150}