semantic_code_edit_mcp/
editor.rs

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        // Get the diff string and clean it up for AI consumption
184        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            // Skip ALL diff headers: file headers, hunk headers (line numbers), and any metadata
204            if line.starts_with("---") || line.starts_with("+++") || line.starts_with("@@") {
205                // Skip "\ No newline at end of file" messages
206                continue;
207            }
208            cleaned_diff.push_str(line);
209            cleaned_diff.push('\n');
210        }
211
212        // Remove trailing newline to avoid extra spacing
213        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        // old_range().range() returns a std::ops::Range<usize> that's properly 0-indexed
276        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}