brush_shell/
entry.rs

1//! Implements the command-line interface for the `brush` shell.
2
3use crate::args::{CommandLineArgs, InputBackend};
4use crate::brushctl;
5use crate::error_formatter;
6use crate::events;
7use crate::productinfo;
8use crate::shell_factory;
9use brush_interactive::InteractiveShell;
10use std::sync::LazyLock;
11use std::{path::Path, sync::Arc};
12use tokio::sync::Mutex;
13
14#[allow(unused_imports, reason = "only used in some configs")]
15use std::io::IsTerminal;
16
17static TRACE_EVENT_CONFIG: LazyLock<Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>>> =
18    LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None)));
19
20// WARN: this implementation shadows `clap::Parser::parse_from` one so it must be defined
21// after the `use clap::Parser`
22impl CommandLineArgs {
23    // Work around clap's limitation handling `--` like a regular value
24    // TODO: We can safely remove this `impl` after the issue is resolved
25    // https://github.com/clap-rs/clap/issues/5055
26    // This function takes precedence over [`clap::Parser::parse_from`]
27    fn try_parse_from(itr: impl IntoIterator<Item = String>) -> Result<Self, clap::Error> {
28        let (mut this, script_args) = brush_core::builtins::try_parse_known::<Self>(itr)?;
29
30        // if we have `--` and unparsed raw args than
31        if let Some(args) = script_args {
32            this.script_args.extend(args);
33        }
34
35        Ok(this)
36    }
37}
38
39/// Main entry point for the `brush` shell.
40pub fn run() {
41    //
42    // Install panic handlers to clean up on panic.
43    //
44    install_panic_handlers();
45
46    //
47    // Parse args.
48    //
49    let mut args: Vec<_> = std::env::args().collect();
50
51    // Work around clap's limitations handling +O options.
52    for arg in &mut args {
53        if arg.starts_with("+O") {
54            arg.insert_str(0, "--");
55        }
56    }
57
58    let parsed_args = match CommandLineArgs::try_parse_from(args.iter().cloned()) {
59        Ok(parsed_args) => parsed_args,
60        Err(e) => {
61            let _ = e.print();
62
63            // Check for whether this is something we'd truly consider fatal. clap returns
64            // errors for `--help`, `--version`, etc.
65            let exit_code = match e.kind() {
66                clap::error::ErrorKind::DisplayVersion => 0,
67                clap::error::ErrorKind::DisplayHelp => 0,
68                _ => 1,
69            };
70
71            std::process::exit(exit_code);
72        }
73    };
74
75    //
76    // Run.
77    //
78    #[cfg(any(unix, windows))]
79    let mut builder = tokio::runtime::Builder::new_multi_thread();
80    #[cfg(not(any(unix, windows)))]
81    let mut builder = tokio::runtime::Builder::new_current_thread();
82
83    let result = builder
84        .enable_all()
85        .build()
86        .unwrap()
87        .block_on(run_async(args, parsed_args));
88
89    let exit_code = match result {
90        Ok(code) => code,
91        Err(err) => {
92            tracing::error!("error: {err:#}");
93            1
94        }
95    };
96
97    std::process::exit(i32::from(exit_code));
98}
99
100/// Installs panic handlers to report our panic and cleanly exit on panic.
101fn install_panic_handlers() {
102    //
103    // Set up panic handler. On release builds, it will capture panic details to a
104    // temporary .toml file and report a human-readable message to the screen.
105    //
106    human_panic::setup_panic!(
107        human_panic::Metadata::new(productinfo::PRODUCT_NAME, productinfo::PRODUCT_VERSION)
108            .homepage(env!("CARGO_PKG_HOMEPAGE"))
109            .support("please post a GitHub issue at https://github.com/reubeno/brush/issues/new")
110    );
111
112    //
113    // If stdout is connected to a terminal, then register a new panic handler that
114    // resets the terminal and then invokes the previously registered handler. In
115    // dev/debug builds, the previously registered handler will be the default
116    // handler; in release builds, it will be the one registered by `human_panic`.
117    //
118    if std::io::stdout().is_terminal() {
119        let original_panic_handler = std::panic::take_hook();
120        std::panic::set_hook(Box::new(move |panic_info| {
121            // Best-effort attempt to reset the terminal to defaults.
122            let _ = try_reset_terminal_to_defaults();
123
124            // Invoke the original handler
125            original_panic_handler(panic_info);
126        }));
127    }
128}
129
130/// Run the brush shell. Returns the exit code.
131///
132/// # Arguments
133///
134/// * `cli_args` - The command-line arguments to the shell, in string form.
135/// * `args` - The already-parsed command-line arguments.
136#[doc(hidden)]
137async fn run_async(
138    cli_args: Vec<String>,
139    args: CommandLineArgs,
140) -> Result<u8, brush_interactive::ShellError> {
141    let default_backend = get_default_input_backend();
142    let selected_backend = args.input_backend.unwrap_or(default_backend);
143
144    match selected_backend {
145        InputBackend::Reedline => {
146            run_impl(cli_args, args, shell_factory::ReedlineShellFactory).await
147        }
148        InputBackend::Basic => run_impl(cli_args, args, shell_factory::BasicShellFactory).await,
149        InputBackend::Minimal => run_impl(cli_args, args, shell_factory::MinimalShellFactory).await,
150    }
151}
152
153/// Run the brush shell. Returns the exit code.
154///
155/// # Arguments
156///
157/// * `cli_args` - The command-line arguments to the shell, in string form.
158/// * `args` - The already-parsed command-line arguments.
159#[doc(hidden)]
160async fn run_impl(
161    cli_args: Vec<String>,
162    args: CommandLineArgs,
163    factory: impl shell_factory::ShellFactory + Send + 'static,
164) -> Result<u8, brush_interactive::ShellError> {
165    // Initializing tracing.
166    let mut event_config = TRACE_EVENT_CONFIG.try_lock().unwrap();
167    *event_config = Some(events::TraceEventConfig::init(
168        &args.enabled_debug_events,
169        &args.disabled_events,
170    ));
171    drop(event_config);
172
173    // Instantiate an appropriately configured shell.
174    let mut shell = instantiate_shell(&args, cli_args, factory).await?;
175
176    // Run in that shell.
177    let result = run_in_shell(&mut shell, args).await;
178
179    // Display any error that percolated up.
180    let exit_code = match result {
181        Ok(code) => code,
182        Err(brush_interactive::ShellError::ShellError(e)) => {
183            let core_shell = shell.shell();
184            let mut stderr = core_shell.as_ref().stderr();
185            let _ = core_shell.as_ref().display_error(&mut stderr, &e).await;
186            1
187        }
188        Err(err) => {
189            tracing::error!("error: {err:#}");
190            1
191        }
192    };
193
194    Ok(exit_code)
195}
196
197async fn run_in_shell(
198    shell: &mut impl brush_interactive::InteractiveShell,
199    args: CommandLineArgs,
200) -> Result<u8, brush_interactive::ShellError> {
201    // If a command was specified via -c, then run that command and then exit.
202    if let Some(command) = args.command {
203        // Pass through args.
204        if !args.script_args.is_empty() {
205            shell.shell_mut().as_mut().shell_name = Some(args.script_args[0].clone());
206        }
207        shell.shell_mut().as_mut().positional_parameters =
208            args.script_args.iter().skip(1).cloned().collect();
209
210        // Execute the command string.
211        let params = shell.shell().as_ref().default_exec_params();
212        shell
213            .shell_mut()
214            .as_mut()
215            .run_string(command, &params)
216            .await?;
217
218    // If -s was provided, then read commands from stdin. If there was a script (and optionally
219    // args) passed on the command line via positional arguments, then we copy over the
220    // parameters but do *not* execute it.
221    } else if args.read_commands_from_stdin {
222        if !args.script_args.is_empty() {
223            shell
224                .shell_mut()
225                .as_mut()
226                .positional_parameters
227                .clone_from(&args.script_args);
228        }
229
230        shell.run_interactively().await?;
231
232    // If a script path was provided, then run the script.
233    } else if !args.script_args.is_empty() {
234        // The path to a script was provided on the command line; run the script.
235        shell
236            .shell_mut()
237            .as_mut()
238            .run_script(
239                Path::new(&args.script_args[0]),
240                args.script_args.iter().skip(1),
241            )
242            .await?;
243
244    // If we got down here, then we don't have any commands to run. We'll be reading
245    // them in from stdin one way or the other.
246    } else {
247        shell.run_interactively().await?;
248    }
249
250    // Make sure to return the last result observed in the shell.
251    let result = shell.shell().as_ref().last_result();
252
253    Ok(result)
254}
255
256async fn instantiate_shell(
257    args: &CommandLineArgs,
258    cli_args: Vec<String>,
259    factory: impl shell_factory::ShellFactory + Send + 'static,
260) -> Result<impl brush_interactive::InteractiveShell + 'static, brush_interactive::ShellError> {
261    let argv0 = if args.sh_mode {
262        // Simulate having been run as "sh".
263        Some(String::from("sh"))
264    } else if !cli_args.is_empty() {
265        Some(cli_args[0].clone())
266    } else {
267        None
268    };
269
270    // Commands are read from stdin if -s was provided, or if no command was specified (either via
271    // -c or as a positional argument).
272    let read_commands_from_stdin = (args.read_commands_from_stdin && args.command.is_none())
273        || (args.script_args.is_empty() && args.command.is_none());
274
275    let interactive = args.is_interactive();
276
277    let builtins = brush_builtins::default_builtins(if args.sh_mode {
278        brush_builtins::BuiltinSet::ShMode
279    } else {
280        brush_builtins::BuiltinSet::BashMode
281    });
282
283    let fds = args
284        .inherited_fds
285        .iter()
286        .filter_map(|&fd| brush_core::sys::fd::try_get_file_for_open_fd(fd).map(|file| (fd, file)))
287        .collect();
288
289    // Compose the options we'll use to create the shell.
290    let options = brush_interactive::Options {
291        shell: brush_core::CreateOptions {
292            disabled_options: args.disabled_options.clone(),
293            disabled_shopt_options: args.disabled_shopt_options.clone(),
294            disallow_overwriting_regular_files_via_output_redirection: args
295                .disallow_overwriting_regular_files_via_output_redirection,
296            enabled_options: args.enabled_options.clone(),
297            enabled_shopt_options: args.enabled_shopt_options.clone(),
298            do_not_execute_commands: args.do_not_execute_commands,
299            exit_after_one_command: args.exit_after_one_command,
300            login: args.login || argv0.as_ref().is_some_and(|a0| a0.starts_with('-')),
301            interactive,
302            no_editing: args.no_editing,
303            no_profile: args.no_profile,
304            no_rc: args.no_rc,
305            rc_file: args.rc_file.clone(),
306            do_not_inherit_env: args.do_not_inherit_env,
307            fds: Some(fds),
308            posix: args.posix || args.sh_mode,
309            print_commands_and_arguments: args.print_commands_and_arguments,
310            read_commands_from_stdin,
311            shell_name: argv0,
312            shell_product_display_str: Some(productinfo::get_product_display_str()),
313            sh_mode: args.sh_mode,
314            verbose: args.verbose,
315            max_function_call_depth: None,
316            key_bindings: None,
317            error_formatter: Some(new_error_formatter(args)),
318            shell_version: Some(env!("CARGO_PKG_VERSION").to_string()),
319            builtins,
320        },
321        disable_bracketed_paste: args.disable_bracketed_paste,
322        disable_color: args.disable_color,
323        disable_highlighting: !args.enable_highlighting,
324    };
325
326    // Create the shell.
327    let mut shell = factory.create(options).await?;
328
329    // Register our own built-in(s) with the shell.
330    brushctl::register(shell.shell_mut().as_mut());
331
332    Ok(shell)
333}
334
335fn new_error_formatter(
336    args: &CommandLineArgs,
337) -> Arc<Mutex<dyn brush_core::error::ErrorFormatter>> {
338    let formatter = error_formatter::Formatter {
339        use_color: !args.disable_color,
340    };
341
342    Arc::new(Mutex::new(formatter))
343}
344
345fn get_default_input_backend() -> InputBackend {
346    #[cfg(any(unix, windows))]
347    {
348        // If stdin isn't a terminal, then `reedline` doesn't do the right thing
349        // (reference: https://github.com/nushell/reedline/issues/509). Switch to
350        // the minimal input backend instead for that scenario.
351        if std::io::stdin().is_terminal() {
352            InputBackend::Reedline
353        } else {
354            InputBackend::Minimal
355        }
356    }
357    #[cfg(not(any(unix, windows)))]
358    {
359        InputBackend::Minimal
360    }
361}
362
363pub(crate) fn get_event_config() -> Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>> {
364    TRACE_EVENT_CONFIG.clone()
365}
366
367fn try_reset_terminal_to_defaults() -> Result<(), std::io::Error> {
368    #[cfg(any(unix, windows))]
369    {
370        // Reset the console.
371        let exec_result = crossterm::execute!(
372            std::io::stdout(),
373            crossterm::terminal::LeaveAlternateScreen,
374            crossterm::terminal::EnableLineWrap,
375            crossterm::style::ResetColor,
376            crossterm::event::DisableMouseCapture,
377            crossterm::event::DisableBracketedPaste,
378            crossterm::cursor::Show,
379            crossterm::cursor::MoveToNextLine(1),
380        );
381
382        let raw_result = crossterm::terminal::disable_raw_mode();
383
384        exec_result?;
385        raw_result?;
386    }
387
388    Ok(())
389}
390
391#[cfg(test)]
392#[allow(clippy::panic_in_result_fn)]
393mod tests {
394    use super::*;
395    use anyhow::Result;
396    use pretty_assertions::{assert_eq, assert_matches};
397
398    #[test]
399    fn parse_empty_args() -> Result<()> {
400        let args = vec!["brush"];
401        let args = args.into_iter().map(|s| s.to_string()).collect::<Vec<_>>();
402
403        let parsed_args = CommandLineArgs::try_parse_from(args)?;
404        assert_matches!(parsed_args.script_args.as_slice(), []);
405
406        Ok(())
407    }
408
409    #[test]
410    fn parse_script_and_args() -> Result<()> {
411        let args = vec!["brush", "some-script", "-x", "1", "--option"];
412        let args = args.into_iter().map(|s| s.to_string()).collect::<Vec<_>>();
413
414        let parsed_args = CommandLineArgs::try_parse_from(args)?;
415        assert_eq!(
416            parsed_args.script_args,
417            ["some-script", "-x", "1", "--option"]
418        );
419
420        Ok(())
421    }
422
423    #[test]
424    fn parse_script_and_args_with_double_dash_in_script_args() -> Result<()> {
425        let args = vec!["brush", "some-script", "--"];
426        let args = args.into_iter().map(|s| s.to_string()).collect::<Vec<_>>();
427
428        let parsed_args = CommandLineArgs::try_parse_from(args)?;
429        assert_eq!(parsed_args.script_args, ["some-script", "--"]);
430
431        Ok(())
432    }
433
434    #[test]
435    fn parse_unknown_args() {
436        let args = vec!["brush", "--unknown-option"];
437        let args = args.into_iter().map(|s| s.to_string()).collect::<Vec<_>>();
438
439        let result = CommandLineArgs::try_parse_from(args);
440        if let Ok(parsed_args) = &result {
441            assert_matches!(parsed_args.script_args.as_slice(), []);
442            assert!(result.is_err());
443        }
444    }
445}