1use 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#[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 registry.register(RegisteredBuiltin {
100 name: APPLY,
101 module: "rules",
102 method: "apply",
103 handler: gated_handler(APPLY, apply_run),
104 });
105 registry.register(RegisteredBuiltin {
107 name: FOLD,
108 module: "rules",
109 method: "fold",
110 handler: gated_handler(FOLD, fold_run),
111 });
112 }
113}
114
115#[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 registry.register(RegisteredBuiltin {
128 name: LINT_RUN,
129 module: "lint",
130 method: "run",
131 handler: Arc::new(lint_run),
132 });
133 }
134}
135
136pub fn install(vm: &mut Vm) {
139 HostlibRegistry::new()
140 .with(RulesCapability)
141 .with(LintCapability)
142 .register_into_vm(vm);
143 vm.register_async_builtin(VISIT, visit_run);
147}
148
149fn 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
200async 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 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 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 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 ("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
309fn 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
362fn 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
395fn 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
419fn 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
439fn 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
460fn 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
532fn host_err(err: HostlibError) -> VmError {
535 VmError::Runtime(err.to_string())
536}
537
538#[derive(Default)]
542struct ReportSpec {
543 message: Option<String>,
544 fix: Option<String>,
545 safety: Option<Safety>,
546 severity: Option<Severity>,
547}
548
549fn 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
602fn 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
613fn 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
651fn 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
674fn 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
746fn 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 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 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 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 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 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}