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.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 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, 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 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 pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
529 let content = self.active_state().buffer.to_string().unwrap_or_default();
530
531 let trimmed: String = content
533 .lines()
534 .map(|line| line.trim_end())
535 .collect::<Vec<_>>()
536 .join("\n");
537
538 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 pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
556 let content = self.active_state().buffer.to_string().unwrap_or_default();
557
558 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
573fn command_exists(command: &str) -> bool {
575 #[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 true
603 }
604}
605
606fn detect_shell() -> String {
608 if let Ok(shell) = std::env::var("SHELL") {
610 if !shell.is_empty() {
611 return shell;
612 }
613 }
614
615 #[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 "sh".to_string()
636}