1use 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#[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#[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 registry.register(RegisteredBuiltin {
125 name: LINT_RUN,
126 module: "lint",
127 method: "run",
128 handler: Arc::new(lint_run),
129 });
130 }
131}
132
133pub fn install(vm: &mut Vm) {
136 HostlibRegistry::new()
137 .with(RulesCapability)
138 .with(LintCapability)
139 .register_into_vm(vm);
140 vm.register_async_capability_method(harn_builtin_meta::CapabilityId::Rules, "visit", visit_run);
144}
145
146fn 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
197async 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 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 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 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 ("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
306fn 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
359fn 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
392fn 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
416fn 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
436fn 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
457fn 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
529fn host_err(err: HostlibError) -> VmError {
532 VmError::Runtime(err.to_string())
533}
534
535#[derive(Default)]
539struct ReportSpec {
540 message: Option<String>,
541 fix: Option<String>,
542 safety: Option<Safety>,
543 severity: Option<Severity>,
544}
545
546fn 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
599fn 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
610fn 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
648fn 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
671fn 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
743fn 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 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 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 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 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 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}