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