Skip to main content

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}