//! Completion integration tests for brush shell. // For now, only compile this for Linux. #![cfg(target_os = "linux")] #![cfg(test)] #![allow(clippy::panic_in_result_fn)] use anyhow::Result; use assert_fs::prelude::*; use brush_builtins::ShellBuilderExt; use std::path::PathBuf; struct TestShellWithBashCompletion { shell: brush_core::Shell, temp_dir: assert_fs::TempDir, } const DEFAULT_BASH_COMPLETION_SCRIPT: &str = "/usr/share/bash-completion/bash_completion"; impl TestShellWithBashCompletion { async fn new() -> Result { let mut shell = brush_core::Shell::builder() .profile(brush_core::ProfileLoadBehavior::Skip) .rc(brush_core::RcLoadBehavior::Skip) .default_builtins(brush_builtins::BuiltinSet::BashMode) .build() .await?; let temp_dir = assert_fs::TempDir::new()?; let bash_completion_script_path = Self::find_bash_completion_script()?; let exec_params = shell.default_exec_params(); let source_result = shell .source_script( bash_completion_script_path.as_path(), std::iter::empty::(), &exec_params, ) .await?; if !source_result.is_success() { return Err(anyhow::anyhow!("failed to source bash completion script")); } shell.set_working_dir(temp_dir.path())?; Ok(Self { shell, temp_dir }) } fn find_bash_completion_script() -> Result { // See if an environmental override was provided. let script_path = std::env::var("BASH_COMPLETION_PATH").map_or_else( |_| PathBuf::from(DEFAULT_BASH_COMPLETION_SCRIPT), PathBuf::from, ); if script_path.exists() { Ok(script_path) } else { Err(anyhow::anyhow!( "bash completion script not found: {}", script_path.display() )) } } pub async fn complete_end_of_line(&mut self, line: &str) -> Result> { self.complete(line, line.len()).await } pub async fn complete(&mut self, line: &str, pos: usize) -> Result> { let completions = self.shell.complete(line, pos).await?; Ok(completions .candidates .into_iter() .collect::>() .into_iter() .collect()) } pub fn set_var(&mut self, name: &str, value: &str) -> Result<()> { self.shell .env_mut() .set_global(name, brush_core::ShellVariable::new(value))?; Ok(()) } } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_relative_file_path() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; test_shell.temp_dir.child(".dot_item1").touch()?; test_shell.temp_dir.child("..dot_item2").touch()?; // Complete; expect to see the two files. let mut results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, ["item1", "item2"]); results = test_shell.complete_end_of_line("ls .").await?; // Some versions of bash-completion filter out "." and ".." via // `-X '?(*/)@(.|..)'`; others don't. Accept either outcome. assert!( results == ["..dot_item2", ".dot_item1"] || results == [".", "..", "..dot_item2", ".dot_item1"], "unexpected completions for 'ls .': {results:?}" ); results = test_shell.complete_end_of_line("ls ..").await?; assert_eq!(results, ["..", "..dot_item2"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_relative_file_path_ignoring_case() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; test_shell .shell .options_mut() .case_insensitive_pathname_expansion = true; // Create file and dir. test_shell.temp_dir.child("ITEM1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. let results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, ["ITEM1", "item2"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_relative_dir_path() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see just the dir. let results = test_shell.complete_end_of_line("cd item").await?; assert_eq!(results, ["item2"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_under_empty_dir() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("empty").create_dir_all()?; // Complete; expect to see nothing. let results = test_shell.complete_end_of_line("ls empty/").await?; assert_eq!(results, Vec::::new()); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_nonexistent_relative_path() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete; expect to see nothing. let results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, Vec::::new()); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_absolute_paths() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see just the dir. let input = std::format!("ls {}", test_shell.temp_dir.path().join("item").display()); let results = test_shell.complete_end_of_line(input.as_str()).await?; assert_eq!( results, [ test_shell .temp_dir .child("item1") .path() .display() .to_string(), test_shell .temp_dir .child("item2") .path() .display() .to_string(), ] ); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_path_with_var() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. let results = test_shell.complete_end_of_line("ls $PWD/item").await?; assert_eq!( results, [ test_shell .temp_dir .child("item1") .path() .display() .to_string(), test_shell .temp_dir .child("item2") .path() .display() .to_string(), ] ); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_path_with_tilde() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Set HOME to the temp dir so we can use ~ to reference it. test_shell.set_var( "HOME", test_shell .temp_dir .path() .to_string_lossy() .to_string() .as_str(), )?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. let results = test_shell.complete_end_of_line("ls ~/item").await?; assert_eq!( results, [ test_shell .temp_dir .child("item1") .path() .display() .to_string(), test_shell .temp_dir .child("item2") .path() .display() .to_string(), ] ); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_variable_names() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Set a few vars. test_shell.set_var("TESTVAR1", "")?; test_shell.set_var("TESTVAR2", "")?; // Complete. let results = test_shell.complete_end_of_line("echo $TESTVAR").await?; assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_variable_names_with_braces() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Set a few vars. test_shell.set_var("TESTVAR1", "")?; test_shell.set_var("TESTVAR2", "")?; // Complete. let results = test_shell.complete_end_of_line("echo ${TESTVAR").await?; assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_help_topic() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete. let results = test_shell.complete_end_of_line("help expor").await?; assert_eq!(results, ["export"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_command_option() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete. let results = test_shell.complete_end_of_line("ls --hel").await?; assert_eq!(results, ["--help"]); Ok(()) } /// Tests completion with some well-known programs that have been good manual test cases /// for us in the past. #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_path_args_to_well_known_programs() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Create file and dir. test_shell.temp_dir.child("item1").touch()?; test_shell.temp_dir.child("item2").create_dir_all()?; // Complete. let results = test_shell.complete_end_of_line("tar tvf ./item").await?; assert_eq!(results, ["./item2"]); Ok(()) } /// Tests some 'find' completion. #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_find_command() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete. let results = test_shell.complete_end_of_line("find . -na").await?; assert_eq!(results, ["-name"]); Ok(()) } #[test_with::file(/usr/share/bash-completion/bash_completion)] #[tokio::test(flavor = "multi_thread")] async fn complete_quoted_filenames() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; test_shell.temp_dir.child("item1 item2").touch()?; test_shell.temp_dir.child("item1'item2").touch()?; let mut results = test_shell.complete_end_of_line("ls item1\\ ").await?; assert_eq!(results, ["item1 item2"]); results = test_shell.complete_end_of_line("ls item1'").await?; assert_eq!(results, ["item1 item2", "item1'item2"]); results = test_shell.complete_end_of_line("ls item1").await?; assert_eq!(results, ["item1 item2", "item1'item2"]); results = test_shell.complete_end_of_line("ls 'item1 ").await?; assert_eq!(results, ["item1 item2"]); results = test_shell.complete_end_of_line("ls \"item1 ").await?; assert_eq!(results, ["item1 item2"]); Ok(()) } /// Tests that interactive completion sets `COMP_KEY` and `COMP_TYPE` to 9 (TAB). #[tokio::test(flavor = "multi_thread")] async fn interactive_completion_sets_comp_key_and_comp_type() -> Result<()> { let mut shell = brush_core::Shell::builder() .profile(brush_core::ProfileLoadBehavior::Skip) .rc(brush_core::RcLoadBehavior::Skip) .default_builtins(brush_builtins::BuiltinSet::BashMode) .build() .await?; // Register a completion function that captures COMP_KEY and COMP_TYPE. let exec_params = shell.default_exec_params(); let source_info = brush_core::SourceInfo::default(); shell .run_string( r" _test_comp() { CAPTURED_COMP_KEY=$COMP_KEY CAPTURED_COMP_TYPE=$COMP_TYPE COMPREPLY=(done) } complete -F _test_comp mycmd " .to_string(), &source_info, &exec_params, ) .await?; // Trigger interactive completion. let _completions = shell.complete("mycmd ", 6).await?; // Check the captured values. let comp_key = shell .env() .get("CAPTURED_COMP_KEY") .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); let comp_type = shell .env() .get("CAPTURED_COMP_TYPE") .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); assert_eq!(comp_key.as_deref(), Some("9"), "COMP_KEY should be 9 (TAB)"); assert_eq!( comp_type.as_deref(), Some("9"), "COMP_TYPE should be 9 (TAB)" ); Ok(()) } // Tests for native completion fallback (without bash-completion installed) struct TestShellNative { shell: brush_core::Shell, temp_dir: assert_fs::TempDir, } impl TestShellNative { async fn new() -> Result { let mut shell = brush_core::Shell::builder() .profile(brush_core::ProfileLoadBehavior::Skip) .rc(brush_core::RcLoadBehavior::Skip) .default_builtins(brush_builtins::BuiltinSet::BashMode) .build() .await?; let temp_dir = assert_fs::TempDir::new()?; shell.set_working_dir(temp_dir.path())?; Ok(Self { shell, temp_dir }) } pub async fn complete_end_of_line_full( &mut self, line: &str, ) -> Result { Ok(self.shell.complete(line, line.len()).await?) } pub fn set_var(&mut self, name: &str, value: &str) -> Result<()> { self.shell .env_mut() .set_global(name, brush_core::ShellVariable::new(value))?; Ok(()) } } /// Tests native variable completion without braces (e.g., $VAR) #[tokio::test(flavor = "multi_thread")] async fn native_complete_variable_names() -> Result<()> { let mut test_shell = TestShellNative::new().await?; // Set test variables. test_shell.set_var("TESTVAR1", "value1")?; test_shell.set_var("TESTVAR2", "value2")?; // Complete. let completions = test_shell .complete_end_of_line_full("echo $TESTVAR") .await?; let results: Vec = completions.candidates.into_iter().collect(); assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); // Variable completions should not be treated as filenames (to avoid escaping $) assert!( !completions.options.treat_as_filenames, "variable completions should not be treated as filenames" ); Ok(()) } /// Tests native variable completion with braces (e.g., ${VAR}) #[tokio::test(flavor = "multi_thread")] async fn native_complete_variable_names_with_braces() -> Result<()> { let mut test_shell = TestShellNative::new().await?; // Set test variables. test_shell.set_var("TESTVAR1", "value1")?; test_shell.set_var("TESTVAR2", "value2")?; // Complete. let completions = test_shell .complete_end_of_line_full("echo ${TESTVAR") .await?; let results: Vec = completions.candidates.into_iter().collect(); assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); // Variable completions should not be treated as filenames (to avoid escaping $) assert!( !completions.options.treat_as_filenames, "variable completions should not be treated as filenames" ); Ok(()) } /// Tests that file completion works after a variable (e.g., $VAR/path) #[tokio::test(flavor = "multi_thread")] async fn native_complete_path_after_variable() -> Result<()> { let mut test_shell = TestShellNative::new().await?; // Create files in temp dir. test_shell.temp_dir.child("file1.txt").touch()?; test_shell.temp_dir.child("file2.txt").touch()?; // Set a variable pointing to the temp dir. let temp_path = test_shell.temp_dir.path().to_str().unwrap().to_owned(); test_shell.set_var("MYDIR", &temp_path)?; // Complete files after variable expansion. // The completion system expands variables before completing, so results use expanded paths. let completions = test_shell .complete_end_of_line_full("ls $MYDIR/file") .await?; let results: Vec = completions.candidates.into_iter().collect(); let expected = [ std::format!("{temp_path}/file1.txt"), std::format!("{temp_path}/file2.txt"), ]; assert_eq!(results, expected); // Path completions should be treated as filenames assert!( completions.options.treat_as_filenames, "path completions should be treated as filenames" ); Ok(()) }