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 rust_i18n::t;
15
16enum ActionResult {
18 Success(String),
20 CommandNotFound(String),
22 Error(String),
24}
25
26impl Editor {
27 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 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 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 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 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 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 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 }
106 ActionResult::Error(e) => {
107 return Err(e);
108 }
109 }
110 }
111
112 Ok(ran_any_action)
113 }
114
115 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 let language = self.active_state().language.clone();
129
130 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 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 fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
165 let file_path_str = file_path.display().to_string();
166
167 if !command_exists(&formatter.command) {
169 return ActionResult::CommandNotFound(formatter.command.clone());
170 }
171
172 let shell = detect_shell();
174
175 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 let project_root = std::env::current_dir()
185 .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());
186
187 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 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 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 Ok(())
226 })
227 })
228 } else {
229 None
230 };
231
232 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 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 let output_result = child.wait_with_output();
273 child_finished.store(true, std::sync::atomic::Ordering::SeqCst);
274
275 if let Some(handle) = stdin_writer {
279 match handle.join() {
280 Ok(Ok(())) => {}
281 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 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 if !command_exists(&action.command) {
342 return ActionResult::CommandNotFound(action.command.clone());
343 }
344
345 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 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 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 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 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 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 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 #[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 fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
463 let cursor_id = self.active_cursors().primary_id();
464
465 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
467
468 if buffer_content == output {
470 return Ok(());
471 }
472
473 let buffer_len = buffer_content.len();
474
475 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 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 let new_buffer_len = output.len();
495 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
496
497 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, 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 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 pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
526 let content = self.active_state().buffer.to_string().unwrap_or_default();
527
528 let trimmed: String = content
530 .lines()
531 .map(|line| line.trim_end())
532 .collect::<Vec<_>>()
533 .join("\n");
534
535 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 pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
553 let content = self.active_state().buffer.to_string().unwrap_or_default();
554
555 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
570fn command_exists(command: &str) -> bool {
572 #[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 true
599 }
600}
601
602fn detect_shell() -> String {
604 if let Ok(shell) = std::env::var("SHELL") {
606 if !shell.is_empty() {
607 return shell;
608 }
609 }
610
611 #[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 "sh".to_string()
632}