Skip to main content

harn_parser/
diagnostic.rs

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
11/// The typed harness path that replaced a removed global, from the generated
12/// projection of `harn_vm::stdlib::harness_migration_for_builtin`.
13///
14/// `harn-parser` compiles below `harn-vm`, so it cannot query the registry;
15/// `make gen-harness-migrations` emits the table and
16/// `make check-harness-migrations` keeps it from drifting.
17///
18/// **This is the fallback, not the first answer.** The hand-written tables
19/// above it encode migration decisions the registry cannot express, because the
20/// registry is keyed by name and some legacy globals collide with unrelated
21/// typed methods — the ambient `read_file` migrated to `harness.fs.read_text`,
22/// but a *different* `harness.tools.read_file` also exists, and that is what a
23/// name lookup finds. [`removed_global_replacement`] composes the sources in
24/// the right order; [`HARNESS_MIGRATION_DISAGREEMENTS`] is the audited list.
25pub 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
37/// Normalize diagnostic filenames lexically for display.
38///
39/// This deliberately does not touch the filesystem: diagnostics should cancel
40/// `.` and `..` path components even when the path points at a file that no
41/// longer exists, without resolving symlinks.
42pub 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
111/// Find the closest match to `name` among `candidates`, within `max_dist` edits
112/// or by reordering its non-empty underscore-separated segments. Candidates
113/// within the edit-distance threshold rank ahead of reorder-only matches.
114pub 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
133/// Return the replacement for stdlib symbols that were directly renamed.
134pub 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
188/// What an undefined name *should* have been, for the type checker's
189/// "did you mean".
190///
191/// Three sources, narrowest first: the in-place renames above, then the
192/// `harness_*_replacement` families, then [`generated_harness_migration`] for
193/// the long tail — 417 generated rows against roughly 60 hand-written ones.
194///
195/// Deliberately separate from [`renamed_stdlib_symbol`], which the linter uses
196/// to decide whether to raise `HARN-LNT-001`. Widening *that* makes every
197/// migrated global draw two warnings: the rename lint and the capability-family
198/// lint that already claims the name (`HARN-LNT-052`/`053`/`054`/`057`/`071`).
199/// Measured before splitting them — a four-call probe went from four findings to
200/// eight. Which lint should own a name is a policy question; answering "what
201/// replaced it" is not, and only the type checker asks this one.
202///
203/// Order matters, and not for style. The generated table is keyed by builtin
204/// name, and some legacy globals share a name with an unrelated typed method —
205/// the ambient `read_file` migrated to `harness.fs.read_text`, while a separate
206/// `harness.tools.read_file` agent tool is what a name lookup finds.
207/// [`HARNESS_MIGRATION_DISAGREEMENTS`] pins those, so a new one is a build
208/// failure rather than a confidently wrong suggestion.
209pub 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
221/// The `harness_*_replacement` families, in one list so anything meaning "any
222/// capability migration" walks the same set.
223///
224/// A new family belongs here as well as beside its siblings. Nothing proves
225/// that mechanically — Rust has no reflection over free functions — but this
226/// list is the only way anything reaches them in bulk, so forgetting it leaves
227/// the new family unreachable rather than half-wired.
228pub 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
237/// Legacy globals whose hand-written migration deliberately differs from what a
238/// name lookup in the generated table finds, with the reason.
239///
240/// Reviewed rather than discovered: a new entry means the runtime registry and
241/// the migration record disagree about a name, which is a finding to
242/// investigate — not a list to extend casually.
243pub 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
266/// Map an ambient clock-capability builtin to its `harness.clock.*`
267/// replacement. Returns the new identifier text (including the receiver
268/// path) so the `bindings/thread-harness-clock` repair can replace the
269/// call-site identifier in place. The mapping is the source of truth for
270/// the E4.3 → E4.6 migration; downstream replatform agents query it via
271/// [`Code::repair_template`].
272pub 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
286/// Map an ambient stdio-capability builtin to its `harness.stdio.*`
287/// replacement so `harn fix` can replace the call in place once the
288/// relevant harness binding is available.
289pub 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
301/// Map an ambient fs-capability builtin to its `harness.fs.*` replacement.
302/// Backs the `bindings/thread-harness-fs` repair the E4.4 → E4.6
303/// migration uses to rewrite `.harn` scripts off the legacy surface.
304pub 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
341/// Map an ambient env-capability builtin to its `harness.env.*` replacement.
342/// Backs the `bindings/thread-harness-env` repair.
343pub 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
351/// Map an ambient random-capability builtin to its `harness.random.*`
352/// replacement. Backs the `bindings/thread-harness-random` repair.
353pub 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
363/// Map an ambient net-capability builtin to its `harness.net.*`
364/// replacement. Backs the `bindings/thread-harness-net` repair. Every
365/// script-facing network effect is exposed only through `HarnessNet`; response
366/// constructors and event encoders remain pure globals.
367pub 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
423/// Render a Rust-style diagnostic message.
424///
425/// Example output:
426/// ```text
427/// error: undefined variable `x`
428///   --> example.harn:5:12
429///    |
430///  5 |     let y = x + 1
431///    |             ^ not found in this scope
432/// ```
433pub 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            // Span width must use char count, not byte offsets, so carets align with the source text.
571            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        crate::typechecker::DiagnosticSeverity::Info => "info",
640    };
641    let related = diag
642        .related
643        .iter()
644        .map(|related| RelatedSpanLabel {
645            span: &related.span,
646            label: &related.message,
647        })
648        .collect::<Vec<_>>();
649    let primary_label = type_diagnostic_primary_label(diag);
650    match &diag.span {
651        Some(span) => render_diagnostic_inner(RenderDiagnostic {
652            source,
653            filename,
654            span,
655            severity,
656            code: Some(diag.code.as_str()),
657            message: &diag.message,
658            label: primary_label.as_deref(),
659            help: diag.help.as_deref(),
660            related: &related,
661            repair: diag.repair.as_ref(),
662        }),
663        None => match diag.repair.as_ref() {
664            Some(repair) => format!(
665                "{severity}[{}]: {}\n  = repair: {} [{}] — {}\n",
666                diag.code, diag.message, repair.id, repair.safety, repair.summary,
667            ),
668            None => format!("{severity}[{}]: {}\n", diag.code, diag.message),
669        },
670    }
671}
672
673pub fn lexer_error_code(err: &harn_lexer::LexerError) -> crate::diagnostic_codes::Code {
674    match err {
675        harn_lexer::LexerError::UnexpectedCharacter(_, _) => {
676            crate::diagnostic_codes::Code::ParserUnexpectedCharacter
677        }
678        harn_lexer::LexerError::UnterminatedString(_) => {
679            crate::diagnostic_codes::Code::ParserUnterminatedString
680        }
681        harn_lexer::LexerError::UnterminatedBlockComment(_) => {
682            crate::diagnostic_codes::Code::ParserUnterminatedBlockComment
683        }
684        harn_lexer::LexerError::IntegerLiteralOutOfRange(_, _) => {
685            crate::diagnostic_codes::Code::ParserIntegerLiteralOutOfRange
686        }
687    }
688}
689
690pub fn parser_error_code(err: &crate::parser::ParserError) -> crate::diagnostic_codes::Code {
691    match err {
692        crate::parser::ParserError::Unexpected { .. } => {
693            crate::diagnostic_codes::Code::ParserUnexpectedToken
694        }
695        crate::parser::ParserError::UnexpectedEof { .. } => {
696            crate::diagnostic_codes::Code::ParserUnexpectedEof
697        }
698        crate::parser::ParserError::RemovedRangeSyntax { .. } => {
699            crate::diagnostic_codes::Code::ParserRemovedRangeSyntax
700        }
701    }
702}
703
704fn type_diagnostic_primary_label(diag: &crate::typechecker::TypeDiagnostic) -> Option<String> {
705    match &diag.details {
706        Some(crate::typechecker::DiagnosticDetails::LintRule { rule }) => {
707            Some(format!("lint[{rule}]"))
708        }
709        Some(crate::typechecker::DiagnosticDetails::TypeMismatch { .. }) => {
710            Some("found this type".to_string())
711        }
712        Some(crate::typechecker::DiagnosticDetails::ImplicitAnyParameter { .. }) => {
713            Some("needs a type".to_string())
714        }
715        _ => None,
716    }
717}
718
719fn render_related_span(
720    out: &mut String,
721    source: &str,
722    filename: &str,
723    span: &Span,
724    label: &str,
725    primary_gutter_width: usize,
726) {
727    let filename = normalize_diagnostic_path(filename);
728    let severity_color = Color::Magenta;
729    let gutter = style_fragment("|", Color::Blue, false);
730    let arrow = style_fragment("-->", Color::Blue, true);
731    let line_num = span.line;
732    let col_num = span.column;
733    let gutter_width = primary_gutter_width.max(line_num.to_string().len());
734
735    out.push_str(&format!(
736        "{:>width$}{arrow} {filename}:{line_num}:{col_num}\n",
737        " ",
738        width = gutter_width + 1,
739    ));
740    out.push_str(&format!(
741        "{:>width$} {gutter}\n",
742        " ",
743        width = gutter_width + 1,
744    ));
745
746    if let Some(source_line) = line_num.checked_sub(1).and_then(|n| source.lines().nth(n)) {
747        out.push_str(&format!(
748            "{:>width$} {gutter} {source_line}\n",
749            line_num,
750            width = gutter_width + 1,
751        ));
752        let span_len = diagnostic_span_char_len(source, source_line, span);
753        let padding = " ".repeat(col_num.max(1) - 1);
754        let carets = style_fragment(&"^".repeat(span_len), severity_color, true);
755        out.push_str(&format!(
756            "{:>width$} {gutter} {padding}{carets} {label}\n",
757            " ",
758            width = gutter_width + 1,
759        ));
760    }
761}
762
763fn diagnostic_span_char_len(source: &str, source_line: &str, span: &Span) -> usize {
764    if span.end <= span.start || span.start >= source.len() {
765        return 1;
766    }
767    let start = source.floor_char_boundary(span.start);
768    let end = source.ceil_char_boundary(span.end);
769    let span_len = source
770        .get(start..end)
771        .map(|text| text.lines().next().unwrap_or(text).chars().count().max(1))
772        .unwrap_or(1);
773    let visible_line_len = source_line
774        .chars()
775        .count()
776        .saturating_sub(span.column.saturating_sub(1))
777        .max(1);
778    span_len.min(visible_line_len)
779}
780
781fn severity_color(severity: &str) -> Color {
782    match severity {
783        "error" => Color::Red,
784        "warning" => Color::Yellow,
785        "note" => Color::Magenta,
786        _ => Color::Cyan,
787    }
788}
789
790fn style_fragment(text: &str, color: Color, bold: bool) -> String {
791    if !colors_enabled() {
792        return text.to_string();
793    }
794
795    let mut paint = Paint::new(text).fg(color);
796    if bold {
797        paint = paint.bold();
798    }
799    paint.to_string()
800}
801
802thread_local! {
803    /// Per-thread override for color output. When `Some`, it wins over the
804    /// `NO_COLOR` env var and TTY detection. This lets tests force colors off
805    /// deterministically without mutating the process-global `NO_COLOR` env
806    /// var, which races across parallel tests (each test runs on its own
807    /// thread, so the override is naturally isolated).
808    static COLOR_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
809}
810
811/// Force color output on (`Some(true)`), off (`Some(false)`), or restore the
812/// default env/TTY behavior (`None`) for the current thread only.
813#[cfg(test)]
814pub(crate) fn set_color_override(force: Option<bool>) {
815    COLOR_OVERRIDE.with(|cell| cell.set(force));
816}
817
818fn colors_enabled() -> bool {
819    if let Some(forced) = COLOR_OVERRIDE.with(std::cell::Cell::get) {
820        return forced;
821    }
822    std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal()
823}
824
825fn fun_note(severity: &str) -> Option<&'static str> {
826    if std::env::var("HARN_FUN").ok().as_deref() != Some("1") {
827        return None;
828    }
829
830    Some(match severity {
831        "error" => "the compiler stepped on a rake here.",
832        "warning" => "this still runs, but it has strong 'double-check me' energy.",
833        _ => "a tiny gremlin has left a note in the margins.",
834    })
835}
836
837pub fn parser_error_message(err: &ParserError) -> String {
838    match err {
839        ParserError::Unexpected { got, expected, .. } => {
840            format!("expected {expected}, found {got}")
841        }
842        ParserError::UnexpectedEof { expected, .. } => {
843            format!("unexpected end of file, expected {expected}")
844        }
845        ParserError::RemovedRangeSyntax { .. } => {
846            "`start to end` ranges were removed; ranges are `start..end` (end excluded) or `start..=end` (end included)".to_string()
847        }
848    }
849}
850
851pub fn parser_error_label(err: &ParserError) -> &'static str {
852    match err {
853        ParserError::Unexpected { got, .. } if got == "Newline" => "line break not allowed here",
854        ParserError::Unexpected { .. } => "unexpected token",
855        ParserError::UnexpectedEof { .. } => "file ends here",
856        ParserError::RemovedRangeSyntax { .. } => "retired range keyword",
857    }
858}
859
860pub fn parser_error_help(err: &ParserError) -> Option<&'static str> {
861    match err {
862        ParserError::RemovedRangeSyntax { .. } => Some(
863            "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",
864        ),
865        ParserError::UnexpectedEof { expected, .. } | ParserError::Unexpected { expected, .. } => {
866            match expected.as_str() {
867                "}" => Some("add a closing `}` to finish this block"),
868                ")" => Some("add a closing `)` to finish this expression or parameter list"),
869                "]" => Some("add a closing `]` to finish this list or subscript"),
870                "fn, struct, enum, or pipeline after pub" => {
871                    Some("use `pub fn`, `pub pipeline`, `pub enum`, or `pub struct`")
872                }
873                "fn, tool, skill, eval_pack, struct, enum, type, pipeline, const, let, or import after pub" => Some(
874                    "use `pub` with `fn`, `tool`, `skill`, `eval_pack`, `struct`, `enum`, `type`, `pipeline`, `const`, `let`, or `import`",
875                ),
876                _ => None,
877            }
878        }
879    }
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    /// Ensure ANSI colors are off so plain-text assertions work regardless
887    /// of whether the test runner's stderr is a TTY. Uses a thread-local
888    /// override rather than the process-global `NO_COLOR` env var so it can't
889    /// race with color-sensitive assertions in parallel tests.
890    fn disable_colors() {
891        set_color_override(Some(false));
892    }
893
894    #[test]
895    fn test_basic_diagnostic() {
896        disable_colors();
897        let source = "pipeline default(task) {\n    const y = x + 1\n}";
898        let span = Span {
899            start: 28,
900            end: 29,
901            line: 2,
902            column: 13,
903            end_line: 2,
904        };
905        let output = render_diagnostic(
906            source,
907            "example.harn",
908            &span,
909            "error",
910            "undefined variable `x`",
911            Some("not found in this scope"),
912            None,
913        );
914        assert!(output.contains("error: undefined variable `x`"));
915        assert!(output.contains("--> example.harn:2:13"));
916        assert!(output.contains("const y = x + 1"));
917        assert!(output.contains("^ not found in this scope"));
918    }
919
920    #[test]
921    fn test_diagnostic_normalizes_filename() {
922        disable_colors();
923        let source = "const value = thing";
924        let span = Span {
925            start: 12,
926            end: 17,
927            line: 1,
928            column: 13,
929            end_line: 1,
930        };
931        let output = render_diagnostic(
932            source,
933            "/workspace/pipelines/mode/../lib/runtime/loop.harn",
934            &span,
935            "error",
936            "bad value",
937            Some("here"),
938            None,
939        );
940        assert!(output.contains("--> /workspace/pipelines/lib/runtime/loop.harn:1:13"));
941        assert!(!output.contains("/../"));
942    }
943
944    #[test]
945    fn test_diagnostic_with_help() {
946        disable_colors();
947        let source = "const y = xx + 1";
948        let span = Span {
949            start: 8,
950            end: 10,
951            line: 1,
952            column: 9,
953            end_line: 1,
954        };
955        let output = render_diagnostic(
956            source,
957            "test.harn",
958            &span,
959            "error",
960            "undefined variable `xx`",
961            Some("not found in this scope"),
962            Some("did you mean `x`?"),
963        );
964        assert!(output.contains("help: did you mean `x`?"));
965    }
966
967    #[test]
968    fn test_multiline_source() {
969        disable_colors();
970        let source = "line1\nline2\nline3";
971        let span = Span::with_offsets(6, 11, 2, 1); // "line2"
972        let result = render_diagnostic(
973            source,
974            "test.harn",
975            &span,
976            "error",
977            "bad line",
978            Some("here"),
979            None,
980        );
981        assert!(result.contains("line2"));
982        assert!(result.contains("^^^^^"));
983    }
984
985    #[test]
986    fn multiline_span_underline_stops_at_rendered_source_line() {
987        disable_colors();
988        let source =
989            "fn make() -> Config {\n  return {\n    first: true,\n    second: false,\n  }\n}";
990        let start = source.find("return {").expect("return record") + "return ".len();
991        let end = source.rfind('}').expect("record end") + 1;
992        let span = Span {
993            start,
994            end,
995            line: 2,
996            column: 10,
997            end_line: 5,
998        };
999
1000        let output = render_diagnostic(
1001            source,
1002            "test.harn",
1003            &span,
1004            "error",
1005            "return value has the wrong type",
1006            Some("found this type"),
1007            None,
1008        );
1009
1010        assert!(
1011            output.contains(" 2 |   return {\n   |          ^ found this type\n"),
1012            "{output}"
1013        );
1014
1015        let primary = Span::with_offsets(13, 19, 1, 14);
1016        let related = [RelatedSpanLabel {
1017            span: &span,
1018            label: "return type declared here",
1019        }];
1020        let output = render_diagnostic_with_related(
1021            source,
1022            "test.harn",
1023            &primary,
1024            "error",
1025            "return value has the wrong type",
1026            Some("expected type"),
1027            None,
1028            &related,
1029        );
1030
1031        assert!(
1032            output.contains(
1033                "  = note: return type declared here\n  --> test.harn:2:10\n   |\n 2 |   return {\n   |          ^ return type declared here\n"
1034            ),
1035            "{output}"
1036        );
1037    }
1038
1039    #[test]
1040    fn diagnostic_rendering_tolerates_offsets_inside_utf8_codepoints() {
1041        disable_colors();
1042        let source = "// capability owner — narrow helper";
1043        let em_dash = source.find('—').expect("em dash");
1044        let span = Span::with_offsets(0, em_dash + 1, 1, 1);
1045        let output = render_diagnostic(
1046            source,
1047            "unicode.harn",
1048            &span,
1049            "warning",
1050            "legacy comment",
1051            Some("rewrite this comment"),
1052            None,
1053        );
1054        assert!(output.contains("capability owner — narrow helper"));
1055        assert!(output.contains("rewrite this comment"));
1056    }
1057
1058    #[test]
1059    fn test_single_char_span() {
1060        disable_colors();
1061        let source = "const x = 42";
1062        let span = Span::with_offsets(4, 5, 1, 5); // "x"
1063        let result = render_diagnostic(
1064            source,
1065            "test.harn",
1066            &span,
1067            "warning",
1068            "unused",
1069            Some("never used"),
1070            None,
1071        );
1072        assert!(result.contains('^'));
1073        assert!(result.contains("never used"));
1074    }
1075
1076    #[test]
1077    fn test_with_help() {
1078        disable_colors();
1079        let source = "const y = reponse";
1080        let span = Span::with_offsets(8, 15, 1, 9);
1081        let result = render_diagnostic(
1082            source,
1083            "test.harn",
1084            &span,
1085            "error",
1086            "undefined",
1087            None,
1088            Some("did you mean `response`?"),
1089        );
1090        assert!(result.contains("help:"));
1091        assert!(result.contains("response"));
1092    }
1093
1094    #[test]
1095    fn closest_match_suggests_reordered_snake_case_segments() {
1096        assert_eq!(
1097            find_closest_match("parse_json", ["json_parse"].into_iter(), 2),
1098            Some("json_parse")
1099        );
1100        assert_eq!(
1101            find_closest_match("read_file", ["file_read"].into_iter(), 2),
1102            Some("file_read")
1103        );
1104        assert_eq!(
1105            find_closest_match("parse_json", ["parse_yaml"].into_iter(), 2),
1106            None
1107        );
1108    }
1109
1110    #[test]
1111    fn closest_match_suggests_plain_typo() {
1112        assert_eq!(
1113            find_closest_match("json_pars", ["json_parse"].into_iter(), 2),
1114            Some("json_parse")
1115        );
1116    }
1117
1118    #[test]
1119    fn test_parser_error_helpers_for_eof() {
1120        disable_colors();
1121        let err = ParserError::UnexpectedEof {
1122            expected: "}".into(),
1123            span: Span::with_offsets(10, 10, 3, 1),
1124        };
1125        assert_eq!(
1126            parser_error_message(&err),
1127            "unexpected end of file, expected }"
1128        );
1129        assert_eq!(parser_error_label(&err), "file ends here");
1130        assert_eq!(
1131            parser_error_help(&err),
1132            Some("add a closing `}` to finish this block")
1133        );
1134    }
1135}
1136
1137#[cfg(test)]
1138mod harness_migration_tests {
1139    use super::{
1140        generated_harness_migration, removed_global_replacement, renamed_stdlib_symbol,
1141        HARNESS_MIGRATION_DISAGREEMENTS, HARNESS_REPLACEMENT_FAMILIES,
1142    };
1143
1144    /// The case that motivated harn#6151: the type checker's fuzzy fallback
1145    /// answered `uuid_v5` — a name-based UUID — for the time-ordered `uuid_v7`,
1146    /// while the runtime record one crate away already knew the answer.
1147    #[test]
1148    fn a_migrated_global_resolves_instead_of_falling_through_to_a_guess() {
1149        assert_eq!(
1150            removed_global_replacement("uuid_v7"),
1151            Some("harness.random.uuid_v7")
1152        );
1153        // And the linter's narrower question is unchanged, so `uuid_v7` does
1154        // not start drawing a rename lint on top of its capability lint.
1155        assert_eq!(renamed_stdlib_symbol("uuid_v7"), None);
1156    }
1157
1158    /// The generated table is keyed by builtin name, and a few legacy globals
1159    /// share a name with an unrelated typed method. The hand-written answer has
1160    /// to win, or `harn check` sends a filesystem call to an agent tool.
1161    #[test]
1162    fn a_name_collision_resolves_to_the_migration_not_the_same_named_method() {
1163        for (legacy, migration, reason) in HARNESS_MIGRATION_DISAGREEMENTS {
1164            assert_eq!(
1165                removed_global_replacement(legacy),
1166                Some(*migration),
1167                "`{legacy}` must resolve to its migration — {reason}"
1168            );
1169            assert_ne!(
1170                generated_harness_migration(legacy),
1171                Some(*migration),
1172                "`{legacy}` is pinned as a disagreement but the generated table now \
1173                 agrees; drop it from HARNESS_MIGRATION_DISAGREEMENTS"
1174            );
1175        }
1176    }
1177
1178    /// Every hand-written family entry either matches the generated table or is
1179    /// a pinned disagreement. A fifth divergence fails here rather than shipping
1180    /// a confidently wrong "did you mean".
1181    #[test]
1182    fn no_unreviewed_disagreement_between_the_hand_written_and_generated_tables() {
1183        let pinned: Vec<&str> = HARNESS_MIGRATION_DISAGREEMENTS
1184            .iter()
1185            .map(|(legacy, _, _)| *legacy)
1186            .collect();
1187        let mut unreviewed = Vec::new();
1188        for (legacy, generated) in super::harness_migrations_generated::HARNESS_MIGRATIONS {
1189            if pinned.contains(legacy) {
1190                continue;
1191            }
1192            for family in HARNESS_REPLACEMENT_FAMILIES {
1193                if let Some(hand_written) = family(legacy) {
1194                    if hand_written != *generated {
1195                        unreviewed.push(format!(
1196                            "`{legacy}`: hand-written={hand_written} generated={generated}"
1197                        ));
1198                    }
1199                }
1200            }
1201        }
1202        assert!(
1203            unreviewed.is_empty(),
1204            "these names disagree without a reviewed reason:\n{}",
1205            unreviewed.join("\n")
1206        );
1207    }
1208
1209    /// The generator emits the table sorted; the lookup binary-searches it.
1210    #[test]
1211    fn the_generated_table_is_sorted_and_unique() {
1212        let table = super::harness_migrations_generated::HARNESS_MIGRATIONS;
1213        assert!(table.len() > 100, "expected the whole migrated surface");
1214        for pair in table.windows(2) {
1215            assert!(
1216                pair[0].0 < pair[1].0,
1217                "`{}` and `{}` are out of order or duplicated",
1218                pair[0].0,
1219                pair[1].0
1220            );
1221        }
1222    }
1223}