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        // Stream buffer content to stdin on a background thread so that
212        // we can drain stdout/stderr concurrently. Without this, any
213        // formatter whose output exceeds the kernel's pipe buffer
214        // (~64KB on Linux) blocks writing stdout, which in turn blocks
215        // waiting for `wait_with_output` and produces the "Format Buffer
216        // hangs" symptom (issue #1573).
217        let stdin_writer = if formatter.stdin {
218            let content = self.active_state().buffer.to_string().unwrap_or_default();
219            child.stdin.take().map(|mut stdin| {
220                std::thread::spawn(move || -> std::io::Result<()> {
221                    stdin.write_all(content.as_bytes())?;
222                    stdin.flush()?;
223                    // Dropping `stdin` here closes the pipe so the child
224                    // sees EOF and exits once it has consumed the input.
225                    Ok(())
226                })
227            })
228        } else {
229            None
230        };
231
232        // Enforce the timeout on an auxiliary thread that kills the child
233        // by PID if it has not finished within `timeout_ms`.
234        // `wait_with_output` blocks until the child closes its
235        // stdout/stderr, so without a watchdog a truly stuck process
236        // would never time out.
237        let timeout = Duration::from_millis(formatter.timeout_ms);
238        let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
239        let child_finished = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
240        let child_pid = child.id();
241        let watchdog = {
242            let timed_out = std::sync::Arc::clone(&timed_out);
243            let child_finished = std::sync::Arc::clone(&child_finished);
244            std::thread::spawn(move || {
245                let start = std::time::Instant::now();
246                while start.elapsed() < timeout {
247                    if child_finished.load(std::sync::atomic::Ordering::SeqCst) {
248                        return;
249                    }
250                    std::thread::sleep(Duration::from_millis(50));
251                }
252                timed_out.store(true, std::sync::atomic::Ordering::SeqCst);
253                #[cfg(unix)]
254                {
255                    // SAFETY: libc::kill is async-signal-safe and we only
256                    // signal a child we spawned.
257                    unsafe {
258                        libc::kill(child_pid as i32, libc::SIGKILL);
259                    }
260                }
261                #[cfg(not(unix))]
262                {
263                    let _ = child_pid;
264                }
265            })
266        };
267
268        // Wait for the child and collect output. `wait_with_output`
269        // internally reads stdout and stderr on separate threads, so
270        // large outputs cannot deadlock the way they did with the old
271        // `try_wait`-polling implementation.
272        let output_result = child.wait_with_output();
273        child_finished.store(true, std::sync::atomic::Ordering::SeqCst);
274
275        // Join the stdin writer (it has either finished or will finish
276        // once the child closes its end of the pipe, which happens on
277        // exit or SIGKILL).
278        if let Some(handle) = stdin_writer {
279            match handle.join() {
280                Ok(Ok(())) => {}
281                // If the child exited before we finished writing, a
282                // `BrokenPipe` is expected and not a hard error; we
283                // still want the output the formatter produced.
284                Ok(Err(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
285                Ok(Err(e)) => {
286                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
287                }
288                Err(_) => {
289                    return ActionResult::Error("stdin writer thread panicked".to_string());
290                }
291            }
292        }
293        if let Err(e) = watchdog.join() {
294            tracing::warn!("formatter watchdog thread panicked: {e:?}");
295        }
296
297        if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
298            return ActionResult::Error(format!(
299                "Formatter '{}' timed out after {}ms",
300                formatter.command, formatter.timeout_ms
301            ));
302        }
303
304        let output = match output_result {
305            Ok(o) => o,
306            Err(e) => return ActionResult::Error(format!("Failed to get output: {}", e)),
307        };
308
309        if output.status.success() {
310            match String::from_utf8(output.stdout) {
311                Ok(s) => ActionResult::Success(s),
312                Err(e) => ActionResult::Error(format!("Invalid UTF-8 in output: {}", e)),
313            }
314        } else {
315            let stderr = String::from_utf8_lossy(&output.stderr);
316            let stdout = String::from_utf8_lossy(&output.stdout);
317            let error_output = if !stderr.is_empty() {
318                stderr.trim().to_string()
319            } else if !stdout.is_empty() {
320                stdout.trim().to_string()
321            } else {
322                format!("exit code {:?}", output.status.code())
323            };
324            ActionResult::Error(format!(
325                "Formatter '{}' failed: {}",
326                formatter.command, error_output
327            ))
328        }
329    }
330
331    /// Run a single on-save action (linter, etc.).
332    fn run_on_save_action(
333        &mut self,
334        action: &OnSaveAction,
335        file_path: &Path,
336        project_root: &Path,
337    ) -> ActionResult {
338        let file_path_str = file_path.display().to_string();
339
340        // Check if command exists
341        if !command_exists(&action.command) {
342            return ActionResult::CommandNotFound(action.command.clone());
343        }
344
345        // Build the command
346        let shell = detect_shell();
347
348        let mut cmd_parts = vec![action.command.clone()];
349        for arg in &action.args {
350            cmd_parts.push(arg.replace("$FILE", &file_path_str));
351        }
352
353        // If no arguments contain $FILE, append the file path
354        let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
355        if !has_file_arg && !action.stdin {
356            cmd_parts.push(file_path_str.clone());
357        }
358
359        let full_command = cmd_parts.join(" ");
360
361        // Determine working directory
362        let working_dir = action
363            .working_dir
364            .as_ref()
365            .map(|wd| {
366                let expanded = wd.replace("$FILE", &file_path_str);
367                Path::new(&expanded).to_path_buf()
368            })
369            .unwrap_or_else(|| project_root.to_path_buf());
370
371        // Set up the command
372        let mut cmd = Command::new(&shell);
373        cmd.args(["-c", &full_command])
374            .current_dir(&working_dir)
375            .stdout(Stdio::piped())
376            .stderr(Stdio::piped());
377
378        if action.stdin {
379            cmd.stdin(Stdio::piped());
380        } else {
381            cmd.stdin(Stdio::null());
382        }
383
384        // Spawn the process
385        let mut child = match cmd.spawn() {
386            Ok(c) => c,
387            Err(e) => {
388                return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
389            }
390        };
391
392        // Write buffer content to stdin if configured
393        if action.stdin {
394            let content = self.active_state().buffer.to_string().unwrap_or_default();
395            if let Some(mut stdin) = child.stdin.take() {
396                if let Err(e) = stdin.write_all(content.as_bytes()) {
397                    return ActionResult::Error(format!("Failed to write to stdin: {}", e));
398                }
399            }
400        }
401
402        // Wait for the process with timeout
403        let timeout = Duration::from_millis(action.timeout_ms);
404        let start = std::time::Instant::now();
405
406        loop {
407            match child.try_wait() {
408                Ok(Some(status)) => {
409                    let output = match child.wait_with_output() {
410                        Ok(o) => o,
411                        Err(e) => {
412                            return ActionResult::Error(format!("Failed to get output: {}", e))
413                        }
414                    };
415
416                    if status.success() {
417                        return match String::from_utf8(output.stdout) {
418                            Ok(s) => ActionResult::Success(s),
419                            Err(e) => {
420                                ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
421                            }
422                        };
423                    } else {
424                        let stderr = String::from_utf8_lossy(&output.stderr);
425                        let stdout = String::from_utf8_lossy(&output.stdout);
426                        let error_output = if !stderr.is_empty() {
427                            stderr.trim().to_string()
428                        } else if !stdout.is_empty() {
429                            stdout.trim().to_string()
430                        } else {
431                            format!("exit code {:?}", status.code())
432                        };
433                        return ActionResult::Error(format!(
434                            "On-save action '{}' failed: {}",
435                            action.command, error_output
436                        ));
437                    }
438                }
439                Ok(None) => {
440                    if start.elapsed() > timeout {
441                        // Best-effort kill of timed-out process.
442                        #[allow(clippy::let_underscore_must_use)]
443                        let _ = child.kill();
444                        return ActionResult::Error(format!(
445                            "On-save action '{}' timed out after {}ms",
446                            action.command, action.timeout_ms
447                        ));
448                    }
449                    std::thread::sleep(Duration::from_millis(10));
450                }
451                Err(e) => {
452                    return ActionResult::Error(format!(
453                        "Failed to wait for '{}': {}",
454                        action.command, e
455                    ));
456                }
457            }
458        }
459    }
460
461    /// Replace the active buffer's content with new output.
462    fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
463        let cursor_id = self.active_cursors().primary_id();
464
465        // Get current buffer content
466        let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
467
468        // Only replace if content is different
469        if buffer_content == output {
470            return Ok(());
471        }
472
473        let buffer_len = buffer_content.len();
474
475        // Capture cursor position and selection state before replacement
476        let old_cursor_pos = self.active_cursors().primary().position;
477        let old_anchor = self.active_cursors().primary().anchor;
478        let old_sticky_column = self.active_cursors().primary().sticky_column;
479
480        // Delete all content and insert new
481        let delete_event = Event::Delete {
482            range: 0..buffer_len,
483            deleted_text: buffer_content,
484            cursor_id,
485        };
486        let insert_event = Event::Insert {
487            position: 0,
488            text: output.to_string(),
489            cursor_id,
490        };
491
492        // After delete+insert, cursor will be at output.len()
493        // Restore cursor to original position (or clamp to new buffer length)
494        let new_buffer_len = output.len();
495        let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
496
497        // Only add MoveCursor event if position actually changes
498        let mut events = vec![delete_event, insert_event];
499        if new_cursor_pos != new_buffer_len {
500            let move_cursor_event = Event::MoveCursor {
501                cursor_id,
502                old_position: new_buffer_len, // Where cursor is after insert
503                new_position: new_cursor_pos,
504                old_anchor: None,
505                new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
506                old_sticky_column: 0,
507                new_sticky_column: old_sticky_column,
508            };
509            events.push(move_cursor_event);
510        }
511
512        // Apply as a batch for atomic undo
513        let batch = Event::Batch {
514            events,
515            description: "On-save format".to_string(),
516        };
517        self.active_event_log_mut().append(batch.clone());
518        self.apply_event_to_active_buffer(&batch);
519
520        Ok(())
521    }
522
523    /// Trim trailing whitespace from all lines in the active buffer.
524    /// Returns Ok(true) if any changes were made, Ok(false) if buffer unchanged.
525    pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
526        let content = self.active_state().buffer.to_string().unwrap_or_default();
527
528        // Process each line and trim trailing whitespace
529        let trimmed: String = content
530            .lines()
531            .map(|line| line.trim_end())
532            .collect::<Vec<_>>()
533            .join("\n");
534
535        // Preserve original trailing newline if present
536        let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
537            format!("{}\n", trimmed)
538        } else {
539            trimmed
540        };
541
542        if trimmed == content {
543            return Ok(false);
544        }
545
546        self.replace_buffer_with_output(&trimmed)?;
547        Ok(true)
548    }
549
550    /// Ensure the buffer ends with a newline.
551    /// Returns Ok(true) if a newline was added, Ok(false) if already ends with newline.
552    pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
553        let content = self.active_state().buffer.to_string().unwrap_or_default();
554
555        // Empty buffers don't need a newline
556        if content.is_empty() {
557            return Ok(false);
558        }
559
560        if content.ends_with('\n') {
561            return Ok(false);
562        }
563
564        let with_newline = format!("{}\n", content);
565        self.replace_buffer_with_output(&with_newline)?;
566        Ok(true)
567    }
568}
569
570/// Check if a command exists in the system PATH.
571fn command_exists(command: &str) -> bool {
572    // Use 'which' on Unix or 'where' on Windows to check if command exists
573    #[cfg(unix)]
574    {
575        Command::new("which")
576            .arg(command)
577            .stdout(Stdio::null())
578            .stderr(Stdio::null())
579            .status()
580            .map(|s| s.success())
581            .unwrap_or(false)
582    }
583
584    #[cfg(windows)]
585    {
586        Command::new("where")
587            .arg(command)
588            .stdout(Stdio::null())
589            .stderr(Stdio::null())
590            .status()
591            .map(|s| s.success())
592            .unwrap_or(false)
593    }
594
595    #[cfg(not(any(unix, windows)))]
596    {
597        // On other platforms, assume command exists and let it fail at runtime
598        true
599    }
600}
601
602/// Detect the shell to use for executing commands.
603fn detect_shell() -> String {
604    // Try SHELL environment variable first
605    if let Ok(shell) = std::env::var("SHELL") {
606        if !shell.is_empty() {
607            return shell;
608        }
609    }
610
611    // Fall back to common shells
612    #[cfg(unix)]
613    {
614        if std::path::Path::new("/bin/bash").exists() {
615            return "/bin/bash".to_string();
616        }
617        if std::path::Path::new("/bin/sh").exists() {
618            return "/bin/sh".to_string();
619        }
620    }
621
622    #[cfg(windows)]
623    {
624        if let Ok(comspec) = std::env::var("COMSPEC") {
625            return comspec;
626        }
627        return "cmd.exe".to_string();
628    }
629
630    // Last resort
631    "sh".to_string()
632}