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.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        // Only add MoveCursor event if position actually changes
501        let mut events = vec![delete_event, insert_event];
502        if new_cursor_pos != new_buffer_len {
503            let move_cursor_event = Event::MoveCursor {
504                cursor_id,
505                old_position: new_buffer_len, // Where cursor is after insert
506                new_position: new_cursor_pos,
507                old_anchor: None,
508                new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
509                old_sticky_column: 0,
510                new_sticky_column: old_sticky_column,
511            };
512            events.push(move_cursor_event);
513        }
514
515        // Apply as a batch for atomic undo
516        let batch = Event::Batch {
517            events,
518            description: "On-save format".to_string(),
519        };
520        self.active_event_log_mut().append(batch.clone());
521        self.apply_event_to_active_buffer(&batch);
522
523        Ok(())
524    }
525
526    /// Trim trailing whitespace from all lines in the active buffer.
527    /// Returns Ok(true) if any changes were made, Ok(false) if buffer unchanged.
528    pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
529        let content = self.active_state().buffer.to_string().unwrap_or_default();
530
531        // Process each line and trim trailing whitespace
532        let trimmed: String = content
533            .lines()
534            .map(|line| line.trim_end())
535            .collect::<Vec<_>>()
536            .join("\n");
537
538        // Preserve original trailing newline if present
539        let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
540            format!("{}\n", trimmed)
541        } else {
542            trimmed
543        };
544
545        if trimmed == content {
546            return Ok(false);
547        }
548
549        self.replace_buffer_with_output(&trimmed)?;
550        Ok(true)
551    }
552
553    /// Ensure the buffer ends with a newline.
554    /// Returns Ok(true) if a newline was added, Ok(false) if already ends with newline.
555    pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
556        let content = self.active_state().buffer.to_string().unwrap_or_default();
557
558        // Empty buffers don't need a newline
559        if content.is_empty() {
560            return Ok(false);
561        }
562
563        if content.ends_with('\n') {
564            return Ok(false);
565        }
566
567        let with_newline = format!("{}\n", content);
568        self.replace_buffer_with_output(&with_newline)?;
569        Ok(true)
570    }
571}
572
573/// Check if a command exists in the system PATH.
574fn command_exists(command: &str) -> bool {
575    // Use 'which' on Unix or 'where' on Windows to check if command exists
576    #[cfg(unix)]
577    {
578        Command::new("which")
579            .arg(command)
580            .stdout(Stdio::null())
581            .stderr(Stdio::null())
582            .status()
583            .map(|s| s.success())
584            .unwrap_or(false)
585    }
586
587    #[cfg(windows)]
588    {
589        Command::new("where")
590            .arg(command)
591            .stdout(Stdio::null())
592            .stderr(Stdio::null())
593            .hide_window()
594            .status()
595            .map(|s| s.success())
596            .unwrap_or(false)
597    }
598
599    #[cfg(not(any(unix, windows)))]
600    {
601        // On other platforms, assume command exists and let it fail at runtime
602        true
603    }
604}
605
606/// Detect the shell to use for executing commands.
607fn detect_shell() -> String {
608    // Try SHELL environment variable first
609    if let Ok(shell) = std::env::var("SHELL") {
610        if !shell.is_empty() {
611            return shell;
612        }
613    }
614
615    // Fall back to common shells
616    #[cfg(unix)]
617    {
618        if std::path::Path::new("/bin/bash").exists() {
619            return "/bin/bash".to_string();
620        }
621        if std::path::Path::new("/bin/sh").exists() {
622            return "/bin/sh".to_string();
623        }
624    }
625
626    #[cfg(windows)]
627    {
628        if let Ok(comspec) = std::env::var("COMSPEC") {
629            return comspec;
630        }
631        return "cmd.exe".to_string();
632    }
633
634    // Last resort
635    "sh".to_string()
636}