1use 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
17enum ActionResult {
19 Success(String),
21 CommandNotFound(String),
23 Error(String),
25}
26
27impl Editor {
28 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 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 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 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 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 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 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 }
107 ActionResult::Error(e) => {
108 return Err(e);
109 }
110 }
111 }
112
113 Ok(ran_any_action)
114 }
115
116 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 let language = self.active_state().language.clone();
130
131 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 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 fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
166 let file_path_str = file_path.display().to_string();
167
168 if !command_exists(&formatter.command) {
170 return ActionResult::CommandNotFound(formatter.command.clone());
171 }
172
173 let shell = detect_shell();
175
176 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 let project_root = std::env::current_dir()
186 .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());
187
188 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 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 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 Ok(())
228 })
229 })
230 } else {
231 None
232 };
233
234 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 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 let output_result = child.wait_with_output();
275 child_finished.store(true, std::sync::atomic::Ordering::SeqCst);
276
277 if let Some(handle) = stdin_writer {
281 match handle.join() {
282 Ok(Ok(())) => {}
283 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 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 if !command_exists(&action.command) {
344 return ActionResult::CommandNotFound(action.command.clone());
345 }
346
347 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 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 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 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 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 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 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 #[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 fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
466 let cursor_id = self.active_cursors().primary_id();
467
468 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
470
471 if buffer_content == output {
473 return Ok(());
474 }
475
476 let buffer_len = buffer_content.len();
477
478 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 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 let new_buffer_len = output.len();
498 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
499
500 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 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, 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 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 pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
547 let content = self.active_state().buffer.to_string().unwrap_or_default();
548
549 let trimmed: String = content
551 .lines()
552 .map(|line| line.trim_end())
553 .collect::<Vec<_>>()
554 .join("\n");
555
556 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 pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
574 let content = self.active_state().buffer.to_string().unwrap_or_default();
575
576 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
591fn command_exists(command: &str) -> bool {
593 #[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 true
621 }
622}
623
624fn detect_shell() -> String {
626 if let Ok(shell) = std::env::var("SHELL") {
628 if !shell.is_empty() {
629 return shell;
630 }
631 }
632
633 #[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 "sh".to_string()
654}