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