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