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 => return Err(format!("No formatter configured for {}", language)),
140 };
141
142 match self.run_formatter(&formatter, &path) {
143 ActionResult::Success(output) => {
144 self.replace_buffer_with_output(&output)?;
145 self.set_status_message(
146 t!(
147 "format.formatted_with",
148 formatter = formatter.command.clone()
149 )
150 .to_string(),
151 );
152 Ok(())
153 }
154 ActionResult::CommandNotFound(cmd) => Err(format!("Formatter '{}' not found", cmd)),
155 ActionResult::Error(e) => Err(e),
156 }
157 }
158
159 fn run_formatter(&mut self, formatter: &FormatterConfig, file_path: &Path) -> ActionResult {
161 let file_path_str = file_path.display().to_string();
162
163 if !command_exists(&formatter.command) {
165 return ActionResult::CommandNotFound(formatter.command.clone());
166 }
167
168 let shell = detect_shell();
170
171 let mut cmd_parts = vec![formatter.command.clone()];
173 for arg in &formatter.args {
174 cmd_parts.push(arg.replace("$FILE", &file_path_str));
175 }
176
177 let full_command = cmd_parts.join(" ");
178
179 let project_root = std::env::current_dir()
181 .unwrap_or_else(|_| file_path.parent().unwrap_or(Path::new(".")).to_path_buf());
182
183 let mut cmd = Command::new(&shell);
185 cmd.args(["-c", &full_command])
186 .current_dir(&project_root)
187 .stdout(Stdio::piped())
188 .stderr(Stdio::piped());
189
190 if formatter.stdin {
191 cmd.stdin(Stdio::piped());
192 } else {
193 cmd.stdin(Stdio::null());
194 }
195
196 let mut child = match cmd.spawn() {
198 Ok(c) => c,
199 Err(e) => {
200 return ActionResult::Error(format!(
201 "Failed to run '{}': {}",
202 formatter.command, e
203 ));
204 }
205 };
206
207 if formatter.stdin {
209 let content = self.active_state().buffer.to_string().unwrap_or_default();
210 if let Some(mut stdin) = child.stdin.take() {
211 if let Err(e) = stdin.write_all(content.as_bytes()) {
212 return ActionResult::Error(format!("Failed to write to stdin: {}", e));
213 }
214 }
215 }
216
217 let timeout = Duration::from_millis(formatter.timeout_ms);
219 let start = std::time::Instant::now();
220
221 loop {
222 match child.try_wait() {
223 Ok(Some(status)) => {
224 let output = match child.wait_with_output() {
225 Ok(o) => o,
226 Err(e) => {
227 return ActionResult::Error(format!("Failed to get output: {}", e))
228 }
229 };
230
231 if status.success() {
232 return match String::from_utf8(output.stdout) {
233 Ok(s) => ActionResult::Success(s),
234 Err(e) => {
235 ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
236 }
237 };
238 } else {
239 let stderr = String::from_utf8_lossy(&output.stderr);
240 let stdout = String::from_utf8_lossy(&output.stdout);
241 let error_output = if !stderr.is_empty() {
242 stderr.trim().to_string()
243 } else if !stdout.is_empty() {
244 stdout.trim().to_string()
245 } else {
246 format!("exit code {:?}", status.code())
247 };
248 return ActionResult::Error(format!(
249 "Formatter '{}' failed: {}",
250 formatter.command, error_output
251 ));
252 }
253 }
254 Ok(None) => {
255 if start.elapsed() > timeout {
256 #[allow(clippy::let_underscore_must_use)]
258 let _ = child.kill();
259 return ActionResult::Error(format!(
260 "Formatter '{}' timed out after {}ms",
261 formatter.command, formatter.timeout_ms
262 ));
263 }
264 std::thread::sleep(Duration::from_millis(10));
265 }
266 Err(e) => {
267 return ActionResult::Error(format!(
268 "Failed to wait for '{}': {}",
269 formatter.command, e
270 ));
271 }
272 }
273 }
274 }
275
276 fn run_on_save_action(
278 &mut self,
279 action: &OnSaveAction,
280 file_path: &Path,
281 project_root: &Path,
282 ) -> ActionResult {
283 let file_path_str = file_path.display().to_string();
284
285 if !command_exists(&action.command) {
287 return ActionResult::CommandNotFound(action.command.clone());
288 }
289
290 let shell = detect_shell();
292
293 let mut cmd_parts = vec![action.command.clone()];
294 for arg in &action.args {
295 cmd_parts.push(arg.replace("$FILE", &file_path_str));
296 }
297
298 let has_file_arg = action.args.iter().any(|a| a.contains("$FILE"));
300 if !has_file_arg && !action.stdin {
301 cmd_parts.push(file_path_str.clone());
302 }
303
304 let full_command = cmd_parts.join(" ");
305
306 let working_dir = action
308 .working_dir
309 .as_ref()
310 .map(|wd| {
311 let expanded = wd.replace("$FILE", &file_path_str);
312 Path::new(&expanded).to_path_buf()
313 })
314 .unwrap_or_else(|| project_root.to_path_buf());
315
316 let mut cmd = Command::new(&shell);
318 cmd.args(["-c", &full_command])
319 .current_dir(&working_dir)
320 .stdout(Stdio::piped())
321 .stderr(Stdio::piped());
322
323 if action.stdin {
324 cmd.stdin(Stdio::piped());
325 } else {
326 cmd.stdin(Stdio::null());
327 }
328
329 let mut child = match cmd.spawn() {
331 Ok(c) => c,
332 Err(e) => {
333 return ActionResult::Error(format!("Failed to run '{}': {}", action.command, e));
334 }
335 };
336
337 if action.stdin {
339 let content = self.active_state().buffer.to_string().unwrap_or_default();
340 if let Some(mut stdin) = child.stdin.take() {
341 if let Err(e) = stdin.write_all(content.as_bytes()) {
342 return ActionResult::Error(format!("Failed to write to stdin: {}", e));
343 }
344 }
345 }
346
347 let timeout = Duration::from_millis(action.timeout_ms);
349 let start = std::time::Instant::now();
350
351 loop {
352 match child.try_wait() {
353 Ok(Some(status)) => {
354 let output = match child.wait_with_output() {
355 Ok(o) => o,
356 Err(e) => {
357 return ActionResult::Error(format!("Failed to get output: {}", e))
358 }
359 };
360
361 if status.success() {
362 return match String::from_utf8(output.stdout) {
363 Ok(s) => ActionResult::Success(s),
364 Err(e) => {
365 ActionResult::Error(format!("Invalid UTF-8 in output: {}", e))
366 }
367 };
368 } else {
369 let stderr = String::from_utf8_lossy(&output.stderr);
370 let stdout = String::from_utf8_lossy(&output.stdout);
371 let error_output = if !stderr.is_empty() {
372 stderr.trim().to_string()
373 } else if !stdout.is_empty() {
374 stdout.trim().to_string()
375 } else {
376 format!("exit code {:?}", status.code())
377 };
378 return ActionResult::Error(format!(
379 "On-save action '{}' failed: {}",
380 action.command, error_output
381 ));
382 }
383 }
384 Ok(None) => {
385 if start.elapsed() > timeout {
386 #[allow(clippy::let_underscore_must_use)]
388 let _ = child.kill();
389 return ActionResult::Error(format!(
390 "On-save action '{}' timed out after {}ms",
391 action.command, action.timeout_ms
392 ));
393 }
394 std::thread::sleep(Duration::from_millis(10));
395 }
396 Err(e) => {
397 return ActionResult::Error(format!(
398 "Failed to wait for '{}': {}",
399 action.command, e
400 ));
401 }
402 }
403 }
404 }
405
406 fn replace_buffer_with_output(&mut self, output: &str) -> Result<(), String> {
408 let cursor_id = self.active_cursors().primary_id();
409
410 let buffer_content = self.active_state().buffer.to_string().unwrap_or_default();
412
413 if buffer_content == output {
415 return Ok(());
416 }
417
418 let buffer_len = buffer_content.len();
419
420 let old_cursor_pos = self.active_cursors().primary().position;
422 let old_anchor = self.active_cursors().primary().anchor;
423 let old_sticky_column = self.active_cursors().primary().sticky_column;
424
425 let delete_event = Event::Delete {
427 range: 0..buffer_len,
428 deleted_text: buffer_content,
429 cursor_id,
430 };
431 let insert_event = Event::Insert {
432 position: 0,
433 text: output.to_string(),
434 cursor_id,
435 };
436
437 let new_buffer_len = output.len();
440 let new_cursor_pos = old_cursor_pos.min(new_buffer_len);
441
442 let mut events = vec![delete_event, insert_event];
444 if new_cursor_pos != new_buffer_len {
445 let move_cursor_event = Event::MoveCursor {
446 cursor_id,
447 old_position: new_buffer_len, new_position: new_cursor_pos,
449 old_anchor: None,
450 new_anchor: old_anchor.map(|a| a.min(new_buffer_len)),
451 old_sticky_column: 0,
452 new_sticky_column: old_sticky_column,
453 };
454 events.push(move_cursor_event);
455 }
456
457 let batch = Event::Batch {
459 events,
460 description: "On-save format".to_string(),
461 };
462 self.active_event_log_mut().append(batch.clone());
463 self.apply_event_to_active_buffer(&batch);
464
465 Ok(())
466 }
467
468 pub fn trim_trailing_whitespace(&mut self) -> Result<bool, String> {
471 let content = self.active_state().buffer.to_string().unwrap_or_default();
472
473 let trimmed: String = content
475 .lines()
476 .map(|line| line.trim_end())
477 .collect::<Vec<_>>()
478 .join("\n");
479
480 let trimmed = if content.ends_with('\n') && !trimmed.ends_with('\n') {
482 format!("{}\n", trimmed)
483 } else {
484 trimmed
485 };
486
487 if trimmed == content {
488 return Ok(false);
489 }
490
491 self.replace_buffer_with_output(&trimmed)?;
492 Ok(true)
493 }
494
495 pub fn ensure_final_newline(&mut self) -> Result<bool, String> {
498 let content = self.active_state().buffer.to_string().unwrap_or_default();
499
500 if content.is_empty() {
502 return Ok(false);
503 }
504
505 if content.ends_with('\n') {
506 return Ok(false);
507 }
508
509 let with_newline = format!("{}\n", content);
510 self.replace_buffer_with_output(&with_newline)?;
511 Ok(true)
512 }
513}
514
515fn command_exists(command: &str) -> bool {
517 #[cfg(unix)]
519 {
520 Command::new("which")
521 .arg(command)
522 .stdout(Stdio::null())
523 .stderr(Stdio::null())
524 .status()
525 .map(|s| s.success())
526 .unwrap_or(false)
527 }
528
529 #[cfg(windows)]
530 {
531 Command::new("where")
532 .arg(command)
533 .stdout(Stdio::null())
534 .stderr(Stdio::null())
535 .status()
536 .map(|s| s.success())
537 .unwrap_or(false)
538 }
539
540 #[cfg(not(any(unix, windows)))]
541 {
542 true
544 }
545}
546
547fn detect_shell() -> String {
549 if let Ok(shell) = std::env::var("SHELL") {
551 if !shell.is_empty() {
552 return shell;
553 }
554 }
555
556 #[cfg(unix)]
558 {
559 if std::path::Path::new("/bin/bash").exists() {
560 return "/bin/bash".to_string();
561 }
562 if std::path::Path::new("/bin/sh").exists() {
563 return "/bin/sh".to_string();
564 }
565 }
566
567 #[cfg(windows)]
568 {
569 if let Ok(comspec) = std::env::var("COMSPEC") {
570 return comspec;
571 }
572 return "cmd.exe".to_string();
573 }
574
575 "sh".to_string()
577}