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