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