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::process_hidden::HideWindow;
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 && self.trim_trailing_whitespace()? {
42            ran_any_action = true;
43        }
44
45        if self.config.editor.ensure_final_newline_on_save && self.ensure_final_newline()? {
46            ran_any_action = true;
47        }
48
49        // If whitespace cleanup made changes, re-save
50        if ran_any_action {
51            if let Err(e) = self.active_state_mut().buffer.save() {
52                return Err(format!("Failed to re-save after whitespace cleanup: {}", e));
53            }
54            self.active_event_log_mut().mark_saved();
55        }
56
57        // Get language from buffer's stored state
58        let language = self.active_state().language.clone();
59
60        let lang_config = match self.config.languages.get(&language) {
61            Some(lc) => lc.clone(),
62            None => return Ok(ran_any_action),
63        };
64
65        // Run formatter if format_on_save is enabled
66        if lang_config.format_on_save {
67            if let Some(ref formatter) = lang_config.formatter {
68                match self.run_formatter(formatter, &path) {
69                    ActionResult::Success(output) => {
70                        self.replace_buffer_with_output(&output)?;
71                        // Re-save after formatting
72                        if let Err(e) = self.active_state_mut().buffer.save() {
73                            return Err(format!("Failed to re-save after format: {}", e));
74                        }
75                        self.active_event_log_mut().mark_saved();
76                        ran_any_action = true;
77                    }
78                    ActionResult::CommandNotFound(cmd) => {
79                        self.active_window_mut().status_message = Some(format!(
80                            "Formatter '{}' not found (install it for auto-formatting)",
81                            cmd
82                        ));
83                    }
84                    ActionResult::Error(e) => {
85                        return Err(e);
86                    }
87                }
88            }
89        }
90
91        // Run on_save actions (linters, etc.)
92        let project_root = std::env::current_dir()
93            .unwrap_or_else(|_| path.parent().unwrap_or(Path::new(".")).to_path_buf());
94
95        for action in &lang_config.on_save {
96            if !action.enabled {
97                continue;
98            }
99
100            match self.run_on_save_action(action, &path, &project_root) {
101                ActionResult::Success(_) => {
102                    ran_any_action = true;
103                }
104                ActionResult::CommandNotFound(_) => {
105                    // Skip missing optional commands silently
106                }
107                ActionResult::Error(e) => {
108                    return Err(e);
109                }
110            }
111        }
112
113        Ok(ran_any_action)
114    }
115
116    /// Format the current buffer using the configured formatter.
117    /// Returns Ok(()) if formatting succeeded, or Err with an error message.
118    pub fn format_buffer(&mut self) -> Result<(), String> {
119        let path = match self.active_state().buffer.file_path() {
120            Some(p) => p.to_path_buf(),
121            None => {
122                return Err(
123                    "Cannot format unsaved buffer (save first to detect language)".to_string(),
124                )
125            }
126        };
127
128        // Get language from buffer's stored state
129        let language = self.active_state().language.clone();
130
131        // Get formatter for this language
132        let formatter = self
133            .config
134            .languages
135            .get(&language)
136            .and_then(|lc| lc.formatter.clone());
137
138        let formatter = match formatter {
139            Some(f) => f,
140            None => {
141                // No external formatter — try LSP formatting
142                self.request_formatting();
143                return Ok(());
144            }
145        };
146
147        match self.run_formatter(&formatter, &path) {
148            ActionResult::Success(output) => {
149                self.replace_buffer_with_output(&output)?;
150                self.set_status_message(
151                    t!(
152                        "format.formatted_with",
153                        formatter = formatter.command.clone()
154                    )
155                    .to_string(),
156                );
157                Ok(())
158            }
159            ActionResult::CommandNotFound(cmd) => Err(format!("Formatter '{}' not found", cmd)),
160            ActionResult::Error(e) => Err(e),
161        }
162    }
163
164    /// Run a formatter on the current buffer content.
165    fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
166        let file_path_str = file_path.display().to_string();
167
168        // Check if command exists
169        if !command_exists(&formatter.command) {
170            return ActionResult::CommandNotFound(formatter.command.clone());
171        }
172
173        // Build the command
174        let shell = detect_shell();
175
176        // Build the full command string with arguments
177        let mut cmd_parts = vec![formatter.command.clone()];
178        for arg in &formatter.args {
179            cmd_parts.push(arg.replace("$FILE", &file_path_str));
180        }
181
182        let full_command = cmd_parts.join(" ");
183
184        // Get project root for working directory
185        let project_root = std::env::current_dir()
186            .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());
187
188        // Set up the command
189        let mut cmd = Command::new(&shell);
190        cmd.args(["-c", &full_command])
191            .current_dir(&project_root)
192            .stdout(Stdio::piped())
193            .stderr(Stdio::piped())
194            .hide_window();
195
196        if formatter.stdin {
197            cmd.stdin(Stdio::piped());
198        } else {
199            cmd.stdin(Stdio::null());
200        }
201
202        // Spawn the process
203        let mut child = match cmd.spawn() {
204            Ok(c) => c,
205            Err(e) => {
206                return ActionResult::Error(format!(
207                    "Failed to run '{}': {}",
208                    formatter.command, e
209                ));
210            }
211        };
212
213        // Stream buffer content to stdin on a background thread so that
214        // we can drain stdout/stderr concurrently. Without this, any
215        // formatter whose output exceeds the kernel's pipe buffer
216        // (~64KB on Linux) blocks writing stdout, which in turn blocks
217        // waiting for `wait_with_output` and produces the "Format Buffer
218        // hangs" symptom (issue #1573).
219        let stdin_writer = if formatter.stdin {
220            let content = self.active_state().buffer.to_string().unwrap_or_default();
221            child.stdin.take().map(|mut stdin| {
222                std::thread::spawn(move || -> std::io::Result<()> {
223                    stdin.write_all(content.as_bytes())?;
224                    stdin.flush()?;
225                    // Dropping `stdin` here closes the pipe so the child
226                    // sees EOF and exits once it has consumed the input.
227                    Ok(())
228                })
229            })
230        } else {
231            None
232        };
233
234        // Enforce the timeout on an auxiliary thread that kills the child
235        // by PID if it has not finished within `timeout_ms`.
236        // `wait_with_output` blocks until the child closes its
237        // stdout/stderr, so without a watchdog a truly stuck process
238        // would never time out.
239        let timeout = Duration::from_millis(formatter.timeout_ms);
240        let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
241        let child_finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
242        let child_pid = child.id();
243        let watchdog = {
244            let timed_out = std::sync::Arc::clone(&timed_out);
245            let child_finished = std::sync::Arc::clone(&child_finished);
246            std::thread::spawn(move || {
247                let start = std::time::Instant::now();
248                while start.elapsed() < timeout {
249                    if child_finished.load(std::sync::atomic::Ordering::SeqCst) {
250                        return;
251                    }
252                    std::thread::sleep(Duration::from_millis(50));
253                }
254                timed_out.store(true, std::sync::atomic::Ordering::SeqCst);
255                #[cfg(unix)]
256                {
257                    // SAFETY: libc::kill is async-signal-safe and we only
258                    // signal a child we spawned.
259                    unsafe {
260                        libc::kill(child_pid as i32, libc::SIGKILL);
261                    }
262                }
263                #[cfg(not(unix))]
264                {
265                    let _ = child_pid;
266                }
267            })
268        };
269
270        // Wait for the child and collect output. `wait_with_output`
271        // internally reads stdout and stderr on separate threads, so
272        // large outputs cannot deadlock the way they did with the old
273        // `try_wait`-polling implementation.
274        let output_result = child.wait_with_output();
275        child_finished.store(true, std::sync::atomic::Ordering::SeqCst);
276
277        // Join the stdin writer (it has either finished or will finish
278        // once the child closes its end of the pipe, which happens on
279        // exit or SIGKILL).
280        if let Some(handle) = stdin_writer {
281            match handle.join() {
282                Ok(Ok(())) => {}
283                // If the child exited before we finished writing, a
284                // `BrokenPipe` is expected and not a hard error; we
285                // still want the output the formatter produced.
286                Ok(Err(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
287                Ok(Err(e)) => {
288                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
289                }
290                Err(_) => {
291                    return ActionResult::Error("stdin writer thread panicked".to_string());
292                }
293            }
294        }
295        if let Err(e) = watchdog.join() {
296            tracing::warn!("formatter watchdog thread panicked: {e:?}");
297        }
298
299        if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
300            return ActionResult::Error(format!(
301                "Formatter '{}' timed out after {}ms",
302                formatter.command, formatter.timeout_ms
303            ));
304        }
305
306        let output = match output_result {
307            Ok(o) => o,
308            Err(e) => return ActionResult::Error(format!("Failed to get output: {}", e)),
309        };
310
311        if output.status.success() {
312            match String::from_utf8(output.stdout) {
313                Ok(s) => ActionResult::Success(s),
314                Err(e) => ActionResult::Error(format!("Invalid UTF-8 in output: {}", e)),
315            }
316        } else {
317            let stderr = String::from_utf8_lossy(&output.stderr);
318            let stdout = String::from_utf8_lossy(&output.stdout);
319            let error_output = if !stderr.is_empty() {
320                stderr.trim().to_string()
321            } else if !stdout.is_empty() {
322                stdout.trim().to_string()
323            } else {
324                format!("exit code {:?}", output.status.code())
325            };
326            ActionResult::Error(format!(
327                "Formatter '{}' failed: {}",
328                formatter.command, error_output
329            ))
330        }
331    }
332
333    /// Run a single on-save action (linter, etc.).
334    fn run_on_save_action(
335        &mut self,
336        action: &OnSaveAction,
337        file_path: &Path,
338        project_root: &Path,
339    ) -> ActionResult {
340        let file_path_str = file_path.display().to_string();
341
342        // Check if command exists
343        if !command_exists(&action.command) {
344            return ActionResult::CommandNotFound(action.command.clone());
345        }
346
347        // Build the command
348        let shell = detect_shell();
349
350        let mut cmd_parts = vec![action.command.clone()];
351        for arg in &action.args {
352            cmd_parts.push(arg.replace("$FILE", &file_path_str));
353        }
354
355        // If no arguments contain $FILE, append the file path
356        let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
357        if !has_file_arg && !action.stdin {
358            cmd_parts.push(file_path_str.clone());
359        }
360
361        let full_command = cmd_parts.join(" ");
362
363        // Determine working directory
364        let working_dir = action
365            .working_dir
366            .as_ref()
367            .map(|wd| {
368                let expanded = wd.replace("$FILE", &file_path_str);
369                Path::new(&expanded).to_path_buf()
370            })
371            .unwrap_or_else(|| project_root.to_path_buf());
372
373        // Set up the command
374        let mut cmd = Command::new(&shell);
375        cmd.args(["-c", &full_command])
376            .current_dir(&working_dir)
377            .stdout(Stdio::piped())
378            .stderr(Stdio::piped())
379            .hide_window();
380
381        if action.stdin {
382            cmd.stdin(Stdio::piped());
383        } else {
384            cmd.stdin(Stdio::null());
385        }
386
387        // Spawn the process
388        let mut child = match cmd.spawn() {
389            Ok(c) => c,
390            Err(e) => {
391                return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
392            }
393        };
394
395        // Write buffer content to stdin if configured
396        if action.stdin {
397            let content = self.active_state().buffer.to_string().unwrap_or_default();
398            if let Some(mut stdin) = child.stdin.take() {
399                if let Err(e) = stdin.write_all(content.as_bytes()) {
400                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
401                }
402            }
403        }
404
405        // Wait for the process with timeout
406        let timeout = Duration::from_millis(action.timeout_ms);
407        let start = std::time::Instant::now();
408
409        loop {
410            match child.try_wait() {
411                Ok(Some(status)) => {
412                    let output = match child.wait_with_output() {
413                        Ok(o) => o,
414                        Err(e) => {
415                            return ActionResult::Error(format!("Failed to get output: {}", e))
416                        }
417                    };
418
419                    if status.success() {
420                        return match String::from_utf8(output.stdout) {
421                            Ok(s) => ActionResult::Success(s),
422                            Err(e) => {
423                                ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
424                            }
425                        };
426                    } else {
427                        let stderr = String::from_utf8_lossy(&output.stderr);
428                        let stdout = String::from_utf8_lossy(&output.stdout);
429                        let error_output = if !stderr.is_empty() {
430                            stderr.trim().to_string()
431                        } else if !stdout.is_empty() {
432                            stdout.trim().to_string()
433                        } else {
434                            format!("exit code {:?}", status.code())
435                        };
436                        return ActionResult::Error(format!(
437                            "On-save action '{}' failed: {}",
438                            action.command, error_output
439                        ));
440                    }
441                }
442                Ok(None) => {
443                    if start.elapsed() > timeout {
444                        // Best-effort kill of timed-out process.
445                        #[allow(clippy::let_underscore_must_use)]
446                        let _ = child.kill();
447                        return ActionResult::Error(format!(
448                            "On-save action '{}' timed out after {}ms",
449                            action.command, action.timeout_ms
450                        ));
451                    }
452                    std::thread::sleep(Duration::from_millis(10));
453                }
454                Err(e) => {
455                    return ActionResult::Error(format!(
456                        "Failed to wait for '{}': {}",
457                        action.command, e
458                    ));
459                }
460            }
461        }
462    }
463
464    /// Replace the active buffer's content with new output.
465    fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
466        let cursor_id = self.active_cursors().primary_id();
467
468        // Get current buffer content
469        let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
470
471        // Only replace if content is different
472        if buffer_content == output {
473            return Ok(());
474        }
475
476        let buffer_len = buffer_content.len();
477
478        // Capture cursor position and selection state before replacement
479        let old_cursor_pos = self.active_cursors().primary().position;
480        let old_anchor = self.active_cursors().primary().anchor;
481        let old_sticky_column = self.active_cursors().primary().sticky_column;
482
483        // Delete all content and insert new
484        let delete_event = Event::Delete {
485            range: 0..buffer_len,
486            deleted_text: buffer_content,
487            cursor_id,
488        };
489        let insert_event = Event::Insert {
490            position: 0,
491            text: output.to_string(),
492            cursor_id,
493        };
494
495        // After delete+insert, cursor will be at output.len()
496        // Restore cursor to original position (or clamp to new buffer length)
497        let new_buffer_len = output.len();
498        let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
499
500        // Leading cursor-restore event. Applied forward this is a no-op (the
501        // cursor is already at `old_cursor_pos`), but a `Batch` is undone by
502        // applying the *inverse* of each event in reverse order, so this
503        // event's inverse runs last on undo and pins the cursor/selection back
504        // to exactly where it was before the rewrite. Without it, the last
505        // inverse applied on undo is the re-`Insert` of the original text,
506        // which leaves the cursor at the end of the buffer and scrolls the
507        // view to the bottom of the file (issue #2027).
508        let restore_cursor_event = Event::MoveCursor {
509            cursor_id,
510            old_position: old_cursor_pos,
511            new_position: old_cursor_pos,
512            old_anchor,
513            new_anchor: old_anchor,
514            old_sticky_column,
515            new_sticky_column: old_sticky_column,
516        };
517
518        // Only add MoveCursor event if position actually changes
519        let mut events = vec![restore_cursor_event, delete_event, insert_event];
520        if new_cursor_pos != new_buffer_len {
521            let move_cursor_event = Event::MoveCursor {
522                cursor_id,
523                old_position: new_buffer_len, // Where cursor is after insert
524                new_position: new_cursor_pos,
525                old_anchor: None,
526                new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
527                old_sticky_column: 0,
528                new_sticky_column: old_sticky_column,
529            };
530            events.push(move_cursor_event);
531        }
532
533        // Apply as a batch for atomic undo
534        let batch = Event::Batch {
535            events,
536            description: "On-save format".to_string(),
537        };
538        self.active_event_log_mut().append(batch.clone());
539        self.apply_event_to_active_buffer(&batch);
540
541        Ok(())
542    }
543
544    /// Trim trailing whitespace from all lines in the active buffer.
545    /// Returns Ok(true) if any changes were made, Ok(false) if buffer unchanged.
546    pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
547        let content = self.active_state().buffer.to_string().unwrap_or_default();
548
549        // Process each line and trim trailing whitespace
550        let trimmed: String = content
551            .lines()
552            .map(|line| line.trim_end())
553            .collect::<Vec<_>>()
554            .join("\n");
555
556        // Preserve original trailing newline if present
557        let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
558            format!("{}\n", trimmed)
559        } else {
560            trimmed
561        };
562
563        if trimmed == content {
564            return Ok(false);
565        }
566
567        self.replace_buffer_with_output(&trimmed)?;
568        Ok(true)
569    }
570
571    /// Ensure the buffer ends with a newline.
572    /// Returns Ok(true) if a newline was added, Ok(false) if already ends with newline.
573    pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
574        let content = self.active_state().buffer.to_string().unwrap_or_default();
575
576        // Empty buffers don't need a newline
577        if content.is_empty() {
578            return Ok(false);
579        }
580
581        if content.ends_with('\n') {
582            return Ok(false);
583        }
584
585        let with_newline = format!("{}\n", content);
586        self.replace_buffer_with_output(&with_newline)?;
587        Ok(true)
588    }
589}
590
591/// Check if a command exists in the system PATH.
592fn command_exists(command: &str) -> bool {
593    // Use 'which' on Unix or 'where' on Windows to check if command exists
594    #[cfg(unix)]
595    {
596        Command::new("which")
597            .arg(command)
598            .stdout(Stdio::null())
599            .stderr(Stdio::null())
600            .status()
601            .map(|s| s.success())
602            .unwrap_or(false)
603    }
604
605    #[cfg(windows)]
606    {
607        Command::new("where")
608            .arg(command)
609            .stdout(Stdio::null())
610            .stderr(Stdio::null())
611            .hide_window()
612            .status()
613            .map(|s| s.success())
614            .unwrap_or(false)
615    }
616
617    #[cfg(not(any(unix, windows)))]
618    {
619        // On other platforms, assume command exists and let it fail at runtime
620        true
621    }
622}
623
624/// Detect the shell to use for executing commands.
625fn detect_shell() -> String {
626    // Try SHELL environment variable first
627    if let Ok(shell) = std::env::var("SHELL") {
628        if !shell.is_empty() {
629            return shell;
630        }
631    }
632
633    // Fall back to common shells
634    #[cfg(unix)]
635    {
636        if std::path::Path::new("/bin/bash").exists() {
637            return "/bin/bash".to_string();
638        }
639        if std::path::Path::new("/bin/sh").exists() {
640            return "/bin/sh".to_string();
641        }
642    }
643
644    #[cfg(windows)]
645    {
646        if let Ok(comspec) = std::env::var("COMSPEC") {
647            return comspec;
648        }
649        return "cmd.exe".to_string();
650    }
651
652    // Last resort
653    "sh".to_string()
654}