Skip to main content

fresh/app/
on_save_actions.rs

1//! On-save action execution.
2//!
3//! This module handles running configured actions when files are saved,
4//! such as formatters, linters, and other tools.
5
6use std::io::Write;
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::time::Duration;
10
11use super::Editor;
12use crate::config::{FormatterConfig, OnSaveAction};
13use crate::model::event::Event;
14use crate::services::lsp::manager::detect_language;
15use rust_i18n::t;
16
17/// Result of running a formatter or on-save action
18enum ActionResult {
19    /// Action ran successfully, contains output
20    Success(String),
21    /// Command not found
22    CommandNotFound(String),
23    /// Action failed with error
24    Error(String),
25}
26
27impl Editor {
28    /// Run on-save actions for the active buffer after a successful save.
29    /// This includes format-on-save (if enabled) and any on_save actions.
30    /// Returns Ok(true) if actions ran successfully, Ok(false) if no actions,
31    /// or Err with an error message.
32    pub fn run_on_save_actions(&mut self) -> Result<bool, String> {
33        let path = match self.active_state().buffer.file_path() {
34            Some(p) => p.to_path_buf(),
35            None => return Ok(false),
36        };
37
38        let mut ran_any_action = false;
39
40        // Run whitespace cleanup actions first (before formatter)
41        if self.config.editor.trim_trailing_whitespace_on_save {
42            if self.trim_trailing_whitespace()? {
43                ran_any_action = true;
44            }
45        }
46
47        if self.config.editor.ensure_final_newline_on_save {
48            if self.ensure_final_newline()? {
49                ran_any_action = true;
50            }
51        }
52
53        // If whitespace cleanup made changes, re-save
54        if ran_any_action {
55            if let Err(e) = self.active_state_mut().buffer.save() {
56                return Err(format!("Failed to re-save after whitespace cleanup: {}", e));
57            }
58            self.active_event_log_mut().mark_saved();
59        }
60
61        // Detect language for this file
62        let language = match detect_language(&path, &self.config.languages) {
63            Some(lang) => lang,
64            None => return Ok(ran_any_action),
65        };
66
67        let lang_config = match self.config.languages.get(&language) {
68            Some(lc) => lc.clone(),
69            None => return Ok(ran_any_action),
70        };
71
72        // Run formatter if format_on_save is enabled
73        if lang_config.format_on_save {
74            if let Some(ref formatter) = lang_config.formatter {
75                match self.run_formatter(formatter, &path) {
76                    ActionResult::Success(output) => {
77                        self.replace_buffer_with_output(&output)?;
78                        // Re-save after formatting
79                        if let Err(e) = self.active_state_mut().buffer.save() {
80                            return Err(format!("Failed to re-save after format: {}", e));
81                        }
82                        self.active_event_log_mut().mark_saved();
83                        ran_any_action = true;
84                    }
85                    ActionResult::CommandNotFound(cmd) => {
86                        self.status_message = Some(format!(
87                            "Formatter '{}' not found (install it for auto-formatting)",
88                            cmd
89                        ));
90                    }
91                    ActionResult::Error(e) => {
92                        return Err(e);
93                    }
94                }
95            }
96        }
97
98        // Run on_save actions (linters, etc.)
99        let project_root = std::env::current_dir()
100            .unwrap_or_else(|_| path.parent().unwrap_or(Path::new(".")).to_path_buf());
101
102        for action in &lang_config.on_save {
103            if !action.enabled {
104                continue;
105            }
106
107            match self.run_on_save_action(action, &path, &project_root) {
108                ActionResult::Success(_) => {
109                    ran_any_action = true;
110                }
111                ActionResult::CommandNotFound(_) => {
112                    // Skip missing optional commands silently
113                }
114                ActionResult::Error(e) => {
115                    return Err(e);
116                }
117            }
118        }
119
120        Ok(ran_any_action)
121    }
122
123    /// Format the current buffer using the configured formatter.
124    /// Returns Ok(()) if formatting succeeded, or Err with an error message.
125    pub fn format_buffer(&mut self) -> Result<(), String> {
126        let path = match self.active_state().buffer.file_path() {
127            Some(p) => p.to_path_buf(),
128            None => {
129                return Err(
130                    "Cannot format unsaved buffer (save first to detect language)".to_string(),
131                )
132            }
133        };
134
135        // Detect language for this file
136        let language = match detect_language(&path, &self.config.languages) {
137            Some(lang) => lang,
138            None => return Err("No language detected for this file".to_string()),
139        };
140
141        // Get formatter for this language
142        let formatter = self
143            .config
144            .languages
145            .get(&language)
146            .and_then(|lc| lc.formatter.clone());
147
148        let formatter = match formatter {
149            Some(f) => f,
150            None => return Err(format!("No formatter configured for {}", language)),
151        };
152
153        match self.run_formatter(&formatter, &path) {
154            ActionResult::Success(output) => {
155                self.replace_buffer_with_output(&output)?;
156                self.set_status_message(
157                    t!(
158                        "format.formatted_with",
159                        formatter = formatter.command.clone()
160                    )
161                    .to_string(),
162                );
163                Ok(())
164            }
165            ActionResult::CommandNotFound(cmd) => Err(format!("Formatter '{}' not found", cmd)),
166            ActionResult::Error(e) => Err(e),
167        }
168    }
169
170    /// Run a formatter on the current buffer content.
171    fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
172        let file_path_str = file_path.display().to_string();
173
174        // Check if command exists
175        if !command_exists(&formatter.command) {
176            return ActionResult::CommandNotFound(formatter.command.clone());
177        }
178
179        // Build the command
180        let shell = detect_shell();
181
182        // Build the full command string with arguments
183        let mut cmd_parts = vec![formatter.command.clone()];
184        for arg in &formatter.args {
185            cmd_parts.push(arg.replace("$FILE", &file_path_str));
186        }
187
188        let full_command = cmd_parts.join(" ");
189
190        // Get project root for working directory
191        let project_root = std::env::current_dir()
192            .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());
193
194        // Set up the command
195        let mut cmd = Command::new(&shell);
196        cmd.args(["-c", &full_command])
197            .current_dir(&project_root)
198            .stdout(Stdio::piped())
199            .stderr(Stdio::piped());
200
201        if formatter.stdin {
202            cmd.stdin(Stdio::piped());
203        } else {
204            cmd.stdin(Stdio::null());
205        }
206
207        // Spawn the process
208        let mut child = match cmd.spawn() {
209            Ok(c) => c,
210            Err(e) => {
211                return ActionResult::Error(format!(
212                    "Failed to run '{}': {}",
213                    formatter.command, e
214                ));
215            }
216        };
217
218        // Write buffer content to stdin if configured
219        if formatter.stdin {
220            let content = self.active_state().buffer.to_string().unwrap_or_default();
221            if let Some(mut stdin) = child.stdin.take() {
222                if let Err(e) = stdin.write_all(content.as_bytes()) {
223                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
224                }
225            }
226        }
227
228        // Wait for the process with timeout
229        let timeout = Duration::from_millis(formatter.timeout_ms);
230        let start = std::time::Instant::now();
231
232        loop {
233            match child.try_wait() {
234                Ok(Some(status)) => {
235                    let output = match child.wait_with_output() {
236                        Ok(o) => o,
237                        Err(e) => {
238                            return ActionResult::Error(format!("Failed to get output: {}", e))
239                        }
240                    };
241
242                    if status.success() {
243                        return match String::from_utf8(output.stdout) {
244                            Ok(s) => ActionResult::Success(s),
245                            Err(e) => {
246                                ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
247                            }
248                        };
249                    } else {
250                        let stderr = String::from_utf8_lossy(&output.stderr);
251                        let stdout = String::from_utf8_lossy(&output.stdout);
252                        let error_output = if !stderr.is_empty() {
253                            stderr.trim().to_string()
254                        } else if !stdout.is_empty() {
255                            stdout.trim().to_string()
256                        } else {
257                            format!("exit code {:?}", status.code())
258                        };
259                        return ActionResult::Error(format!(
260                            "Formatter '{}' failed: {}",
261                            formatter.command, error_output
262                        ));
263                    }
264                }
265                Ok(None) => {
266                    if start.elapsed() > timeout {
267                        let _ = child.kill();
268                        return ActionResult::Error(format!(
269                            "Formatter '{}' timed out after {}ms",
270                            formatter.command, formatter.timeout_ms
271                        ));
272                    }
273                    std::thread::sleep(Duration::from_millis(10));
274                }
275                Err(e) => {
276                    return ActionResult::Error(format!(
277                        "Failed to wait for '{}': {}",
278                        formatter.command, e
279                    ));
280                }
281            }
282        }
283    }
284
285    /// Run a single on-save action (linter, etc.).
286    fn run_on_save_action(
287        &mut self,
288        action: &OnSaveAction,
289        file_path: &Path,
290        project_root: &Path,
291    ) -> ActionResult {
292        let file_path_str = file_path.display().to_string();
293
294        // Check if command exists
295        if !command_exists(&action.command) {
296            return ActionResult::CommandNotFound(action.command.clone());
297        }
298
299        // Build the command
300        let shell = detect_shell();
301
302        let mut cmd_parts = vec![action.command.clone()];
303        for arg in &action.args {
304            cmd_parts.push(arg.replace("$FILE", &file_path_str));
305        }
306
307        // If no arguments contain $FILE, append the file path
308        let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
309        if !has_file_arg && !action.stdin {
310            cmd_parts.push(file_path_str.clone());
311        }
312
313        let full_command = cmd_parts.join(" ");
314
315        // Determine working directory
316        let working_dir = action
317            .working_dir
318            .as_ref()
319            .map(|wd| {
320                let expanded = wd.replace("$FILE", &file_path_str);
321                Path::new(&expanded).to_path_buf()
322            })
323            .unwrap_or_else(|| project_root.to_path_buf());
324
325        // Set up the command
326        let mut cmd = Command::new(&shell);
327        cmd.args(["-c", &full_command])
328            .current_dir(&working_dir)
329            .stdout(Stdio::piped())
330            .stderr(Stdio::piped());
331
332        if action.stdin {
333            cmd.stdin(Stdio::piped());
334        } else {
335            cmd.stdin(Stdio::null());
336        }
337
338        // Spawn the process
339        let mut child = match cmd.spawn() {
340            Ok(c) => c,
341            Err(e) => {
342                return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
343            }
344        };
345
346        // Write buffer content to stdin if configured
347        if action.stdin {
348            let content = self.active_state().buffer.to_string().unwrap_or_default();
349            if let Some(mut stdin) = child.stdin.take() {
350                if let Err(e) = stdin.write_all(content.as_bytes()) {
351                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
352                }
353            }
354        }
355
356        // Wait for the process with timeout
357        let timeout = Duration::from_millis(action.timeout_ms);
358        let start = std::time::Instant::now();
359
360        loop {
361            match child.try_wait() {
362                Ok(Some(status)) => {
363                    let output = match child.wait_with_output() {
364                        Ok(o) => o,
365                        Err(e) => {
366                            return ActionResult::Error(format!("Failed to get output: {}", e))
367                        }
368                    };
369
370                    if status.success() {
371                        return match String::from_utf8(output.stdout) {
372                            Ok(s) => ActionResult::Success(s),
373                            Err(e) => {
374                                ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
375                            }
376                        };
377                    } else {
378                        let stderr = String::from_utf8_lossy(&output.stderr);
379                        let stdout = String::from_utf8_lossy(&output.stdout);
380                        let error_output = if !stderr.is_empty() {
381                            stderr.trim().to_string()
382                        } else if !stdout.is_empty() {
383                            stdout.trim().to_string()
384                        } else {
385                            format!("exit code {:?}", status.code())
386                        };
387                        return ActionResult::Error(format!(
388                            "On-save action '{}' failed: {}",
389                            action.command, error_output
390                        ));
391                    }
392                }
393                Ok(None) => {
394                    if start.elapsed() > timeout {
395                        let _ = child.kill();
396                        return ActionResult::Error(format!(
397                            "On-save action '{}' timed out after {}ms",
398                            action.command, action.timeout_ms
399                        ));
400                    }
401                    std::thread::sleep(Duration::from_millis(10));
402                }
403                Err(e) => {
404                    return ActionResult::Error(format!(
405                        "Failed to wait for '{}': {}",
406                        action.command, e
407                    ));
408                }
409            }
410        }
411    }
412
413    /// Replace the active buffer's content with new output.
414    fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
415        let cursor_id = self.active_state().cursors.primary_id();
416
417        // Get current buffer content
418        let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
419
420        // Only replace if content is different
421        if buffer_content == output {
422            return Ok(());
423        }
424
425        let buffer_len = buffer_content.len();
426
427        // Capture cursor position and selection state before replacement
428        let old_cursor_pos = self.active_state().cursors.primary().position;
429        let old_anchor = self.active_state().cursors.primary().anchor;
430        let old_sticky_column = self.active_state().cursors.primary().sticky_column;
431
432        // Delete all content and insert new
433        let delete_event = Event::Delete {
434            range: 0..buffer_len,
435            deleted_text: buffer_content,
436            cursor_id,
437        };
438        let insert_event = Event::Insert {
439            position: 0,
440            text: output.to_string(),
441            cursor_id,
442        };
443
444        // After delete+insert, cursor will be at output.len()
445        // Restore cursor to original position (or clamp to new buffer length)
446        let new_buffer_len = output.len();
447        let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
448
449        // Only add MoveCursor event if position actually changes
450        let mut events = vec![delete_event, insert_event];
451        if new_cursor_pos != new_buffer_len {
452            let move_cursor_event = Event::MoveCursor {
453                cursor_id,
454                old_position: new_buffer_len, // Where cursor is after insert
455                new_position: new_cursor_pos,
456                old_anchor: None,
457                new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
458                old_sticky_column: 0,
459                new_sticky_column: old_sticky_column,
460            };
461            events.push(move_cursor_event);
462        }
463
464        // Apply as a batch for atomic undo
465        let batch = Event::Batch {
466            events,
467            description: "On-save format".to_string(),
468        };
469        self.active_event_log_mut().append(batch.clone());
470        self.apply_event_to_active_buffer(&batch);
471
472        Ok(())
473    }
474
475    /// Trim trailing whitespace from all lines in the active buffer.
476    /// Returns Ok(true) if any changes were made, Ok(false) if buffer unchanged.
477    pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
478        let content = self.active_state().buffer.to_string().unwrap_or_default();
479
480        // Process each line and trim trailing whitespace
481        let trimmed: String = content
482            .lines()
483            .map(|line| line.trim_end())
484            .collect::<Vec<_>>()
485            .join("\n");
486
487        // Preserve original trailing newline if present
488        let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
489            format!("{}\n", trimmed)
490        } else {
491            trimmed
492        };
493
494        if trimmed == content {
495            return Ok(false);
496        }
497
498        self.replace_buffer_with_output(&trimmed)?;
499        Ok(true)
500    }
501
502    /// Ensure the buffer ends with a newline.
503    /// Returns Ok(true) if a newline was added, Ok(false) if already ends with newline.
504    pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
505        let content = self.active_state().buffer.to_string().unwrap_or_default();
506
507        // Empty buffers don't need a newline
508        if content.is_empty() {
509            return Ok(false);
510        }
511
512        if content.ends_with('\n') {
513            return Ok(false);
514        }
515
516        let with_newline = format!("{}\n", content);
517        self.replace_buffer_with_output(&with_newline)?;
518        Ok(true)
519    }
520}
521
522/// Check if a command exists in the system PATH.
523fn command_exists(command: &str) -> bool {
524    // Use 'which' on Unix or 'where' on Windows to check if command exists
525    #[cfg(unix)]
526    {
527        Command::new("which")
528            .arg(command)
529            .stdout(Stdio::null())
530            .stderr(Stdio::null())
531            .status()
532            .map(|s| s.success())
533            .unwrap_or(false)
534    }
535
536    #[cfg(windows)]
537    {
538        Command::new("where")
539            .arg(command)
540            .stdout(Stdio::null())
541            .stderr(Stdio::null())
542            .status()
543            .map(|s| s.success())
544            .unwrap_or(false)
545    }
546
547    #[cfg(not(any(unix, windows)))]
548    {
549        // On other platforms, assume command exists and let it fail at runtime
550        true
551    }
552}
553
554/// Detect the shell to use for executing commands.
555fn detect_shell() -> String {
556    // Try SHELL environment variable first
557    if let Ok(shell) = std::env::var("SHELL") {
558        if !shell.is_empty() {
559            return shell;
560        }
561    }
562
563    // Fall back to common shells
564    #[cfg(unix)]
565    {
566        if std::path::Path::new("/bin/bash").exists() {
567            return "/bin/bash".to_string();
568        }
569        if std::path::Path::new("/bin/sh").exists() {
570            return "/bin/sh".to_string();
571        }
572    }
573
574    #[cfg(windows)]
575    {
576        if let Ok(comspec) = std::env::var("COMSPEC") {
577            return comspec;
578        }
579        return "cmd.exe".to_string();
580    }
581
582    // Last resort
583    "sh".to_string()
584}