Rust · 21174 bytes Raw Blame History
1 use reedline::{
2 ColumnarMenu, Emacs, FileBackedHistory,
3 KeyCode, KeyModifiers, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal,
4 default_emacs_keybindings,
5 };
6 use rush_expand::Context;
7 use rush_interactive::{RushCompleter, RushHighlighter, RushHinter, RushPrompt};
8 use std::process::ExitCode;
9
10 /// Expand history references in a command line
11 ///
12 /// Supports:
13 /// - `!!` - previous command
14 /// - `!$` - last argument of previous command
15 /// - `!^` - first argument of previous command
16 /// - `!*` - all arguments of previous command
17 /// - `!-n` - nth previous command
18 /// - `!n` - command at history index n
19 /// - `!string` - most recent command starting with string
20 fn expand_history(input: &str, history: &[String]) -> Result<String, String> {
21 if !input.contains('!') {
22 return Ok(input.to_string());
23 }
24
25 let mut result = String::new();
26 let mut chars = input.chars().peekable();
27 let mut in_single_quote = false;
28 let mut in_double_quote = false;
29
30 while let Some(c) = chars.next() {
31 match c {
32 '\'' if !in_double_quote => {
33 in_single_quote = !in_single_quote;
34 result.push(c);
35 }
36 '"' if !in_single_quote => {
37 in_double_quote = !in_double_quote;
38 result.push(c);
39 }
40 '!' if !in_single_quote => {
41 // Check for escaped !
42 if let Some(&next) = chars.peek() {
43 match next {
44 '!' => {
45 // !! - previous command
46 chars.next();
47 if history.is_empty() {
48 return Err("!!: event not found".to_string());
49 }
50 result.push_str(&history[history.len() - 1]);
51 }
52 '$' => {
53 // !$ - last argument of previous command
54 chars.next();
55 if history.is_empty() {
56 return Err("!$: event not found".to_string());
57 }
58 let prev = &history[history.len() - 1];
59 let args: Vec<&str> = prev.split_whitespace().collect();
60 if let Some(last) = args.last() {
61 result.push_str(last);
62 }
63 }
64 '^' => {
65 // !^ - first argument of previous command
66 chars.next();
67 if history.is_empty() {
68 return Err("!^: event not found".to_string());
69 }
70 let prev = &history[history.len() - 1];
71 let args: Vec<&str> = prev.split_whitespace().collect();
72 if args.len() > 1 {
73 result.push_str(args[1]);
74 }
75 }
76 '*' => {
77 // !* - all arguments of previous command
78 chars.next();
79 if history.is_empty() {
80 return Err("!*: event not found".to_string());
81 }
82 let prev = &history[history.len() - 1];
83 let args: Vec<&str> = prev.split_whitespace().collect();
84 if args.len() > 1 {
85 result.push_str(&args[1..].join(" "));
86 }
87 }
88 '-' => {
89 // !-n - nth previous command
90 chars.next();
91 let mut num_str = String::new();
92 while let Some(&ch) = chars.peek() {
93 if ch.is_ascii_digit() {
94 num_str.push(chars.next().unwrap());
95 } else {
96 break;
97 }
98 }
99 if let Ok(n) = num_str.parse::<usize>() {
100 if n > 0 && n <= history.len() {
101 result.push_str(&history[history.len() - n]);
102 } else {
103 return Err(format!("!-{}: event not found", n));
104 }
105 } else {
106 return Err(format!("!-{}: event not found", num_str));
107 }
108 }
109 d if d.is_ascii_digit() => {
110 // !n - command at index n
111 let mut num_str = String::new();
112 while let Some(&ch) = chars.peek() {
113 if ch.is_ascii_digit() {
114 num_str.push(chars.next().unwrap());
115 } else {
116 break;
117 }
118 }
119 if let Ok(n) = num_str.parse::<usize>() {
120 if n > 0 && n <= history.len() {
121 result.push_str(&history[n - 1]);
122 } else {
123 return Err(format!("!{}: event not found", n));
124 }
125 } else {
126 return Err(format!("!{}: event not found", num_str));
127 }
128 }
129 ch if ch.is_alphanumeric() || ch == '_' => {
130 // !string - most recent command starting with string
131 let mut prefix = String::new();
132 while let Some(&ch) = chars.peek() {
133 if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
134 prefix.push(chars.next().unwrap());
135 } else {
136 break;
137 }
138 }
139 // Find most recent command starting with prefix
140 if let Some(cmd) = history.iter().rev().find(|h| h.starts_with(&prefix)) {
141 result.push_str(cmd);
142 } else {
143 return Err(format!("!{}: event not found", prefix));
144 }
145 }
146 ' ' | '\t' | '\n' => {
147 // Standalone ! followed by whitespace - literal
148 result.push('!');
149 }
150 _ => {
151 // Unknown ! sequence - keep as is
152 result.push('!');
153 }
154 }
155 } else {
156 // ! at end of line - literal
157 result.push('!');
158 }
159 }
160 _ => {
161 result.push(c);
162 }
163 }
164 }
165
166 Ok(result)
167 }
168
169 /// Get history as a vector of strings from reedline history
170 fn get_history_strings(line_editor: &Reedline) -> Vec<String> {
171 let history = line_editor.history();
172
173 // Try to get all history items (no session filtering)
174 match history.search(reedline::SearchQuery::last_with_search(
175 reedline::SearchFilter::anything(None),
176 )) {
177 Ok(items) => items.into_iter().map(|item| item.command_line).collect(),
178 Err(_) => Vec::new(),
179 }
180 }
181
182 /// Default config file contents
183 const DEFAULT_CONFIG: &str = r#"# Rush shell configuration
184 # This file is sourced on interactive shell startup
185
186 # Example aliases
187 # alias ll='ls -la'
188 # alias la='ls -A'
189
190 # Example prompt customization (uncomment to use)
191 # export PS1='\u@\h:\w\$ '
192 # export PS1_RIGHT='\D{%m/%d/%Y %I:%M:%S %p}'
193
194 # Example environment variables
195 # export EDITOR=vim
196 "#;
197
198 /// Check if a config file exists
199 fn config_exists() -> bool {
200 let config_paths = [
201 dirs::home_dir().map(|p| p.join(".rushrc")),
202 dirs::config_dir().map(|p| p.join("rush").join("rushrc")),
203 ];
204
205 config_paths.iter().flatten().any(|p| p.exists())
206 }
207
208 /// Prompt user to create default config on first run
209 fn maybe_create_default_config() {
210 if config_exists() {
211 return;
212 }
213
214 // Check if we've already asked (store a marker file)
215 let marker_path = dirs::data_dir().map(|p| p.join("rush").join(".config_prompted"));
216 if let Some(ref marker) = marker_path {
217 if marker.exists() {
218 return;
219 }
220 }
221
222 println!("Welcome to Rush! No configuration file found.");
223 print!("Create default config at ~/.rushrc? [Y/n] ");
224
225 // Flush stdout to ensure prompt is displayed
226 use std::io::Write;
227 std::io::stdout().flush().ok();
228
229 let mut input = String::new();
230 if std::io::stdin().read_line(&mut input).is_ok() {
231 let response = input.trim().to_lowercase();
232 if response.is_empty() || response == "y" || response == "yes" {
233 // Create the config file
234 if let Some(config_path) = dirs::home_dir().map(|p| p.join(".rushrc")) {
235 match std::fs::write(&config_path, DEFAULT_CONFIG) {
236 Ok(_) => println!("Created {}. Edit it to customize your shell!", config_path.display()),
237 Err(e) => eprintln!("rush: could not create config: {}", e),
238 }
239 }
240 } else {
241 println!("Skipped. You can create ~/.rushrc manually anytime.");
242 }
243 }
244
245 // Create marker so we don't ask again
246 if let Some(marker) = marker_path {
247 if let Some(parent) = marker.parent() {
248 std::fs::create_dir_all(parent).ok();
249 }
250 std::fs::write(marker, "").ok();
251 }
252
253 println!();
254 }
255
256 /// Source config files on interactive shell startup
257 fn source_config_files(context: &mut Context) {
258 let config_paths = [
259 dirs::home_dir().map(|p| p.join(".rushrc")),
260 dirs::config_dir().map(|p| p.join("rush").join("rushrc")),
261 ];
262
263 for path_opt in config_paths {
264 if let Some(path) = path_opt {
265 if path.exists() {
266 match std::fs::read_to_string(&path) {
267 Ok(content) => {
268 // Filter out comment-only and empty lines, then parse
269 let executable_lines: Vec<&str> = content
270 .lines()
271 .filter(|line| {
272 let trimmed = line.trim();
273 !trimmed.is_empty() && !trimmed.starts_with('#')
274 })
275 .collect();
276
277 // Skip if no executable content
278 if executable_lines.is_empty() {
279 break;
280 }
281
282 let filtered_content = executable_lines.join("\n");
283
284 // Parse and execute the config file
285 use rush_parser::parse_line;
286 match parse_line(&filtered_content) {
287 Ok(statement) => {
288 if let Err(e) = rush_executor::execute_statement(&statement, context) {
289 eprintln!("rush: error in {:?}: {}", path, e);
290 }
291 }
292 Err(e) => {
293 eprintln!("rush: parse error in {:?}: {}", path, e);
294 }
295 }
296 }
297 Err(e) => {
298 eprintln!("rush: could not read {:?}: {}", path, e);
299 }
300 }
301 break; // Only source first found config
302 }
303 }
304 }
305 }
306
307 /// Source login shell profile files
308 fn source_login_profiles(context: &mut Context) {
309 let profile_paths = [
310 // System-wide profile
311 Some(std::path::PathBuf::from("/etc/profile")),
312 // User profile (traditional)
313 dirs::home_dir().map(|p| p.join(".profile")),
314 // Rush-specific login profile
315 dirs::home_dir().map(|p| p.join(".rush_profile")),
316 ];
317
318 for path_opt in profile_paths {
319 if let Some(path) = path_opt {
320 if path.exists() {
321 match std::fs::read_to_string(&path) {
322 Ok(content) => {
323 // Filter comments and empty lines
324 let executable_lines: Vec<&str> = content
325 .lines()
326 .filter(|line| {
327 let trimmed = line.trim();
328 !trimmed.is_empty() && !trimmed.starts_with('#')
329 })
330 .collect();
331
332 if executable_lines.is_empty() {
333 continue;
334 }
335
336 let filtered_content = executable_lines.join("\n");
337
338 use rush_parser::parse_line;
339 match parse_line(&filtered_content) {
340 Ok(statement) => {
341 if let Err(e) = rush_executor::execute_statement(&statement, context) {
342 eprintln!("rush: error in {:?}: {}", path, e);
343 }
344 }
345 Err(e) => {
346 eprintln!("rush: parse error in {:?}: {}", path, e);
347 }
348 }
349 }
350 Err(e) => {
351 // Only warn for user profiles, not system ones
352 if path.starts_with(dirs::home_dir().unwrap_or_default()) {
353 eprintln!("rush: could not read {:?}: {}", path, e);
354 }
355 }
356 }
357 }
358 }
359 }
360 }
361
362 pub fn run_interactive(is_login: bool) -> ExitCode {
363 // On first run, offer to create default config
364 maybe_create_default_config();
365
366 // Set up persistent history
367 let history_path = dirs::data_dir()
368 .map(|mut path| {
369 path.push("rush");
370 if let Err(e) = std::fs::create_dir_all(&path) {
371 eprintln!("rush: warning: could not create data directory: {}", e);
372 }
373 path.push("history.txt");
374 path
375 });
376
377 let history_file = history_path.as_ref().and_then(|path| {
378 match FileBackedHistory::with_file(1000, path.clone()) {
379 Ok(history) => Some(history),
380 Err(e) => {
381 eprintln!("rush: warning: could not load history from {:?}: {}", path, e);
382 None
383 }
384 }
385 });
386
387 // Create the completion menu with custom marker (instead of default "|")
388 let completion_menu = Box::new(
389 ColumnarMenu::default()
390 .with_name("completion_menu")
391 .with_marker("› ")
392 );
393
394 // Set up keybindings with Tab completion and arrow key navigation
395 let mut keybindings = default_emacs_keybindings();
396
397 // Tab opens menu or cycles through completions
398 keybindings.add_binding(
399 KeyModifiers::NONE,
400 KeyCode::Tab,
401 ReedlineEvent::UntilFound(vec![
402 ReedlineEvent::Menu("completion_menu".to_string()),
403 ReedlineEvent::MenuNext,
404 ]),
405 );
406
407 // Shift+Tab cycles backwards
408 keybindings.add_binding(
409 KeyModifiers::SHIFT,
410 KeyCode::BackTab,
411 ReedlineEvent::MenuPrevious,
412 );
413
414 // Arrow keys navigate the completion menu (fish-style)
415 keybindings.add_binding(
416 KeyModifiers::NONE,
417 KeyCode::Down,
418 ReedlineEvent::UntilFound(vec![
419 ReedlineEvent::MenuDown,
420 ReedlineEvent::Down,
421 ]),
422 );
423 keybindings.add_binding(
424 KeyModifiers::NONE,
425 KeyCode::Up,
426 ReedlineEvent::UntilFound(vec![
427 ReedlineEvent::MenuUp,
428 ReedlineEvent::Up,
429 ]),
430 );
431 keybindings.add_binding(
432 KeyModifiers::NONE,
433 KeyCode::Left,
434 ReedlineEvent::UntilFound(vec![
435 ReedlineEvent::MenuLeft,
436 ReedlineEvent::Left,
437 ]),
438 );
439 keybindings.add_binding(
440 KeyModifiers::NONE,
441 KeyCode::Right,
442 ReedlineEvent::UntilFound(vec![
443 ReedlineEvent::MenuRight,
444 ReedlineEvent::HistoryHintComplete, // Accept hint if at end of line
445 ReedlineEvent::Right,
446 ]),
447 );
448
449 // Enter executes command
450 keybindings.add_binding(
451 KeyModifiers::NONE,
452 KeyCode::Enter,
453 ReedlineEvent::Submit,
454 );
455
456 let mut line_editor = Reedline::create()
457 .with_highlighter(Box::new(RushHighlighter::new()))
458 .with_hinter(Box::new(RushHinter::new()))
459 .with_completer(Box::new(RushCompleter::new()))
460 .with_menu(ReedlineMenu::EngineCompleter(completion_menu))
461 .with_edit_mode(Box::new(Emacs::new(keybindings)));
462
463 // Add history if we successfully created it
464 if let Some(history) = history_file {
465 line_editor = line_editor.with_history(Box::new(history));
466 }
467
468 let prompt = RushPrompt::new();
469 let mut context = crate::create_context();
470
471 // Source login profile files if this is a login shell
472 if is_login {
473 source_login_profiles(&mut context);
474 }
475
476 // Source config files (~/.rushrc or ~/.config/rush/rushrc)
477 source_config_files(&mut context);
478
479 loop {
480 // Check for SIGHUP (terminal hangup)
481 #[cfg(unix)]
482 if rush_job::check_sighup() {
483 // Send SIGHUP to all jobs and exit
484 context.job_list.send_hup_to_all();
485 break;
486 }
487
488 // Check for completed/stopped background jobs before each prompt
489 #[cfg(unix)]
490 crate::check_background_jobs(&mut context);
491
492 let sig = line_editor.read_line(&prompt);
493
494 match sig {
495 Ok(Signal::Success(buffer)) => {
496 if buffer.trim().is_empty() {
497 continue;
498 }
499
500 // Perform history expansion if the line contains !
501 let expanded_buffer = if buffer.contains('!') {
502 let history = get_history_strings(&line_editor);
503 match expand_history(&buffer, &history) {
504 Ok(expanded) => {
505 // If expansion changed the line, print it (bash behavior)
506 if expanded != buffer {
507 println!("{}", expanded);
508 }
509 expanded
510 }
511 Err(e) => {
512 eprintln!("rush: {}", e);
513 continue;
514 }
515 }
516 } else {
517 buffer
518 };
519
520 if let Err(e) = crate::execute_interactive_line(&expanded_buffer, &mut context) {
521 eprintln!("rush: {}", e);
522 }
523
524 // Check if exit was requested
525 if context.exit_requested.is_some() {
526 break;
527 }
528 }
529 Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => {
530 break;
531 }
532 Err(e) => {
533 eprintln!("rush: error: {}", e);
534 // Sync history before returning on error
535 if let Err(e) = line_editor.sync_history() {
536 eprintln!("rush: warning: failed to save history: {}", e);
537 }
538 // Restore terminal attributes
539 #[cfg(unix)]
540 if let Err(e) = rush_job::restore_terminal_attrs() {
541 eprintln!("rush: warning: failed to restore terminal: {}", e);
542 }
543 return ExitCode::from(1);
544 }
545 }
546 }
547
548 // Sync history before exiting
549 if let Err(e) = line_editor.sync_history() {
550 eprintln!("rush: warning: failed to save history: {}", e);
551 }
552
553 // Restore terminal attributes to their original state
554 #[cfg(unix)]
555 if let Err(e) = rush_job::restore_terminal_attrs() {
556 eprintln!("rush: warning: failed to restore terminal: {}", e);
557 }
558
559 // Return requested exit code, or SUCCESS
560 match context.exit_requested {
561 Some(code) => ExitCode::from(code as u8),
562 None => ExitCode::SUCCESS,
563 }
564 }
565