1use std::collections::BTreeMap;
12
13use harn_hostlib::ast::Language;
14use serde::Serialize;
15
16use crate::constraint::CompiledConstraint;
17use crate::error::RulesError;
18use crate::evaluator::CompiledRuleTree;
19use crate::fix::{interpolate, splice, AppliedEdit};
20use crate::model::{Applicability, Rule, Safety, Severity};
21use crate::semantic::enrich_harn_matches;
22use crate::transform::CompiledTransform;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
27pub struct Span {
28 pub start_byte: usize,
30 pub end_byte: usize,
32 pub start_row: usize,
34 pub start_col: usize,
36 pub end_row: usize,
38 pub end_col: usize,
40}
41
42impl Span {
43 pub(crate) fn of(node: tree_sitter::Node<'_>) -> Self {
44 let start = node.start_position();
45 let end = node.end_position();
46 Span {
47 start_byte: node.start_byte(),
48 end_byte: node.end_byte(),
49 start_row: start.row,
50 start_col: start.column,
51 end_row: end.row,
52 end_col: end.column,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
61pub struct BindingMetadata {
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub resolved: Option<ResolvedBinding>,
65 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
67 pub ty: Option<String>,
68}
69
70impl BindingMetadata {
71 pub fn is_empty(&self) -> bool {
73 self.resolved.is_none() && self.ty.is_none()
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct ResolvedBinding {
80 pub id: String,
82 pub name: String,
84 pub kind: String,
87 #[serde(flatten)]
89 pub span: Span,
90}
91
92#[derive(Debug, Clone)]
94pub struct Binding {
95 pub text: String,
97 pub span: Span,
99 pub metadata: BindingMetadata,
101}
102
103impl Binding {
104 pub(crate) fn new(text: String, span: Span) -> Self {
105 Binding {
106 text,
107 span,
108 metadata: BindingMetadata::default(),
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct RuleMatch {
116 pub rule_id: String,
118 pub span: Span,
120 pub text: String,
122 pub bindings: BTreeMap<String, Binding>,
125}
126
127#[derive(Debug, Clone)]
129pub struct CodemodResult {
130 pub rewritten: String,
132 pub edits: Vec<AppliedEdit>,
134 pub changed: bool,
136 pub safety: Safety,
138 pub applicability: Applicability,
140 pub idempotent: bool,
143}
144
145pub struct CompiledRule {
147 rule_id: String,
148 language: Language,
149 execution: Execution,
150 constraints: Vec<CompiledConstraint>,
152 transforms: Vec<(String, CompiledTransform)>,
154 fix: Option<String>,
156 fix_target: Option<String>,
159 safety: Safety,
161 message: String,
163 severity: Severity,
165}
166
167#[derive(Debug, Clone)]
170pub struct Diagnostic {
171 pub rule_id: String,
173 pub message: String,
175 pub severity: Severity,
177 pub span: Span,
179 pub applicability: Applicability,
181 pub fix: Option<String>,
184}
185
186enum Execution {
187 SourceRegex(regex::Regex),
190 Tree(Box<CompiledRuleTree>),
192}
193
194impl CompiledRule {
195 pub fn compile(rule: &Rule) -> Result<Self, RulesError> {
197 let language =
198 Language::from_name(&rule.language).ok_or_else(|| RulesError::UnknownLanguage {
199 rule: rule.id.clone(),
200 language: rule.language.clone(),
201 })?;
202
203 let execution = if rule.rule.is_pure_regex() {
207 let pattern = rule.rule.regex.as_ref().expect("pure regex");
208 Execution::SourceRegex(regex::Regex::new(pattern).map_err(|err| {
209 RulesError::PatternCompile {
210 rule: rule.id.clone(),
211 message: format!("invalid regex `{pattern}`: {err}"),
212 }
213 })?)
214 } else {
215 Execution::Tree(Box::new(CompiledRuleTree::compile(
216 &rule.id,
217 language,
218 &rule.rule,
219 &rule.utils,
220 )?))
221 };
222
223 let constraints = rule
224 .where_constraints
225 .iter()
226 .map(|c| CompiledConstraint::compile(&rule.id, language, c))
227 .collect::<Result<Vec<_>, _>>()?;
228
229 let transforms = rule
230 .transform
231 .iter()
232 .map(|(name, t)| {
233 CompiledTransform::compile(&rule.id, name, t).map(|c| (name.clone(), c))
234 })
235 .collect::<Result<Vec<_>, _>>()?;
236
237 Ok(CompiledRule {
238 rule_id: rule.id.clone(),
239 language,
240 execution,
241 constraints,
242 transforms,
243 fix: rule.fix.clone(),
244 fix_target: rule.fix_target.clone(),
245 safety: rule.safety,
246 message: rule.message.clone(),
247 severity: rule.severity,
248 })
249 }
250
251 pub fn language(&self) -> Language {
253 self.language
254 }
255
256 pub fn safety(&self) -> Safety {
258 self.safety
259 }
260
261 pub fn applicability(&self) -> Applicability {
264 self.safety.applicability()
265 }
266
267 pub fn id(&self) -> &str {
269 &self.rule_id
270 }
271
272 pub fn severity(&self) -> Severity {
275 self.severity
276 }
277
278 pub fn message(&self) -> &str {
280 &self.message
281 }
282
283 pub fn run(&self, source: &str) -> Result<Vec<RuleMatch>, RulesError> {
286 let mut matches = match &self.execution {
287 Execution::SourceRegex(regex) => self.run_regex(regex, source),
288 Execution::Tree(tree) => tree
289 .find(&self.rule_id, self.language, source)?
290 .into_iter()
291 .map(|m| RuleMatch {
292 rule_id: self.rule_id.clone(),
293 span: m.span,
294 text: m.text,
295 bindings: m.bindings,
296 })
297 .collect(),
298 };
299 if self.language == Language::Harn && !matches.is_empty() {
300 enrich_harn_matches(source, &mut matches).map_err(|message| {
301 RulesError::SourceParse {
302 rule: self.rule_id.clone(),
303 message,
304 }
305 })?;
306 }
307 if !self.constraints.is_empty() {
308 matches.retain(|m| self.satisfies_constraints(m));
309 }
310 Ok(matches)
311 }
312
313 fn satisfies_constraints(&self, m: &RuleMatch) -> bool {
316 self.constraints
317 .iter()
318 .all(|c| m.bindings.get(&c.metavar).is_some_and(|b| c.evaluate(b)))
319 }
320
321 pub fn apply(&self, source: &str) -> Result<CodemodResult, RulesError> {
330 let (rewritten, edits) = self.rewrite(source)?;
331 let changed = rewritten != source;
332 let (twice, _) = self.rewrite(&rewritten)?;
335 let idempotent = twice == rewritten;
336 Ok(CodemodResult {
337 rewritten,
338 edits,
339 changed,
340 safety: self.safety,
341 applicability: self.applicability(),
342 idempotent,
343 })
344 }
345
346 pub fn auto_apply(&self, source: &str) -> Result<CodemodResult, RulesError> {
350 if !self.safety.is_auto_applicable() {
351 return Err(RulesError::NotAutoApplicable {
352 rule: self.rule_id.clone(),
353 safety: format!("{:?}", self.safety),
354 });
355 }
356 self.apply(source)
357 }
358
359 pub fn apply_checked(&self, source: &str) -> Result<CodemodResult, RulesError> {
363 let result = self.apply(source)?;
364 if !result.idempotent {
365 return Err(RulesError::NotIdempotent {
366 rule: self.rule_id.clone(),
367 });
368 }
369 Ok(result)
370 }
371
372 pub fn diagnostics(&self, source: &str) -> Result<Vec<Diagnostic>, RulesError> {
377 let applicability = self.applicability();
378 let matches = self.run(source)?;
379 matches
380 .iter()
381 .map(|m| {
382 let span = if self.fix.is_some() && self.fix_target.is_some() {
386 self.edit_target(m)?.0
387 } else {
388 m.span
389 };
390 Ok(Diagnostic {
391 rule_id: self.rule_id.clone(),
392 message: self.message.clone(),
393 severity: self.severity,
394 span,
395 applicability,
396 fix: self.fix.as_ref().map(|template| {
397 let vars = self.metavars_for(m);
398 interpolate(template, &vars)
399 }),
400 })
401 })
402 .collect()
403 }
404
405 fn rewrite(&self, source: &str) -> Result<(String, Vec<AppliedEdit>), RulesError> {
408 let template = self
409 .fix
410 .as_ref()
411 .ok_or_else(|| RulesError::PatternCompile {
412 rule: self.rule_id.clone(),
413 message: "apply requires a `fix` template; this rule has none".into(),
414 })?;
415
416 let matches = dedupe_overlapping(self.run(source)?);
417 let edits: Vec<AppliedEdit> = matches
418 .iter()
419 .map(|m| {
420 let vars = self.metavars_for(m);
421 let (span, before) = self.edit_target(m)?;
422 Ok(AppliedEdit {
423 span,
424 before,
425 replacement: interpolate(template, &vars),
426 })
427 })
428 .collect::<Result<_, RulesError>>()?;
429 Ok((splice(source, &edits), edits))
430 }
431
432 fn edit_target(&self, m: &RuleMatch) -> Result<(Span, String), RulesError> {
435 match &self.fix_target {
436 None => Ok((m.span, m.text.clone())),
437 Some(name) => {
438 let binding =
439 m.bindings
440 .get(name)
441 .ok_or_else(|| RulesError::PatternCompile {
442 rule: self.rule_id.clone(),
443 message: format!(
444 "fixTarget `{name}` is not bound by this match; name a capture the matcher always binds"
445 ),
446 })?;
447 Ok((binding.span, binding.text.clone()))
448 }
449 }
450 }
451
452 fn metavars_for(&self, m: &RuleMatch) -> BTreeMap<String, String> {
455 let mut vars: BTreeMap<String, String> = m
456 .bindings
457 .iter()
458 .map(|(name, binding)| (name.clone(), binding.text.clone()))
459 .collect();
460 for (name, transform) in &self.transforms {
461 let input = m
462 .bindings
463 .get(&transform.source)
464 .map(|b| b.text.as_str())
465 .unwrap_or("");
466 vars.insert(name.clone(), transform.apply(input));
467 }
468 vars
469 }
470
471 fn run_regex(&self, regex: ®ex::Regex, source: &str) -> Vec<RuleMatch> {
472 let mut matches = Vec::new();
473 let mut cursor = RowColCursor::new(source);
478 for m in regex.find_iter(source) {
479 let (start_row, start_col) = cursor.advance_to(m.start());
480 let (end_row, end_col) = cursor.advance_to(m.end());
481 matches.push(RuleMatch {
482 rule_id: self.rule_id.clone(),
483 span: Span {
484 start_byte: m.start(),
485 end_byte: m.end(),
486 start_row,
487 start_col,
488 end_row,
489 end_col,
490 },
491 text: m.as_str().to_string(),
492 bindings: BTreeMap::new(),
493 });
494 }
495 matches
496 }
497}
498
499fn dedupe_overlapping(mut matches: Vec<RuleMatch>) -> Vec<RuleMatch> {
507 matches.sort_by(|a, b| {
510 a.span
511 .start_byte
512 .cmp(&b.span.start_byte)
513 .then(b.span.end_byte.cmp(&a.span.end_byte))
514 });
515 let mut kept: Vec<RuleMatch> = Vec::with_capacity(matches.len());
516 let mut covered_to = 0usize; for m in matches {
518 if m.span.start_byte >= covered_to {
522 covered_to = m.span.end_byte.max(covered_to);
523 kept.push(m);
524 }
525 }
526 kept
527}
528
529struct RowColCursor<'a> {
534 source: &'a str,
535 byte: usize,
536 row: usize,
537 col: usize,
538}
539
540impl<'a> RowColCursor<'a> {
541 fn new(source: &'a str) -> Self {
542 Self {
543 source,
544 byte: 0,
545 row: 0,
546 col: 0,
547 }
548 }
549
550 fn advance_to(&mut self, target: usize) -> (usize, usize) {
554 for ch in self.source[self.byte..target].chars() {
555 if ch == '\n' {
556 self.row += 1;
557 self.col = 0;
558 } else {
559 self.col += 1;
560 }
561 }
562 self.byte = target;
563 (self.row, self.col)
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::model::Rule;
571
572 fn rule(toml: &str) -> CompiledRule {
573 let parsed = Rule::from_toml_str(toml).expect("rule parses");
574 CompiledRule::compile(&parsed).expect("rule compiles")
575 }
576
577 #[test]
578 fn pattern_rule_binds_metavars() {
579 let compiled = rule(
580 r#"
581 id = "destructure-default"
582 language = "typescript"
583 fix = "{ $KEY: $SRC }"
584 [rule]
585 pattern = "$SRC?.$KEY ?? $DEFAULT"
586 "#,
587 );
588 let matches = compiled
589 .run("const a = cfg?.timeout ?? 30;\nconst b = opts?.retries ?? 3;\n")
590 .unwrap();
591 assert_eq!(matches.len(), 2);
592 assert_eq!(matches[0].bindings["SRC"].text, "cfg");
593 assert_eq!(matches[0].bindings["KEY"].text, "timeout");
594 assert_eq!(matches[0].bindings["DEFAULT"].text, "30");
595 assert_eq!(matches[1].bindings["SRC"].text, "opts");
596 assert_eq!(matches[0].text, "cfg?.timeout ?? 30");
598 assert_eq!(matches[0].span.start_row, 0);
599 assert_eq!(matches[1].span.start_row, 1);
600 }
601
602 #[test]
603 fn nested_matches_do_not_corrupt_or_panic_on_apply() {
604 let compiled = rule(
609 r#"
610 id = "sum-binop"
611 language = "typescript"
612 fix = "sum($X, $Y)"
613 [rule]
614 pattern = "$X + $Y"
615 "#,
616 );
617 assert!(compiled.run("const z = a + b + c;\n").unwrap().len() >= 2);
619 let result = compiled.apply("const z = a + b + c;\n").unwrap();
620 assert_eq!(result.rewritten, "const z = sum(a + b, c);\n");
622 assert_eq!(result.edits.len(), 1);
623 assert!(result.changed);
624 }
625
626 #[test]
627 fn dedupe_overlapping_keeps_outermost_in_document_order() {
628 let span = |s: usize, e: usize| Span {
629 start_byte: s,
630 end_byte: e,
631 start_row: 0,
632 start_col: s,
633 end_row: 0,
634 end_col: e,
635 };
636 let m = |s: usize, e: usize| RuleMatch {
637 rule_id: "r".into(),
638 span: span(s, e),
639 text: String::new(),
640 bindings: BTreeMap::new(),
641 };
642 let kept = dedupe_overlapping(vec![m(0, 5), m(0, 9), m(10, 14)]);
644 let spans: Vec<_> = kept
645 .iter()
646 .map(|m| (m.span.start_byte, m.span.end_byte))
647 .collect();
648 assert_eq!(spans, vec![(0, 9), (10, 14)]);
649 }
650
651 #[test]
652 fn kind_rule_matches_node_kind() {
653 let compiled = rule(
654 r#"
655 id = "find-calls"
656 language = "python"
657 [rule]
658 kind = "call"
659 "#,
660 );
661 let matches = compiled.run("print(x)\nlog(y)\n").unwrap();
662 assert_eq!(matches.len(), 2);
663 assert_eq!(matches[0].text, "print(x)");
664 assert!(matches[0].bindings.is_empty());
665 }
666
667 #[test]
668 fn regex_rule_matches_text() {
669 let compiled = rule(
670 r#"
671 id = "todo"
672 language = "rust"
673 message = "Found a TODO"
674 [rule]
675 regex = "TODO\\(\\w+\\)"
676 "#,
677 );
678 let matches = compiled
679 .run("fn f() {\n // TODO(ken) fix\n // todo lower\n}\n")
680 .unwrap();
681 assert_eq!(matches.len(), 1);
682 assert_eq!(matches[0].text, "TODO(ken)");
683 assert_eq!(matches[0].span.start_row, 1);
684 }
685
686 #[test]
687 fn raw_query_with_fix_target_rewrites_only_the_capture() {
688 let compiled = rule(
694 r#"
695 id = "let-to-const"
696 language = "harn"
697 fix = "const"
698 fixTarget = "kw"
699 [rule]
700 query = '(let_binding "let" @kw) @__match'
701 "#,
702 );
703 let result = compiled
704 .apply("fn f() {\n let x: Int = 1\n let y = 2\n}\n")
705 .unwrap();
706 assert_eq!(
707 result.rewritten,
708 "fn f() {\n const x: Int = 1\n const y = 2\n}\n"
709 );
710 assert_eq!(result.edits.len(), 2);
711 assert!(result.edits.iter().all(|e| e.before == "let"));
713 assert!(result.changed);
714 assert!(compiled.apply(&result.rewritten).unwrap().rewritten == result.rewritten);
716 }
717
718 #[test]
719 fn raw_query_scales_to_many_bindings() {
720 let compiled = rule(
729 r#"
730 id = "let-to-const"
731 language = "harn"
732 fix = "const"
733 fixTarget = "kw"
734 [rule]
735 query = '(let_binding "let" @kw) @__match'
736 "#,
737 );
738 let n = 200;
739 let mut src = String::from("fn f() {\n");
740 for i in 0..n {
741 src.push_str(&format!(" let v{i} = {i}\n"));
742 }
743 src.push_str("}\n");
744 let result = compiled.apply(&src).unwrap();
745 assert_eq!(result.edits.len(), n, "every binding keyword rewritten");
746 assert!(result.edits.iter().all(|e| e.before == "let"));
747 assert!(!result.rewritten.contains("let v"));
748 assert_eq!(result.rewritten.matches("const v").count(), n);
749 }
750
751 #[test]
752 fn raw_query_without_root_capture_is_a_compile_error() {
753 let parsed = Rule::from_toml_str(
754 r#"
755 id = "no-root"
756 language = "harn"
757 fix = "const"
758 [rule]
759 query = '(let_binding "let" @kw)'
760 "#,
761 )
762 .unwrap();
763 let compiled = CompiledRule::compile(&parsed);
764 assert!(
765 matches!(&compiled, Err(RulesError::PatternCompile { message, .. }) if message.contains("__match")),
766 "expected a missing-@__match compile error"
767 );
768 }
769
770 #[test]
771 fn fix_target_naming_an_unbound_capture_errors_on_apply() {
772 let compiled = rule(
773 r#"
774 id = "bad-target"
775 language = "harn"
776 fix = "const"
777 fixTarget = "missing"
778 [rule]
779 query = '(let_binding "let" @kw) @__match'
780 "#,
781 );
782 let result = compiled.apply("fn f() {\n let x = 1\n}\n");
783 assert!(
784 matches!(&result, Err(RulesError::PatternCompile { message, .. }) if message.contains("missing")),
785 "expected an unbound-fixTarget error"
786 );
787 }
788
789 #[test]
790 fn unknown_language_is_an_error() {
791 let parsed = Rule::from_toml_str(
792 r#"
793 id = "x"
794 language = "cobol"
795 [rule]
796 kind = "foo"
797 "#,
798 )
799 .unwrap();
800 assert!(matches!(
801 CompiledRule::compile(&parsed),
802 Err(RulesError::UnknownLanguage { .. })
803 ));
804 }
805
806 #[test]
807 fn invalid_pattern_surfaces_compile_error() {
808 let parsed = Rule::from_toml_str(
809 r#"
810 id = "x"
811 language = "typescript"
812 [rule]
813 pattern = "foo($$$ARGS)"
814 "#,
815 )
816 .unwrap();
817 assert!(matches!(
818 CompiledRule::compile(&parsed),
819 Err(RulesError::PatternCompile { .. })
820 ));
821 }
822
823 #[test]
824 fn harn_resolves_same_named_call_sites_by_binding_identity() {
825 let compiled = rule(
826 r#"
827 id = "top-level-target"
828 language = "harn"
829 [rule]
830 pattern = "$FN($ARG)"
831
832 [[where]]
833 metavar = "FN"
834 resolvesTo = { name = "target", kind = "fn", line = 1 }
835 "#,
836 );
837 let source = r"fn target(value: int) -> int {
838 return value
839}
840
841fn call_shadowed(target: fn(int) -> int) {
842 target(1)
843}
844
845fn call_global() {
846 target(2)
847}
848";
849 let matches = compiled.run(source).unwrap();
850 assert_eq!(matches.len(), 1);
851 assert_eq!(matches[0].text, "target(2)");
852 let binding = &matches[0].bindings["FN"];
853 let resolved = binding.metadata.resolved.as_ref().unwrap();
854 assert_eq!(resolved.name, "target");
855 assert_eq!(resolved.kind, "fn");
856 assert_eq!(resolved.span.start_row, 0);
857 assert_eq!(binding.metadata.ty.as_deref(), Some("fn(int) -> int"));
858 }
859
860 #[test]
861 fn harn_capture_type_constraint_filters_matches() {
862 let compiled = rule(
863 r#"
864 id = "int-logs"
865 language = "harn"
866 [rule]
867 pattern = "log($VALUE)"
868
869 [[where]]
870 metavar = "VALUE"
871 type = "int"
872 "#,
873 );
874 let source = r#"fn main() {
875 let count: int = 1
876 let label: string = "one"
877 log(count)
878 log(label)
879}
880"#;
881 let matches = compiled.run(source).unwrap();
882 assert_eq!(matches.len(), 1);
883 let value = &matches[0].bindings["VALUE"];
884 assert_eq!(value.text, "count");
885 assert_eq!(value.metadata.ty.as_deref(), Some("int"));
886 assert_eq!(
887 value
888 .metadata
889 .resolved
890 .as_ref()
891 .map(|resolved| resolved.kind.as_str()),
892 Some("let")
893 );
894 }
895
896 #[test]
897 fn harn_initializer_uses_outer_binding_scope() {
898 let compiled = rule(
899 r#"
900 id = "outer-initializer"
901 language = "harn"
902 [rule]
903 pattern = "log($VALUE)"
904
905 [[where]]
906 metavar = "VALUE"
907 resolvesTo = { name = "value", kind = "let", line = 2 }
908 "#,
909 );
910 let source = r"fn main() {
911 let value: int = 1
912 if true {
913 let value: string = log(value)
914 }
915}
916";
917 let matches = compiled.run(source).unwrap();
918 assert_eq!(matches.len(), 1);
919 let value = &matches[0].bindings["VALUE"];
920 assert_eq!(value.text, "value");
921 assert_eq!(
922 value
923 .metadata
924 .resolved
925 .as_ref()
926 .map(|resolved| resolved.span.start_row),
927 Some(1)
928 );
929 }
930}