fix_engine/language.rs
1//! Language-specific fix provider trait.
2//!
3//! The fix engine is language-agnostic -- it works with any Konveyor analysis
4//! output regardless of the source language. However, certain fix operations
5//! (attribute removal, import deduplication, path skipping, dependency
6//! management) require knowledge of the target language's syntax and ecosystem.
7//!
8//! This module defines the [`LanguageFixProvider`] trait that language-specific
9//! crates implement, plus a [`NoOpLanguageFixProvider`] fallback that performs
10//! no language-specific processing.
11
12use fix_engine_core::{PlannedFix, RenameMapping};
13use konveyor_core::incident::Incident;
14use std::path::Path;
15
16/// Trait that language-specific crates implement to provide syntax-aware
17/// fix operations for the fix engine.
18///
19/// Implementations are passed to [`plan_fixes`](crate::engine::plan_fixes),
20/// [`apply_fixes`](crate::engine::apply_fixes), and
21/// [`preview_fixes`](crate::engine::preview_fixes) at runtime.
22pub trait LanguageFixProvider: Send + Sync {
23 /// Should this file path be skipped during fix planning?
24 ///
25 /// For example, a JS/TS provider skips `node_modules/` since those
26 /// dependencies are updated via package manager, not source patches.
27 fn should_skip_path(&self, path: &Path) -> bool;
28
29 /// Post-process lines after edits have been applied.
30 ///
31 /// Called once per file after all text edits are applied. Implementations
32 /// can use this to clean up language-specific artifacts (e.g., deduplicating
33 /// import specifiers after renames produce duplicates).
34 fn post_process_lines(&self, lines: &mut [String]);
35
36 /// Plan an attribute/prop removal fix.
37 ///
38 /// Given an incident flagging an attribute for removal, produce a
39 /// [`PlannedFix`] with the text edits needed to remove it. Returns `None`
40 /// if the incident cannot be processed.
41 fn plan_remove_attribute(
42 &self,
43 rule_id: &str,
44 incident: &Incident,
45 file_path: &Path,
46 ) -> Option<PlannedFix>;
47
48 /// Plan dependency version fix(es).
49 ///
50 /// Given an incident requiring a dependency to be at a specific version,
51 /// produce [`PlannedFix`] entries with the text edits needed to update or
52 /// add the dependency in the appropriate manifest file (e.g., `package.json`
53 /// for Node.js, `Cargo.toml` for Rust, `go.mod` for Go).
54 ///
55 /// Returns a `Vec` because a single incident (e.g., a transitive lockfile
56 /// dep) may require updating multiple parent packages. Returns an empty
57 /// `Vec` if the language provider does not support dependency management
58 /// or if the incident cannot be processed.
59 fn plan_ensure_dependency(
60 &self,
61 rule_id: &str,
62 incident: &Incident,
63 package: &str,
64 new_version: &str,
65 file_path: &Path,
66 ) -> Vec<PlannedFix>;
67
68 /// Extract the matched text from incident variables.
69 ///
70 /// Incidents carry language-specific variable names (e.g., `propName`,
71 /// `className`, `variableName`). This method extracts the primary matched
72 /// text from whichever variable is present.
73 fn get_matched_text(&self, incident: &Incident) -> String;
74
75 /// Get the matched text for rename operations.
76 ///
77 /// Rename mappings may target either the attribute name or its value.
78 /// This method inspects incident variables to find which mapping entry
79 /// matches, considering both names and values.
80 fn get_matched_text_for_rename(
81 &self,
82 incident: &Incident,
83 mappings: &[RenameMapping],
84 ) -> String;
85
86 /// Whether a rename incident requires whole-file scanning.
87 ///
88 /// Some renames (e.g., component/import renames in JSX) affect many lines
89 /// beyond the incident line -- opening tags, closing tags, type references.
90 /// When this returns `true`, the engine scans the entire file for all
91 /// occurrences of the rename mappings.
92 fn is_whole_file_rename(&self, incident: &Incident) -> bool;
93
94 /// Capture baseline state before edits are written to disk.
95 ///
96 /// Called once before any files are modified. Implementations can capture
97 /// pre-existing state needed for diffing in `post_apply` — e.g., the set
98 /// of unmet peer dependencies before package version updates, so that
99 /// `post_apply` only installs *newly* introduced peers rather than
100 /// pre-existing intentionally-unmet ones (like host-provided shared modules).
101 ///
102 /// Returns opaque state that will be forwarded to `post_apply`.
103 ///
104 /// Default: no-op, returns `None`.
105 fn pre_apply(&self, _project_root: &Path) -> Option<Box<dyn std::any::Any>> {
106 None
107 }
108
109 /// Post-process after all fixes in a plan have been applied to disk.
110 ///
111 /// Called once after all files are written. Implementations can trigger
112 /// ecosystem-specific steps like `npm install` after `package.json`
113 /// modifications to keep the lockfile and `node_modules` in sync.
114 ///
115 /// `project_root` is the top-level project directory.
116 /// `modified_files` lists paths of files that were actually changed.
117 /// `pre_state` is the opaque state returned by `pre_apply`, if any.
118 ///
119 /// Default: no-op.
120 fn post_apply(
121 &self,
122 _project_root: &Path,
123 _modified_files: &[std::path::PathBuf],
124 _pre_state: Option<Box<dyn std::any::Any>>,
125 ) -> anyhow::Result<()> {
126 Ok(())
127 }
128}
129
130/// No-op fallback provider for languages without specific fix support.
131///
132/// Skips no paths, performs no post-processing, and returns `None` / empty
133/// defaults for all language-specific operations. The engine still applies
134/// generic strategies (text replacement renames, import path changes, etc.).
135pub struct NoOpLanguageFixProvider;
136
137impl LanguageFixProvider for NoOpLanguageFixProvider {
138 fn should_skip_path(&self, _path: &Path) -> bool {
139 false
140 }
141
142 fn post_process_lines(&self, _lines: &mut [String]) {
143 // No post-processing
144 }
145
146 fn plan_remove_attribute(
147 &self,
148 _rule_id: &str,
149 _incident: &Incident,
150 _file_path: &Path,
151 ) -> Option<PlannedFix> {
152 None
153 }
154
155 fn plan_ensure_dependency(
156 &self,
157 _rule_id: &str,
158 _incident: &Incident,
159 _package: &str,
160 _new_version: &str,
161 _file_path: &Path,
162 ) -> Vec<PlannedFix> {
163 Vec::new()
164 }
165
166 fn get_matched_text(&self, _incident: &Incident) -> String {
167 String::new()
168 }
169
170 fn get_matched_text_for_rename(
171 &self,
172 _incident: &Incident,
173 _mappings: &[RenameMapping],
174 ) -> String {
175 String::new()
176 }
177
178 fn is_whole_file_rename(&self, _incident: &Incident) -> bool {
179 false
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_noop_provider_skips_nothing() {
189 let provider = NoOpLanguageFixProvider;
190 assert!(!provider.should_skip_path(Path::new("/some/path")));
191 assert!(!provider.should_skip_path(Path::new("/node_modules/foo")));
192 }
193
194 #[test]
195 fn test_noop_provider_no_post_processing() {
196 let provider = NoOpLanguageFixProvider;
197 let mut lines = vec!["import { Foo, Foo } from 'bar';".to_string()];
198 provider.post_process_lines(&mut lines);
199 // Lines should be unchanged
200 assert_eq!(lines[0], "import { Foo, Foo } from 'bar';");
201 }
202
203 #[test]
204 fn test_noop_provider_returns_none_for_remove() {
205 let provider = NoOpLanguageFixProvider;
206 let incident = konveyor_core::incident::Incident {
207 file_uri: "file:///test.rs".to_string(),
208 line_number: Some(1),
209 code_location: None,
210 message: String::new(),
211 code_snip: None,
212 variables: std::collections::BTreeMap::new(),
213 effort: None,
214 links: Vec::new(),
215 is_dependency_incident: false,
216 };
217 assert!(provider
218 .plan_remove_attribute("rule", &incident, Path::new("/test.rs"))
219 .is_none());
220 }
221
222 #[test]
223 fn test_noop_provider_returns_empty_for_ensure_dependency() {
224 let provider = NoOpLanguageFixProvider;
225 let incident = konveyor_core::incident::Incident {
226 file_uri: "file:///test.rs".to_string(),
227 line_number: Some(1),
228 code_location: None,
229 message: String::new(),
230 code_snip: None,
231 variables: std::collections::BTreeMap::new(),
232 effort: None,
233 links: Vec::new(),
234 is_dependency_incident: false,
235 };
236 assert!(provider
237 .plan_ensure_dependency("rule", &incident, "pkg", "1.0.0", Path::new("/test.rs"))
238 .is_empty());
239 }
240
241 #[test]
242 fn test_noop_provider_empty_matched_text() {
243 let provider = NoOpLanguageFixProvider;
244 let incident = konveyor_core::incident::Incident {
245 file_uri: String::new(),
246 line_number: Some(1),
247 code_location: None,
248 message: String::new(),
249 code_snip: None,
250 variables: std::collections::BTreeMap::new(),
251 effort: None,
252 links: Vec::new(),
253 is_dependency_incident: false,
254 };
255 assert_eq!(provider.get_matched_text(&incident), "");
256 assert_eq!(provider.get_matched_text_for_rename(&incident, &[]), "");
257 assert!(!provider.is_whole_file_rename(&incident));
258 }
259}