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 if formatter.stdin {
213 let content = self.active_state().buffer.to_string().unwrap_or_default();
214 if let Some(mut stdin) = child.stdin.take() {
215 if let Err(e) = stdin.write_all(content.as_bytes()) {
216 return ActionResult::Error(format!("Failed to write to stdin: {}", e));
217 }
218 }
219 }
220
221 let timeout = Duration::from_millis(formatter.timeout_ms);
223 let start = std::time::Instant::now();
224
225 loop {
226 match child.try_wait() {
227 Ok(Some(status)) => {
228 let output = match child.wait_with_output() {
229 Ok(o) => o,
230 Err(e) => {
231 return ActionResult::Error(format!("Failed to get output: {}", e))
232 }
233 };
234
235 if status.success() {
236 return match String::from_utf8(output.stdout) {
237 Ok(s) => ActionResult::Success(s),
238 Err(e) => {
239 ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
240 }
241 };
242 } else {
243 let stderr = String::from_utf8_lossy(&output.stderr);
244 let stdout = String::from_utf8_lossy(&output.stdout);
245 let error_output = if !stderr.is_empty() {
246 stderr.trim().to_string()
247 } else if !stdout.is_empty() {
248 stdout.trim().to_string()
249 } else {
250 format!("exit code {:?}", status.code())
251 };
252 return ActionResult::Error(format!(
253 "Formatter '{}' failed: {}",
254 formatter.command, error_output
255 ));
256 }
257 }
258 Ok(None) => {
259 if start.elapsed() > timeout {
260 #[allow(clippy::let_underscore_must_use)]
262 let _ = child.kill();
263 return ActionResult::Error(format!(
264 "Formatter '{}' timed out after {}ms",
265 formatter.command, formatter.timeout_ms
266 ));
267 }
268 std::thread::sleep(Duration::from_millis(10));
269 }
270 Err(e) => {
271 return ActionResult::Error(format!(
272 "Failed to wait for '{}': {}",
273 formatter.command, e
274 ));
275 }
276 }
277 }
278 }
279
280 fn run_on_save_action(
282 &mut self,
283 action: &OnSaveAction,
284 file_path: &Path,
285 project_root: &Path,
286 ) -> ActionResult {
287 let file_path_str = file_path.display().to_string();
288
289 if !command_exists(&action.command) {
291 return ActionResult::CommandNotFound(action.command.clone());
292 }
293
294 let shell = detect_shell();
296
297 let mut cmd_parts = vec![action.command.clone()];
298 for arg in &action.args {
299 cmd_parts.push(arg.replace("$FILE", &file_path_str));
300 }
301
302 let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
304 if !has_file_arg && !action.stdin {
305 cmd_parts.push(file_path_str.clone());
306 }
307
308 let full_command = cmd_parts.join(" ");
309
310 let working_dir = action
312 .working_dir
313 .as_ref()
314 .map(|wd| {
315 let expanded = wd.replace("$FILE", &file_path_str);
316 Path::new(&expanded).to_path_buf()
317 })
318 .unwrap_or_else(|| project_root.to_path_buf());
319
320 let mut cmd = Command::new(&shell);
322 cmd.args(["-c", &full_command])
323 .current_dir(&working_dir)
324 .stdout(Stdio::piped())
325 .stderr(Stdio::piped());
326
327 if action.stdin {
328 cmd.stdin(Stdio::piped());
329 } else {
330 cmd.stdin(Stdio::null());
331 }
332
333 let mut child = match cmd.spawn() {
335 Ok(c) => c,
336 Err(e) => {
337 return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
338 }
339 };
340
341 if action.stdin {
343 let content = self.active_state().buffer.to_string().unwrap_or_default();
344 if let Some(mut stdin) = child.stdin.take() {
345 if let Err(e) = stdin.write_all(content.as_bytes()) {
346 return ActionResult::Error(format!("Failed to write to stdin: {}", e));
347 }
348 }
349 }
350
351 let timeout = Duration::from_millis(action.timeout_ms);
353 let start = std::time::Instant::now();
354
355 loop {
356 match child.try_wait() {
357 Ok(Some(status)) => {
358 let output = match child.wait_with_output() {
359 Ok(o) => o,
360 Err(e) => {
361 return ActionResult::Error(format!("Failed to get output: {}", e))
362 }
363 };
364
365 if status.success() {
366 return match String::from_utf8(output.stdout) {
367 Ok(s) => ActionResult::Success(s),
368 Err(e) => {
369 ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
370 }
371 };
372 } else {
373 let stderr = String::from_utf8_lossy(&output.stderr);
374 let stdout = String::from_utf8_lossy(&output.stdout);
375 let error_output = if !stderr.is_empty() {
376 stderr.trim().to_string()
377 } else if !stdout.is_empty() {
378 stdout.trim().to_string()
379 } else {
380 format!("exit code {:?}", status.code())
381 };
382 return ActionResult::Error(format!(
383 "On-save action '{}' failed: {}",
384 action.command, error_output
385 ));
386 }
387 }
388 Ok(None) => {
389 if start.elapsed() > timeout {
390 #[allow(clippy::let_underscore_must_use)]
392 let _ = child.kill();
393 return ActionResult::Error(format!(
394 "On-save action '{}' timed out after {}ms",
395 action.command, action.timeout_ms
396 ));
397 }
398 std::thread::sleep(Duration::from_millis(10));
399 }
400 Err(e) => {
401 return ActionResult::Error(format!(
402 "Failed to wait for '{}': {}",
403 action.command, e
404 ));
405 }
406 }
407 }
408 }
409
410 fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
412 let cursor_id = self.active_cursors().primary_id();
413
414 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
416
417 if buffer_content == output {
419 return Ok(());
420 }
421
422 let buffer_len = buffer_content.len();
423
424 let old_cursor_pos = self.active_cursors().primary().position;
426 let old_anchor = self.active_cursors().primary().anchor;
427 let old_sticky_column = self.active_cursors().primary().sticky_column;
428
429 let delete_event = Event::Delete {
431 range: 0..buffer_len,
432 deleted_text: buffer_content,
433 cursor_id,
434 };
435 let insert_event = Event::Insert {
436 position: 0,
437 text: output.to_string(),
438 cursor_id,
439 };
440
441 let new_buffer_len = output.len();
444 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
445
446 let mut events = vec![delete_event, insert_event];
448 if new_cursor_pos != new_buffer_len {
449 let move_cursor_event = Event::MoveCursor {
450 cursor_id,
451 old_position: new_buffer_len, new_position: new_cursor_pos,
453 old_anchor: None,
454 new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
455 old_sticky_column: 0,
456 new_sticky_column: old_sticky_column,
457 };
458 events.push(move_cursor_event);
459 }
460
461 let batch = Event::Batch {
463 events,
464 description: "On-save format".to_string(),
465 };
466 self.active_event_log_mut().append(batch.clone());
467 self.apply_event_to_active_buffer(&batch);
468
469 Ok(())
470 }
471
472 pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
475 let content = self.active_state().buffer.to_string().unwrap_or_default();
476
477 let trimmed: String = content
479 .lines()
480 .map(|line| line.trim_end())
481 .collect::<Vec<_>>()
482 .join("\n");
483
484 let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
486 format!("{}\n", trimmed)
487 } else {
488 trimmed
489 };
490
491 if trimmed == content {
492 return Ok(false);
493 }
494
495 self.replace_buffer_with_output(&trimmed)?;
496 Ok(true)
497 }
498
499 pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
502 let content = self.active_state().buffer.to_string().unwrap_or_default();
503
504 if content.is_empty() {
506 return Ok(false);
507 }
508
509 if content.ends_with('\n') {
510 return Ok(false);
511 }
512
513 let with_newline = format!("{}\n", content);
514 self.replace_buffer_with_output(&with_newline)?;
515 Ok(true)
516 }
517}
518
519fn command_exists(command: &str) -> bool {
521 #[cfg(unix)]
523 {
524 Command::new("which")
525 .arg(command)
526 .stdout(Stdio::null())
527 .stderr(Stdio::null())
528 .status()
529 .map(|s| s.success())
530 .unwrap_or(false)
531 }
532
533 #[cfg(windows)]
534 {
535 Command::new("where")
536 .arg(command)
537 .stdout(Stdio::null())
538 .stderr(Stdio::null())
539 .status()
540 .map(|s| s.success())
541 .unwrap_or(false)
542 }
543
544 #[cfg(not(any(unix, windows)))]
545 {
546 true
548 }
549}
550
551fn detect_shell() -> String {
553 if let Ok(shell) = std::env::var("SHELL") {
555 if !shell.is_empty() {
556 return shell;
557 }
558 }
559
560 #[cfg(unix)]
562 {
563 if std::path::Path::new("/bin/bash").exists() {
564 return "/bin/bash".to_string();
565 }
566 if std::path::Path::new("/bin/sh").exists() {
567 return "/bin/sh".to_string();
568 }
569 }
570
571 #[cfg(windows)]
572 {
573 if let Ok(comspec) = std::env::var("COMSPEC") {
574 return comspec;
575 }
576 return "cmd.exe".to_string();
577 }
578
579 "sh".to_string()
581}