1mod edit;
2mod edit_iterator;
3mod edit_position;
4
5use std::{collections::BTreeSet, path::PathBuf};
6
7use anyhow::{Result, anyhow};
8use diffy::{DiffOptions, Patch, PatchFormatter};
9use edit::Edit;
10use edit_iterator::EditIterator;
11use ropey::Rope;
12use tree_sitter::Tree;
13
14pub use edit_position::EditPosition;
15
16use crate::{
17 languages::{LanguageCommon, LanguageRegistry},
18 selector::Selector,
19 state::StagedOperation,
20 validation::ContextValidator,
21};
22
23pub struct Editor<'language> {
24 content: String,
25 selector: Selector,
26 file_path: PathBuf,
27 language: &'language LanguageCommon,
28 source_code: String,
29 tree: Tree,
30 rope: Rope,
31 staged_edit: Option<EditPosition>,
32}
33
34impl<'language> Editor<'language> {
35 pub fn new(
36 content: String,
37 selector: Selector,
38 language: &'language LanguageCommon,
39 file_path: PathBuf,
40 staged_edit: Option<EditPosition>,
41 ) -> Result<Self> {
42 let source_code = std::fs::read_to_string(&file_path)?;
43 let mut parser = language.tree_sitter_parser()?;
44 let tree = parser.parse(&source_code, None).ok_or_else(|| {
45 anyhow!(
46 "Unable to parse {} as {}",
47 file_path.display(),
48 language.name()
49 )
50 })?;
51 let rope = Rope::from_str(&source_code);
52
53 Ok(Self {
54 content,
55 selector,
56 language,
57 tree,
58 file_path,
59 source_code,
60 rope,
61 staged_edit,
62 })
63 }
64
65 pub fn from_staged_operation(
66 staged_operation: StagedOperation,
67 language_registry: &'language LanguageRegistry,
68 ) -> Result<Self> {
69 let StagedOperation {
70 selector,
71 content,
72 file_path,
73 language_name,
74 edit_position,
75 } = staged_operation;
76 let language = language_registry.get_language(language_name);
77 Self::new(content, selector, language, file_path, edit_position)
78 }
79
80 fn prevalidate(&self) -> Option<String> {
81 self.validate_tree(&self.tree, &self.source_code)
82 .map(|errors| {
83 format!(
84 "Syntax error found prior to edit, not attempting.
85Suggestion: Pause and show your human collaborator this context:\n\n{errors}"
86 )
87 })
88 }
89
90 fn validate_tree(&self, tree: &Tree, content: &str) -> Option<String> {
91 Self::validate(self.language, tree, content)
92 }
93
94 pub fn validate(language: &LanguageCommon, tree: &Tree, content: &str) -> Option<String> {
95 let errors = language.editor().collect_errors(tree, content);
96 if errors.is_empty() {
97 if let Some(query) = language.validation_query() {
98 let validation_result = ContextValidator::validate_tree(tree, query, content);
99
100 if !validation_result.is_valid {
101 return Some(validation_result.format_errors());
102 }
103 }
104
105 return None;
106 }
107
108 let context_lines = 3;
109 let lines_with_errors = errors.into_iter().collect::<BTreeSet<_>>();
110 let context_lines = lines_with_errors
111 .iter()
112 .copied()
113 .flat_map(|line| line.saturating_sub(context_lines)..line + context_lines)
114 .collect::<BTreeSet<_>>();
115 Some(
116 std::iter::once(String::from("===SYNTAX ERRORS===\n"))
117 .chain(
118 content
119 .lines()
120 .enumerate()
121 .filter(|(index, _)| context_lines.contains(index))
122 .map(|(index, line)| {
123 let display_index = index + 1;
124 if lines_with_errors.contains(&index) {
125 format!("{display_index:>4} ->⎸{line}\n")
126 } else {
127 format!("{display_index:>4} ⎸{line}\n")
128 }
129 }),
130 )
131 .collect(),
132 )
133 }
134
135 fn edit_iterator(&self) -> EditIterator<'_, 'language> {
136 EditIterator::new(self)
137 }
138
139 fn edit(&mut self) -> Result<(String, Option<String>)> {
140 if let Some(prevalidation_failure) = self.prevalidate() {
141 return Ok((prevalidation_failure, None));
142 };
143
144 let mut failed_edits = vec![];
145 for edit in self.edit_iterator() {
146 match edit {
147 Ok(mut edit) => {
148 edit.apply()?;
149 if edit.is_valid() {
150 return Ok((edit.message(), edit.output()));
151 }
152
153 failed_edits.push(edit);
154 }
155
156 Err(message) => return Ok((message, None)),
157 }
158 }
159
160 Ok((failed_edits.first_mut().unwrap().message(), None))
161 }
162
163 pub fn preview(mut self) -> Result<(String, Option<StagedOperation>)> {
164 let (message, output) = self.edit()?;
165 if let Some(output) = &output {
166 let mut preview = String::new();
167
168 preview.push_str(&format!("STAGED: {}\n\n", self.selector.operation_name()));
169 preview.push_str(&self.diff(output));
170
171 Ok((preview, Some(self.into())))
172 } else {
173 Ok((message, None))
174 }
175 }
176
177 fn diff(&self, output: &str) -> String {
178 let source_code: &str = &self.source_code;
179 let content_patch = &self.content;
180 let diff_patch = DiffOptions::new().create_patch(source_code, output);
181 let formatter = PatchFormatter::new().missing_newline_message(false);
182
183 let diff_output = formatter.fmt_patch(&diff_patch).to_string();
185 let lines: Vec<&str> = diff_output.lines().collect();
186 let mut cleaned_diff = String::new();
187
188 let content_line_count = content_patch.lines().count();
189 if content_line_count > 10 {
190 let changed_lines = changed_lines(&diff_patch, content_line_count);
191
192 let changed_fraction = (changed_lines * 100) / content_line_count;
193
194 cleaned_diff.push_str(&format!("Edit efficiency: {changed_fraction}%\n",));
195 if changed_fraction < 30 {
196 cleaned_diff.push_str("💡 TIP: For focused changes like this, you might try targeted insert/replace operations for easier review and iteration\n");
197 };
198 cleaned_diff.push('\n');
199 }
200
201 cleaned_diff.push_str("===DIFF===\n");
202 for line in lines {
203 if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
205 continue;
207 }
208 cleaned_diff.push_str(line);
209 cleaned_diff.push('\n');
210 }
211
212 if cleaned_diff.ends_with('\n') {
214 cleaned_diff.pop();
215 }
216 cleaned_diff
217 }
218
219 pub fn format_code(&self, source: &str) -> Result<String> {
220 self.language.editor().format_code(source).map_err(|e| {
221 anyhow!(
222 "The formatter has encountered the following error making \
223 that change, so the file has not been modified. The tool has \
224 prevented what it believes to be an unsafe edit. Please try a \
225 different edit.\n\n\
226 {e}"
227 )
228 })
229 }
230
231 pub fn commit(mut self) -> Result<(String, Option<String>, PathBuf)> {
232 let (mut message, output) = self.edit()?;
233 if let Some(output) = &output {
234 let diff = self.diff(output);
235
236 message = format!(
237 "{} operation result:\n{}\n\n{diff}",
238 self.selector.operation_name(),
239 message,
240 );
241 }
242 Ok((message, output, self.file_path))
243 }
244
245 fn parse(&self, output: &str, old_tree: Option<&Tree>) -> Option<Tree> {
246 let mut parser = self.language.tree_sitter_parser().unwrap();
247 parser.parse(output, old_tree)
248 }
249}
250
251impl From<Editor<'_>> for StagedOperation {
252 fn from(value: Editor) -> Self {
253 let Editor {
254 content,
255 selector,
256 file_path,
257 language,
258 staged_edit,
259 ..
260 } = value;
261 Self {
262 selector,
263 content,
264 file_path,
265 language_name: language.name(),
266 edit_position: staged_edit,
267 }
268 }
269}
270
271pub fn changed_lines(patch: &Patch<'_, str>, content_line_count: usize) -> usize {
272 let mut changed_line_numbers = BTreeSet::new();
273
274 for hunk in patch.hunks() {
275 for line_num in hunk.old_range().range() {
277 if line_num < content_line_count {
278 changed_line_numbers.insert(line_num);
279 }
280 }
281 }
282 changed_line_numbers.len()
283}