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