Skip to main content

harn_rules_hostlib/
lib.rs

1//! Host capability exposing the `harn-rules` declarative rule engine to
2//! Harn as `rules.search` / `rules.report` / `rules.apply`.
3//!
4//! This crate lives outside `harn-hostlib` on purpose: `harn-rules` already
5//! depends on `harn-hostlib` (for the tree-sitter grammars), so the rules
6//! builtins would form a dependency cycle if they lived there. An embedder
7//! (harn-cli, harn-serve) calls [`install`] alongside `harn_hostlib::install_default`.
8//!
9//! ## Builtins
10//!
11//! - `rules.search` (read-only) — run a rule and return its matches.
12//! - `rules.report` (read-only) — run a rule in report-only mode and return
13//!   a [`harn_rules::DataTable`] (counts + per-match rows).
14//! - `rules.diagnostics` (read-only) — run a **declarative** rule and return
15//!   its [`harn_rules::Diagnostic`]s (message + severity + span + fix).
16//! - `rules.visit` (read-only, **async**) — the **imperative** escape hatch:
17//!   run a rule's matcher, then invoke a `.harn` visitor
18//!   `on_match($node, $ctx)` once per match. The visitor returns its
19//!   report(s) — `nil`/`false` to skip, a `{message, fix, safety}` dict, or
20//!   a list of them — which the engine turns into diagnostics of the same
21//!   shape `rules.diagnostics` emits. The visitor has full programmatic
22//!   control (compute a message/fix from the captured metavars), which the
23//!   declarative form cannot.
24//! - `rules.apply` (write-gated) — apply a codemod rule's `fix`; writes only
25//!   when `dry_run: false` *and* the rule is safe to auto-apply (or
26//!   `allow_unsafe: true`). Shares the deterministic-tools gate with the
27//!   other mutating builtins.
28//!
29//! A rule is passed as its TOML source (`rule`), so an agent can author and
30//! run a rule — declarative *or* imperative — entirely from `.harn` without
31//! recompiling the binary.
32//!
33//! ### Why `rules.visit` is async, and why it returns rather than mutates
34//!
35//! A *synchronous* hostlib builtin cannot call a `.harn` closure: the VM's
36//! [`Vm::call_closure_pub`] is async-only. So the visitor is registered as an
37//! **async** builtin (directly on the VM via [`Vm::register_async_builtin`],
38//! bypassing the sync [`HostlibRegistry`]), which can obtain a child VM from
39//! its [`AsyncBuiltinCtx`] and call back per match.
40//!
41//! The visitor **returns** its reports instead of calling a mutating
42//! `ctx.report(...)`: `VmValue` has no callable variant that carries captured
43//! Rust state, so a stateful `report` method cannot be embedded in `ctx`.
44//! Returning is both the sound option and the simpler one.
45
46use std::collections::BTreeMap;
47use std::path::{Path, PathBuf};
48use std::sync::Arc;
49
50use harn_hostlib::ast::Language;
51use harn_hostlib::tools::permissions::gated_handler;
52use harn_hostlib::{
53    BuiltinRegistry, HostlibCapability, HostlibError, HostlibRegistry, RegisteredBuiltin,
54};
55use harn_vm::{AsyncBuiltinCtx, Vm, VmError, VmValue};
56
57use harn_rules::{
58    data_table, Applicability, BindingMetadata, CompiledRule, Diagnostic, ResolvedBinding, Rule,
59    RuleMatch, Safety, Severity, SourceFile, Span,
60};
61
62const SEARCH: &str = "hostlib_rules_search";
63const REPORT: &str = "hostlib_rules_report";
64const DIAGNOSTICS: &str = "hostlib_rules_diagnostics";
65const VISIT: &str = "hostlib_rules_visit";
66const APPLY: &str = "hostlib_rules_apply";
67const FOLD: &str = "hostlib_rules_fold";
68const LINT_RUN: &str = "hostlib_lint_run";
69
70/// The `rules` host capability.
71#[derive(Default)]
72pub struct RulesCapability;
73
74impl HostlibCapability for RulesCapability {
75    fn module_name(&self) -> &'static str {
76        "rules"
77    }
78
79    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
80        registry.register(RegisteredBuiltin {
81            name: SEARCH,
82            module: "rules",
83            method: "search",
84            handler: Arc::new(search_run),
85        });
86        registry.register(RegisteredBuiltin {
87            name: REPORT,
88            module: "rules",
89            method: "report",
90            handler: Arc::new(report_run),
91        });
92        registry.register(RegisteredBuiltin {
93            name: DIAGNOSTICS,
94            module: "rules",
95            method: "diagnostics",
96            handler: Arc::new(diagnostics_run),
97        });
98        // `apply` writes files, so it shares the deterministic-tools gate.
99        registry.register(RegisteredBuiltin {
100            name: APPLY,
101            module: "rules",
102            method: "apply",
103            handler: gated_handler(APPLY, apply_run),
104        });
105        // `fold` also writes; same gate.
106        registry.register(RegisteredBuiltin {
107            name: FOLD,
108            module: "rules",
109            method: "fold",
110            handler: gated_handler(FOLD, fold_run),
111        });
112    }
113}
114
115/// The `lint` host capability (#2851): runs the Harn linter for an
116/// agent/IDE/cloud caller, returning the same diagnostics the CLI emits.
117#[derive(Default)]
118pub struct LintCapability;
119
120impl HostlibCapability for LintCapability {
121    fn module_name(&self) -> &'static str {
122        "lint"
123    }
124
125    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
126        // Read-only: lint.run parses + lints in memory, never writes.
127        registry.register(RegisteredBuiltin {
128            name: LINT_RUN,
129            module: "lint",
130            method: "run",
131            handler: Arc::new(lint_run),
132        });
133    }
134}
135
136/// Install the `rules` + `lint` capabilities into a VM. Call this alongside
137/// `harn_hostlib::install_default`.
138pub fn install(vm: &mut Vm) {
139    HostlibRegistry::new()
140        .with(RulesCapability)
141        .with(LintCapability)
142        .register_into_vm(vm);
143    // `rules.visit` invokes a `.harn` closure per match, which only an async
144    // builtin can do (`call_closure_pub` is async). It is therefore registered
145    // directly on the VM rather than through the sync `HostlibRegistry`.
146    vm.register_async_builtin(VISIT, visit_run);
147}
148
149// ---------------------------------------------------------------------------
150// Builtins
151// ---------------------------------------------------------------------------
152
153fn search_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
154    let dict = first_dict(SEARCH, args)?;
155    let rule = compile_rule(SEARCH, &dict)?;
156    let files = load_files(SEARCH, &dict)?;
157
158    let mut matches = Vec::new();
159    for file in &files {
160        for m in rule.run(&file.source).map_err(|e| backend(SEARCH, &e))? {
161            matches.push(match_to_vm(&file.path, &m));
162        }
163    }
164    Ok(dict_vm([
165        ("result", str_vm("ok")),
166        ("match_count", VmValue::Int(matches.len() as i64)),
167        ("matches", VmValue::List(Arc::new(matches))),
168    ]))
169}
170
171fn report_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
172    let dict = first_dict(REPORT, args)?;
173    let rule = compile_rule(REPORT, &dict)?;
174    let files = load_files(REPORT, &dict)?;
175    let table = data_table(&rule, &files).map_err(|e| backend(REPORT, &e))?;
176    Ok(json_to_vm(&table.to_json_value()))
177}
178
179fn diagnostics_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
180    let dict = first_dict(DIAGNOSTICS, args)?;
181    let rule = compile_rule(DIAGNOSTICS, &dict)?;
182    let files = load_files(DIAGNOSTICS, &dict)?;
183
184    let mut diagnostics = Vec::new();
185    for file in &files {
186        for d in rule
187            .diagnostics(&file.source)
188            .map_err(|e| backend(DIAGNOSTICS, &e))?
189        {
190            diagnostics.push(diagnostic_vm(&file.path, &d));
191        }
192    }
193    Ok(dict_vm([
194        ("result", str_vm("ok")),
195        ("diagnostic_count", VmValue::Int(diagnostics.len() as i64)),
196        ("diagnostics", VmValue::List(Arc::new(diagnostics))),
197    ]))
198}
199
200/// The imperative escape hatch (#2878): run the rule's matcher, then call the
201/// `.harn` visitor `on_match($node, $ctx)` once per match. The visitor's
202/// return value becomes diagnostics of the same shape `rules.diagnostics`
203/// emits. Read-only — it never writes; the agent applies fixes itself.
204async fn visit_run(ctx: AsyncBuiltinCtx, args: Vec<VmValue>) -> Result<VmValue, VmError> {
205    let dict = first_dict(VISIT, &args).map_err(host_err)?;
206    let rule = compile_rule(VISIT, &dict).map_err(host_err)?;
207    let files = load_files(VISIT, &dict).map_err(host_err)?;
208    let visitor = match dict.get("on_match") {
209        Some(VmValue::Closure(c)) => c.clone(),
210        _ => {
211            return Err(VmError::Runtime(format!(
212                "{VISIT}: `on_match` must be a function `fn(node, ctx)`"
213            )))
214        }
215    };
216
217    let default_severity = rule.severity();
218    let default_safety = rule.safety();
219    let rule_id = rule.id().to_string();
220
221    let mut vm = ctx.child_vm();
222    let mut diagnostics = Vec::new();
223    for file in &files {
224        let matches = rule
225            .run(&file.source)
226            .map_err(|e| host_err(backend(VISIT, &e)))?;
227        let file_ctx = ctx_vm(&file.path, file.language, &file.source, &rule_id);
228        for m in &matches {
229            let node = node_vm(m);
230            let ret = vm
231                .call_closure_pub(&visitor, &[node, file_ctx.clone()])
232                .await?;
233            ctx.forward_output(&vm.take_output());
234            for report in reports_from_return(ret) {
235                diagnostics.push(report_to_diagnostic_vm(
236                    &file.path,
237                    &rule_id,
238                    m.span,
239                    report,
240                    default_severity,
241                    default_safety,
242                ));
243            }
244        }
245    }
246    Ok(dict_vm([
247        ("result", str_vm("ok")),
248        ("diagnostic_count", VmValue::Int(diagnostics.len() as i64)),
249        ("diagnostics", VmValue::List(Arc::new(diagnostics))),
250    ]))
251}
252
253fn apply_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
254    let dict = first_dict(APPLY, args)?;
255    let rule = compile_rule(APPLY, &dict)?;
256    let dry_run = optional_bool(&dict, "dry_run", true);
257    let allow_unsafe = optional_bool(&dict, "allow_unsafe", false);
258    // fmt post-pass (#2847): normalize rewritten `.harn` so a batch lands
259    // fmt-stable. On by default; `format: false` opts out.
260    let format = optional_bool(&dict, "format", true);
261    let files = load_files(APPLY, &dict)?;
262
263    let auto_applicable = rule.safety().is_auto_applicable();
264    let mut entries = Vec::new();
265    for file in &files {
266        let outcome = rule.apply(&file.source).map_err(|e| backend(APPLY, &e))?;
267        // Only `.harn` has a formatter; harn_fmt is idempotent, so a later
268        // `harn fmt` is a no-op. A formatter error falls back to the raw
269        // rewrite rather than failing the codemod.
270        let formatted = format && outcome.changed && file.language == Language::Harn;
271        let rewritten = if formatted {
272            match harn_fmt::format_source(&outcome.rewritten) {
273                Ok(canonical) => canonical,
274                Err(_) => outcome.rewritten,
275            }
276        } else {
277            outcome.rewritten
278        };
279        // Write only on a real apply, when the edit is safe to auto-apply
280        // (or explicitly allowed), and the rule actually changed the file.
281        let applied = !dry_run && outcome.changed && (auto_applicable || allow_unsafe);
282        if applied {
283            std::fs::write(&file.path, &rewritten).map_err(|e| HostlibError::Backend {
284                builtin: APPLY,
285                message: format!("write `{}`: {e}", file.path.display()),
286            })?;
287        }
288        entries.push(dict_vm([
289            ("path", str_vm(file.path.display().to_string())),
290            ("changed", VmValue::Bool(outcome.changed)),
291            ("applied", VmValue::Bool(applied)),
292            ("idempotent", VmValue::Bool(outcome.idempotent)),
293            ("formatted", VmValue::Bool(formatted)),
294            ("safety", str_vm(format!("{:?}", outcome.safety))),
295            // The original source, so callers can render a diff without a
296            // (sandboxed) re-read of the file.
297            ("before", str_vm(&file.source)),
298            ("preview", str_vm(rewritten)),
299        ]));
300    }
301    Ok(dict_vm([
302        ("result", str_vm("ok")),
303        ("dry_run", VmValue::Bool(dry_run)),
304        ("auto_applicable", VmValue::Bool(auto_applicable)),
305        ("files", VmValue::List(Arc::new(entries))),
306    ]))
307}
308
309/// `rules.fold` (#2824): fold consecutive `let x = src?.x ?? d` runs into a
310/// single destructure-with-defaults. A specialized, behavior-preserving
311/// codemod (the engine can't fold statement sequences declaratively). Writes
312/// only on a real apply (`dry_run: false`); shares the deterministic gate.
313fn fold_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
314    let dict = first_dict(FOLD, args)?;
315    let dry_run = optional_bool(&dict, "dry_run", true);
316    let files = load_files(FOLD, &dict)?;
317
318    let mut entries = Vec::new();
319    for file in &files {
320        let raw_folded =
321            harn_rules::fold::fold_destructure_defaults(&file.source, file.language.name())
322                .map_err(|e| backend(FOLD, &e))?;
323        let raw_changed = raw_folded != file.source;
324        let formatted = raw_changed && file.language == Language::Harn;
325        let folded = if formatted {
326            match harn_fmt::format_source(&raw_folded) {
327                Ok(canonical) => canonical,
328                Err(_) => raw_folded,
329            }
330        } else {
331            raw_folded
332        };
333        let changed = folded != file.source;
334        let idempotent = harn_rules::fold::fold_destructure_defaults(&folded, file.language.name())
335            .map(|again| again == folded)
336            .unwrap_or(false);
337        let applied = !dry_run && changed;
338        if applied {
339            std::fs::write(&file.path, &folded).map_err(|e| HostlibError::Backend {
340                builtin: FOLD,
341                message: format!("write `{}`: {e}", file.path.display()),
342            })?;
343        }
344        entries.push(dict_vm([
345            ("path", str_vm(file.path.display().to_string())),
346            ("changed", VmValue::Bool(changed)),
347            ("applied", VmValue::Bool(applied)),
348            ("idempotent", VmValue::Bool(idempotent)),
349            ("formatted", VmValue::Bool(formatted)),
350            ("safety", str_vm("BehaviorPreserving")),
351            ("before", str_vm(&file.source)),
352            ("preview", str_vm(folded)),
353        ]));
354    }
355    Ok(dict_vm([
356        ("result", str_vm("ok")),
357        ("dry_run", VmValue::Bool(dry_run)),
358        ("files", VmValue::List(Arc::new(entries))),
359    ]))
360}
361
362/// `lint.run` (#2851): lint a Harn source string and return its diagnostics, so
363/// an agent / IDE / cloud caller gets the same findings as `harn lint` without
364/// shelling out. Read-only. Params: `{source, disabled?, severity?}` where
365/// `severity` maps a rule id to `"error"` / `"warning"` / `"info"`.
366fn lint_run(args: &[VmValue]) -> Result<VmValue, HostlibError> {
367    let dict = first_dict(LINT_RUN, args)?;
368    let source = require_string(LINT_RUN, &dict, "source")?;
369    let disabled = optional_string_list(&dict, "disabled");
370    let severity_overrides = parse_severity_overrides(&dict);
371
372    let program = harn_parser::parse_source(&source).map_err(|e| HostlibError::Backend {
373        builtin: LINT_RUN,
374        message: format!("parse error: {e}"),
375    })?;
376    let options = harn_lint::LintOptions {
377        severity_overrides,
378        ..Default::default()
379    };
380    let diagnostics = harn_lint::lint_with_options(
381        &program,
382        &disabled,
383        Some(&source),
384        &std::collections::HashSet::new(),
385        &options,
386    );
387    let items: Vec<VmValue> = diagnostics.iter().map(lint_diagnostic_vm).collect();
388    Ok(dict_vm([
389        ("result", str_vm("ok")),
390        ("diagnostic_count", VmValue::Int(items.len() as i64)),
391        ("diagnostics", VmValue::List(Arc::new(items))),
392    ]))
393}
394
395/// Parse a `severity` dict param (`{rule: "error"|"warning"|"info"}`) into the
396/// linter's override map. Unknown severities are skipped.
397fn parse_severity_overrides(
398    dict: &harn_vm::value::DictMap,
399) -> std::collections::HashMap<String, harn_lint::LintSeverity> {
400    let mut out = std::collections::HashMap::new();
401    if let Some(VmValue::Dict(map)) = dict.get("severity") {
402        for (rule, value) in map.iter() {
403            if let VmValue::String(s) = value {
404                let severity = match s.to_ascii_lowercase().as_str() {
405                    "error" => Some(harn_lint::LintSeverity::Error),
406                    "warning" | "warn" => Some(harn_lint::LintSeverity::Warning),
407                    "info" => Some(harn_lint::LintSeverity::Info),
408                    _ => None,
409                };
410                if let Some(severity) = severity {
411                    out.insert(rule.to_string(), severity);
412                }
413            }
414        }
415    }
416    out
417}
418
419/// Marshal a [`harn_lint::LintDiagnostic`] into a VM dict, mirroring the
420/// fields the CLI renders (code, rule, message, severity, span).
421fn lint_diagnostic_vm(diag: &harn_lint::LintDiagnostic) -> VmValue {
422    let severity = match diag.severity {
423        harn_lint::LintSeverity::Error => "error",
424        harn_lint::LintSeverity::Warning => "warning",
425        harn_lint::LintSeverity::Info => "info",
426    };
427    dict_vm([
428        ("code", str_vm(diag.code.as_str())),
429        ("rule", str_vm(diag.rule.as_ref())),
430        ("message", str_vm(&diag.message)),
431        ("severity", str_vm(severity)),
432        ("start_byte", VmValue::Int(diag.span.start as i64)),
433        ("end_byte", VmValue::Int(diag.span.end as i64)),
434        ("line", VmValue::Int(diag.span.line as i64)),
435        ("column", VmValue::Int(diag.span.column as i64)),
436    ])
437}
438
439// ---------------------------------------------------------------------------
440// Shared parsing / conversion
441// ---------------------------------------------------------------------------
442
443fn compile_rule(
444    builtin: &'static str,
445    dict: &harn_vm::value::DictMap,
446) -> Result<CompiledRule, HostlibError> {
447    let toml = require_string(builtin, dict, "rule")?;
448    let rule = Rule::from_toml_str(&toml).map_err(|e| HostlibError::InvalidParameter {
449        builtin,
450        param: "rule",
451        message: format!("invalid rule TOML: {e}"),
452    })?;
453    CompiledRule::compile(&rule).map_err(|e| HostlibError::InvalidParameter {
454        builtin,
455        param: "rule",
456        message: e.to_string(),
457    })
458}
459
460/// Load the fileset: inline `source` (+ `language`) for a single buffer, or
461/// `paths` read from disk (language inferred per file; non-UTF8 and
462/// undetectable files are skipped).
463fn load_files(
464    builtin: &'static str,
465    dict: &harn_vm::value::DictMap,
466) -> Result<Vec<SourceFile>, HostlibError> {
467    if let Some(source) = optional_string(dict, "source") {
468        let language_name = require_string(builtin, dict, "language")?;
469        let language =
470            Language::from_name(&language_name).ok_or_else(|| HostlibError::InvalidParameter {
471                builtin,
472                param: "language",
473                message: format!("unknown language `{language_name}`"),
474            })?;
475        let path = optional_string(dict, "path").unwrap_or_else(|| "<inline>".to_string());
476        return Ok(vec![SourceFile {
477            path: PathBuf::from(path),
478            language,
479            source,
480        }]);
481    }
482
483    let paths = optional_string_list(dict, "paths");
484    if paths.is_empty() {
485        return Err(HostlibError::MissingParameter {
486            builtin,
487            param: "paths",
488        });
489    }
490    let mut files = Vec::new();
491    for path in paths {
492        let bytes = std::fs::read(&path).map_err(|e| HostlibError::Backend {
493            builtin,
494            message: format!("read `{path}`: {e}"),
495        })?;
496        let Ok(contents) = String::from_utf8(bytes) else {
497            continue;
498        };
499        if let Some(file) = SourceFile::detect(&path, contents) {
500            files.push(file);
501        }
502    }
503    Ok(files)
504}
505
506fn match_to_vm(path: &std::path::Path, m: &RuleMatch) -> VmValue {
507    let captures: harn_vm::value::DictMap = m
508        .bindings
509        .iter()
510        .map(|(name, b)| (name.clone(), str_vm(&b.text)))
511        .collect();
512    let capture_metadata = capture_metadata_vm(m);
513    dict_vm([
514        ("path", str_vm(path.display().to_string())),
515        ("text", str_vm(&m.text)),
516        ("start_row", VmValue::Int(m.span.start_row as i64)),
517        ("start_col", VmValue::Int(m.span.start_col as i64)),
518        ("end_row", VmValue::Int(m.span.end_row as i64)),
519        ("end_col", VmValue::Int(m.span.end_col as i64)),
520        ("captures", VmValue::dict(captures)),
521        ("capture_metadata", capture_metadata),
522    ])
523}
524
525fn backend(builtin: &'static str, err: &harn_rules::RulesError) -> HostlibError {
526    HostlibError::Backend {
527        builtin,
528        message: err.to_string(),
529    }
530}
531
532/// Lower a `HostlibError` into a `VmError` for the async `rules.visit` path
533/// (which must return `VmError`, not `HostlibError`).
534fn host_err(err: HostlibError) -> VmError {
535    VmError::Runtime(err.to_string())
536}
537
538/// One report a `.harn` visitor returned for a single match. Every field is
539/// optional: an empty report (e.g. the visitor returned `true`) flags the
540/// match using the rule's own defaults.
541#[derive(Default)]
542struct ReportSpec {
543    message: Option<String>,
544    fix: Option<String>,
545    safety: Option<Safety>,
546    severity: Option<Severity>,
547}
548
549/// The `node` value handed to a visitor: the matched text, its metavar
550/// captures, and its span.
551fn node_vm(m: &RuleMatch) -> VmValue {
552    let captures: harn_vm::value::DictMap = m
553        .bindings
554        .iter()
555        .map(|(name, b)| (name.clone(), str_vm(&b.text)))
556        .collect();
557    let capture_metadata = capture_metadata_vm(m);
558    dict_vm([
559        ("text", str_vm(&m.text)),
560        ("captures", VmValue::dict(captures)),
561        ("capture_metadata", capture_metadata),
562        ("start_row", VmValue::Int(m.span.start_row as i64)),
563        ("start_col", VmValue::Int(m.span.start_col as i64)),
564        ("end_row", VmValue::Int(m.span.end_row as i64)),
565        ("end_col", VmValue::Int(m.span.end_col as i64)),
566    ])
567}
568
569fn capture_metadata_vm(m: &RuleMatch) -> VmValue {
570    let metadata: harn_vm::value::DictMap = m
571        .bindings
572        .iter()
573        .filter(|(_, binding)| !binding.metadata.is_empty())
574        .map(|(name, binding)| (name.clone(), binding_metadata_vm(&binding.metadata)))
575        .collect();
576    VmValue::dict(metadata)
577}
578
579fn binding_metadata_vm(metadata: &BindingMetadata) -> VmValue {
580    let mut entries: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
581    if let Some(ty) = &metadata.ty {
582        entries.insert("type".into(), str_vm(ty));
583    }
584    if let Some(resolved) = &metadata.resolved {
585        entries.insert("resolved".into(), resolved_binding_vm(resolved));
586    }
587    VmValue::dict(entries)
588}
589
590fn resolved_binding_vm(resolved: &ResolvedBinding) -> VmValue {
591    dict_vm([
592        ("id", str_vm(&resolved.id)),
593        ("name", str_vm(&resolved.name)),
594        ("kind", str_vm(&resolved.kind)),
595        ("start_row", VmValue::Int(resolved.span.start_row as i64)),
596        ("start_col", VmValue::Int(resolved.span.start_col as i64)),
597        ("end_row", VmValue::Int(resolved.span.end_row as i64)),
598        ("end_col", VmValue::Int(resolved.span.end_col as i64)),
599    ])
600}
601
602/// The read-only `ctx` value handed to a visitor: where the match lives and
603/// what produced it.
604fn ctx_vm(path: &Path, language: Language, source: &str, rule_id: &str) -> VmValue {
605    dict_vm([
606        ("path", str_vm(path.display().to_string())),
607        ("language", str_vm(language.name())),
608        ("source", str_vm(source)),
609        ("rule_id", str_vm(rule_id)),
610    ])
611}
612
613/// Build a diagnostic dict — the one shape both `rules.diagnostics` and
614/// `rules.visit` emit, so an equivalent declarative and imperative rule
615/// produce identical output.
616fn diagnostic_dict(
617    path: &Path,
618    rule_id: &str,
619    message: &str,
620    severity: Severity,
621    span: Span,
622    fix: Option<String>,
623    applicability: Applicability,
624) -> VmValue {
625    dict_vm([
626        ("path", str_vm(path.display().to_string())),
627        ("rule_id", str_vm(rule_id)),
628        ("message", str_vm(message)),
629        ("severity", str_vm(severity.as_str())),
630        ("start_row", VmValue::Int(span.start_row as i64)),
631        ("start_col", VmValue::Int(span.start_col as i64)),
632        ("end_row", VmValue::Int(span.end_row as i64)),
633        ("end_col", VmValue::Int(span.end_col as i64)),
634        ("applicability", str_vm(applicability.as_str())),
635        ("fix", fix.map(str_vm).unwrap_or(VmValue::Nil)),
636    ])
637}
638
639fn diagnostic_vm(path: &Path, d: &Diagnostic) -> VmValue {
640    diagnostic_dict(
641        path,
642        &d.rule_id,
643        &d.message,
644        d.severity,
645        d.span,
646        d.fix.clone(),
647        d.applicability,
648    )
649}
650
651/// Turn a visitor's [`ReportSpec`] into the same diagnostic dict, located at
652/// the match's span and falling back to the rule's defaults.
653fn report_to_diagnostic_vm(
654    path: &Path,
655    rule_id: &str,
656    span: Span,
657    report: ReportSpec,
658    default_severity: Severity,
659    default_safety: Safety,
660) -> VmValue {
661    let severity = report.severity.unwrap_or(default_severity);
662    let safety = report.safety.unwrap_or(default_safety);
663    diagnostic_dict(
664        path,
665        rule_id,
666        report.message.as_deref().unwrap_or(""),
667        severity,
668        span,
669        report.fix,
670        safety.applicability(),
671    )
672}
673
674/// Interpret a visitor's return value: `nil`/`false` skips, `true` flags with
675/// rule defaults, a dict is one report, a list is many (skipping `nil`/`false`
676/// entries).
677fn reports_from_return(ret: VmValue) -> Vec<ReportSpec> {
678    match ret {
679        VmValue::Nil | VmValue::Bool(false) => Vec::new(),
680        VmValue::Bool(true) => vec![ReportSpec::default()],
681        VmValue::Dict(d) => vec![report_from_dict(&d)],
682        VmValue::List(items) => items.iter().filter_map(report_from_item).collect(),
683        _ => Vec::new(),
684    }
685}
686
687fn report_from_item(v: &VmValue) -> Option<ReportSpec> {
688    match v {
689        VmValue::Nil | VmValue::Bool(false) => None,
690        VmValue::Bool(true) => Some(ReportSpec::default()),
691        VmValue::Dict(d) => Some(report_from_dict(d)),
692        _ => None,
693    }
694}
695
696fn report_from_dict(d: &harn_vm::value::DictMap) -> ReportSpec {
697    ReportSpec {
698        message: optional_string(d, "message"),
699        fix: optional_string(d, "fix"),
700        safety: optional_string(d, "safety").and_then(|s| parse_safety(&s)),
701        severity: optional_string(d, "severity").and_then(|s| parse_severity(&s)),
702    }
703}
704
705fn parse_severity(s: &str) -> Option<Severity> {
706    match s {
707        "info" => Some(Severity::Info),
708        "warning" => Some(Severity::Warning),
709        "error" => Some(Severity::Error),
710        _ => None,
711    }
712}
713
714fn parse_safety(s: &str) -> Option<Safety> {
715    match s {
716        "format-only" => Some(Safety::FormatOnly),
717        "behavior-preserving" => Some(Safety::BehaviorPreserving),
718        "scope-local" => Some(Safety::ScopeLocal),
719        "surface-changing" => Some(Safety::SurfaceChanging),
720        "capability-changing" => Some(Safety::CapabilityChanging),
721        "needs-human" => Some(Safety::NeedsHuman),
722        _ => None,
723    }
724}
725
726fn json_to_vm(value: &serde_json::Value) -> VmValue {
727    match value {
728        serde_json::Value::Null => VmValue::Nil,
729        serde_json::Value::Bool(b) => VmValue::Bool(*b),
730        serde_json::Value::Number(n) => n
731            .as_i64()
732            .map(VmValue::Int)
733            .unwrap_or_else(|| VmValue::Float(n.as_f64().unwrap_or(0.0))),
734        serde_json::Value::String(s) => str_vm(s),
735        serde_json::Value::Array(items) => {
736            VmValue::List(Arc::new(items.iter().map(json_to_vm).collect()))
737        }
738        serde_json::Value::Object(map) => VmValue::dict(
739            map.iter()
740                .map(|(k, v)| (k.clone(), json_to_vm(v)))
741                .collect::<harn_vm::value::DictMap>(),
742        ),
743    }
744}
745
746// ---------------------------------------------------------------------------
747// Minimal arg/value helpers (harn-hostlib's `tools::args` is crate-private)
748// ---------------------------------------------------------------------------
749
750fn first_dict(
751    builtin: &'static str,
752    args: &[VmValue],
753) -> Result<Arc<harn_vm::value::DictMap>, HostlibError> {
754    match args.first() {
755        Some(VmValue::Dict(dict)) => Ok(dict.clone()),
756        Some(VmValue::Nil) | None => Ok(Arc::new(harn_vm::value::DictMap::new())),
757        Some(_) => Err(HostlibError::InvalidParameter {
758            builtin,
759            param: "params",
760            message: "expected a dict argument".into(),
761        }),
762    }
763}
764
765fn require_string(
766    builtin: &'static str,
767    dict: &harn_vm::value::DictMap,
768    key: &'static str,
769) -> Result<String, HostlibError> {
770    match dict.get(key) {
771        Some(VmValue::String(s)) => Ok(s.to_string()),
772        _ => Err(HostlibError::MissingParameter {
773            builtin,
774            param: key,
775        }),
776    }
777}
778
779fn optional_string(dict: &harn_vm::value::DictMap, key: &str) -> Option<String> {
780    match dict.get(key) {
781        Some(VmValue::String(s)) => Some(s.to_string()),
782        _ => None,
783    }
784}
785
786fn optional_string_list(dict: &harn_vm::value::DictMap, key: &str) -> Vec<String> {
787    match dict.get(key) {
788        Some(VmValue::List(items)) => items
789            .iter()
790            .filter_map(|v| match v {
791                VmValue::String(s) => Some(s.to_string()),
792                _ => None,
793            })
794            .collect(),
795        _ => Vec::new(),
796    }
797}
798
799fn optional_bool(dict: &harn_vm::value::DictMap, key: &str, default: bool) -> bool {
800    match dict.get(key) {
801        Some(VmValue::Bool(b)) => *b,
802        _ => default,
803    }
804}
805
806fn str_vm(s: impl AsRef<str>) -> VmValue {
807    VmValue::string(s)
808}
809
810fn dict_vm<const N: usize>(entries: [(&str, VmValue); N]) -> VmValue {
811    let map: harn_vm::value::DictMap = entries
812        .into_iter()
813        .map(|(k, v)| (k.to_string(), v))
814        .collect();
815    VmValue::dict(map)
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821
822    fn dict(pairs: &[(&str, VmValue)]) -> VmValue {
823        let map: harn_vm::value::DictMap = pairs
824            .iter()
825            .map(|(k, v)| (k.to_string(), v.clone()))
826            .collect();
827        VmValue::dict(map)
828    }
829
830    fn get<'a>(v: &'a VmValue, key: &str) -> &'a VmValue {
831        match v {
832            VmValue::Dict(d) => d.get(key).unwrap_or_else(|| panic!("missing {key}")),
833            _ => panic!("not a dict"),
834        }
835    }
836
837    fn int(v: &VmValue) -> i64 {
838        match v {
839            VmValue::Int(i) => *i,
840            other => panic!("not int: {other:?}"),
841        }
842    }
843
844    fn s(v: &VmValue) -> String {
845        match v {
846            VmValue::String(s) => s.to_string(),
847            other => panic!("not string: {other:?}"),
848        }
849    }
850
851    fn b(v: &VmValue) -> bool {
852        match v {
853            VmValue::Bool(b) => *b,
854            other => panic!("not bool: {other:?}"),
855        }
856    }
857
858    const SEARCH_RULE: &str = r#"
859        id = "find-calls"
860        language = "typescript"
861        [rule]
862        pattern = "$FN()"
863    "#;
864
865    #[test]
866    fn search_returns_matches_with_captures() {
867        let result = search_run(&[dict(&[
868            ("rule", str_vm(SEARCH_RULE)),
869            ("source", str_vm("foo();\nbar();\n")),
870            ("language", str_vm("typescript")),
871        ])])
872        .unwrap();
873        assert_eq!(int(get(&result, "match_count")), 2);
874        let matches = match get(&result, "matches") {
875            VmValue::List(l) => l.clone(),
876            _ => panic!(),
877        };
878        assert_eq!(s(get(get(&matches[0], "captures"), "FN")), "foo");
879    }
880
881    #[test]
882    fn search_skips_non_utf8_paths() {
883        let dir = tempfile::tempdir().unwrap();
884        let source_path = dir.path().join("calls.ts");
885        let binary_path = dir.path().join(".DS_Store");
886        std::fs::write(&source_path, b"foo();\n").unwrap();
887        std::fs::write(&binary_path, [0xff, 0xfe, 0xfd]).unwrap();
888
889        let result = search_run(&[dict(&[
890            ("rule", str_vm(SEARCH_RULE)),
891            (
892                "paths",
893                VmValue::List(Arc::new(vec![
894                    str_vm(source_path.display().to_string()),
895                    str_vm(binary_path.display().to_string()),
896                ])),
897            ),
898        ])])
899        .unwrap();
900
901        assert_eq!(int(get(&result, "match_count")), 1);
902        let matches = match get(&result, "matches") {
903            VmValue::List(l) => l.clone(),
904            _ => panic!(),
905        };
906        assert_eq!(
907            s(get(&matches[0], "path")),
908            source_path.display().to_string()
909        );
910    }
911
912    #[test]
913    fn search_returns_harn_capture_metadata() {
914        let rule = r#"
915            id = "int-logs"
916            language = "harn"
917            [rule]
918            pattern = "log($VALUE)"
919        "#;
920        let result = search_run(&[dict(&[
921            ("rule", str_vm(rule)),
922            (
923                "source",
924                str_vm("fn main() {\n  let count: int = 1\n  log(count)\n}\n"),
925            ),
926            ("language", str_vm("harn")),
927        ])])
928        .unwrap();
929        let matches = match get(&result, "matches") {
930            VmValue::List(l) => l.clone(),
931            _ => panic!(),
932        };
933        let metadata = get(get(&matches[0], "capture_metadata"), "VALUE");
934        assert_eq!(s(get(metadata, "type")), "int");
935        assert_eq!(s(get(get(metadata, "resolved"), "name")), "count");
936        assert_eq!(s(get(get(metadata, "resolved"), "kind")), "let");
937    }
938
939    #[test]
940    fn report_returns_a_data_table() {
941        let result = report_run(&[dict(&[
942            ("rule", str_vm(SEARCH_RULE)),
943            ("source", str_vm("foo();\nbar();\n")),
944            ("language", str_vm("typescript")),
945            ("path", str_vm("a.ts")),
946        ])])
947        .unwrap();
948        assert_eq!(int(get(get(&result, "summary"), "total_rows")), 2);
949        assert_eq!(s(get(&result, "rule_id")), "find-calls");
950    }
951
952    #[test]
953    fn apply_dry_run_previews_without_writing() {
954        let rule = r#"
955            id = "rename"
956            language = "typescript"
957            safety = "behavior-preserving"
958            fix = "bar()"
959            [rule]
960            pattern = "foo()"
961        "#;
962        let result = apply_run(&[dict(&[
963            ("rule", str_vm(rule)),
964            ("source", str_vm("foo();\n")),
965            ("language", str_vm("typescript")),
966            ("dry_run", VmValue::Bool(true)),
967        ])])
968        .unwrap();
969        let files = match get(&result, "files") {
970            VmValue::List(l) => l.clone(),
971            _ => panic!(),
972        };
973        assert!(b(get(&files[0], "changed")));
974        assert!(!b(get(&files[0], "applied")));
975        assert_eq!(s(get(&files[0], "preview")), "bar();\n");
976    }
977
978    const UGLY_HARN_CODEMOD: &str = r#"
979        id = "dd"
980        language = "harn"
981        safety = "scope-local"
982        fix = "let {$K=$D}=$X"
983        [rule]
984        pattern = "let $K = $X?.$K ?? $D"
985    "#;
986
987    #[test]
988    fn apply_formats_harn_output_by_default() {
989        // The fix template is deliberately ugly; the #2847 fmt post-pass
990        // normalizes the rewritten `.harn` (so a batch lands fmt-stable).
991        let result = apply_run(&[dict(&[
992            ("rule", str_vm(UGLY_HARN_CODEMOD)),
993            (
994                "source",
995                str_vm("fn main() {\n  let timeout = cfg?.timeout ?? 30\n}\n"),
996            ),
997            ("language", str_vm("harn")),
998            ("dry_run", VmValue::Bool(true)),
999        ])])
1000        .unwrap();
1001        let files = match get(&result, "files") {
1002            VmValue::List(l) => l.clone(),
1003            _ => panic!(),
1004        };
1005        assert!(b(get(&files[0], "changed")));
1006        assert!(b(get(&files[0], "formatted")));
1007        let preview = s(get(&files[0], "preview"));
1008        assert!(preview.contains("= 30"), "preview not formatted: {preview}");
1009    }
1010
1011    #[test]
1012    fn apply_format_false_leaves_raw_output() {
1013        let result = apply_run(&[dict(&[
1014            ("rule", str_vm(UGLY_HARN_CODEMOD)),
1015            (
1016                "source",
1017                str_vm("fn main() {\n  let timeout = cfg?.timeout ?? 30\n}\n"),
1018            ),
1019            ("language", str_vm("harn")),
1020            ("dry_run", VmValue::Bool(true)),
1021            ("format", VmValue::Bool(false)),
1022        ])])
1023        .unwrap();
1024        let files = match get(&result, "files") {
1025            VmValue::List(l) => l.clone(),
1026            _ => panic!(),
1027        };
1028        assert!(!b(get(&files[0], "formatted")));
1029        let preview = s(get(&files[0], "preview"));
1030        assert!(preview.contains("{timeout=30}"), "expected raw: {preview}");
1031    }
1032
1033    #[test]
1034    fn diagnostics_returns_lint_findings() {
1035        let lint = r#"
1036            id = "calls"
1037            language = "typescript"
1038            message = "function call"
1039            [rule]
1040            pattern = "$FN()"
1041        "#;
1042        let result = diagnostics_run(&[dict(&[
1043            ("rule", str_vm(lint)),
1044            ("source", str_vm("foo();\nbar();\n")),
1045            ("language", str_vm("typescript")),
1046            ("path", str_vm("a.ts")),
1047        ])])
1048        .unwrap();
1049        assert_eq!(int(get(&result, "diagnostic_count")), 2);
1050        let diags = match get(&result, "diagnostics") {
1051            VmValue::List(l) => l.clone(),
1052            _ => panic!(),
1053        };
1054        assert_eq!(s(get(&diags[0], "message")), "function call");
1055        assert_eq!(s(get(&diags[0], "severity")), "warning");
1056        // No `fix` and default safety → a suggestion, not machine-applicable.
1057        assert_eq!(s(get(&diags[0], "applicability")), "suggestion");
1058        assert_eq!(int(get(&diags[1], "start_row")), 1);
1059        assert!(matches!(get(&diags[0], "fix"), VmValue::Nil));
1060    }
1061
1062    #[test]
1063    fn report_helpers_round_trip_severity_and_safety() {
1064        // The string<->enum mapping used by `rules.visit` reports.
1065        assert_eq!(parse_severity("error"), Some(Severity::Error));
1066        assert_eq!(parse_severity("bogus"), None);
1067        assert_eq!(parse_safety("format-only"), Some(Safety::FormatOnly));
1068        assert_eq!(parse_safety("needs-human"), Some(Safety::NeedsHuman));
1069        assert_eq!(parse_safety("nope"), None);
1070        // `true` flags with defaults; nil/false skip; a dict carries fields.
1071        assert_eq!(reports_from_return(VmValue::Bool(true)).len(), 1);
1072        assert_eq!(reports_from_return(VmValue::Nil).len(), 0);
1073        assert_eq!(reports_from_return(VmValue::Bool(false)).len(), 0);
1074        let list = VmValue::List(Arc::new(vec![
1075            dict(&[("message", str_vm("a"))]),
1076            VmValue::Nil,
1077            dict(&[("message", str_vm("b"))]),
1078        ]));
1079        assert_eq!(reports_from_return(list).len(), 2);
1080    }
1081
1082    #[test]
1083    fn capability_does_not_register_the_async_visitor() {
1084        // `rules.visit` is async, so it is installed directly on the VM in
1085        // `install`, not through the sync capability registry.
1086        let mut registry = BuiltinRegistry::new();
1087        RulesCapability.register_builtins(&mut registry);
1088        let names: Vec<_> = registry.iter().map(|b| b.name).collect();
1089        assert!(!names.contains(&VISIT));
1090        assert!(names.contains(&DIAGNOSTICS));
1091    }
1092
1093    #[test]
1094    fn missing_rule_is_an_error() {
1095        let err = search_run(&[dict(&[
1096            ("source", str_vm("x")),
1097            ("language", str_vm("rust")),
1098        ])]);
1099        assert!(matches!(
1100            err,
1101            Err(HostlibError::MissingParameter { param: "rule", .. })
1102        ));
1103    }
1104
1105    #[test]
1106    fn capability_registers_the_sync_builtins() {
1107        let mut registry = BuiltinRegistry::new();
1108        RulesCapability.register_builtins(&mut registry);
1109        let names: Vec<_> = registry.iter().map(|b| b.name).collect();
1110        assert_eq!(names, vec![SEARCH, REPORT, DIAGNOSTICS, APPLY, FOLD]);
1111    }
1112
1113    #[test]
1114    fn lint_capability_registers_run() {
1115        let mut registry = BuiltinRegistry::new();
1116        LintCapability.register_builtins(&mut registry);
1117        let names: Vec<_> = registry.iter().map(|b| b.name).collect();
1118        assert_eq!(names, vec![LINT_RUN]);
1119    }
1120
1121    #[test]
1122    fn lint_run_returns_the_linter_findings() {
1123        let result =
1124            lint_run(&[dict(&[("source", str_vm("fn f() {\n  let x = (1)\n}\n"))])]).unwrap();
1125        assert_eq!(s(get(&result, "result")), "ok");
1126        let diags = match get(&result, "diagnostics") {
1127            VmValue::List(l) => l.clone(),
1128            _ => panic!(),
1129        };
1130        assert!(
1131            diags
1132                .iter()
1133                .any(|d| s(get(d, "rule")) == "unnecessary-parentheses"),
1134            "expected unnecessary-parentheses, got {diags:?}"
1135        );
1136    }
1137
1138    #[test]
1139    fn lint_run_applies_a_severity_override() {
1140        let result = lint_run(&[dict(&[
1141            ("source", str_vm("fn f() {\n  let x = (1)\n}\n")),
1142            (
1143                "severity",
1144                dict(&[("unnecessary-parentheses", str_vm("error"))]),
1145            ),
1146        ])])
1147        .unwrap();
1148        let diags = match get(&result, "diagnostics") {
1149            VmValue::List(l) => l.clone(),
1150            _ => panic!(),
1151        };
1152        let d = diags
1153            .iter()
1154            .find(|d| s(get(d, "rule")) == "unnecessary-parentheses")
1155            .expect("rule present");
1156        assert_eq!(s(get(d, "severity")), "error");
1157    }
1158}