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