Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4143,6 +4143,34 @@ impl Interpreter {
/// Search $PATH for an executable script and run it.
///
/// Returns `Ok(None)` if no matching file found (caller emits "command not found").
/// Resolve a command name to its full path via PATH search on VFS.
/// Returns the resolved path string if found, None otherwise.
async fn resolve_command_path(&self, name: &str) -> Option<String> {
let path_var = self
.variables
.get("PATH")
.or_else(|| self.env.get("PATH"))
.cloned()
.unwrap_or_default();

for dir in path_var.split(':') {
if dir.is_empty() {
continue;
}
let candidate = PathBuf::from(dir).join(name);
if let Ok(meta) = self.fs.stat(&candidate).await {
if meta.file_type.is_dir() {
continue;
}
if meta.mode & 0o111 == 0 {
continue;
}
return Some(candidate.to_string_lossy().to_string());
}
}
None
}

async fn try_execute_script_via_path_search(
&mut self,
name: &str,
Expand Down Expand Up @@ -5107,12 +5135,17 @@ impl Interpreter {

match mode {
'v' => {
// command -v: print name if it's a known command
let found = self.builtins.contains_key(cmd_name.as_str())
|| self.functions.contains_key(cmd_name.as_str())
|| is_keyword(cmd_name);
let mut result = if found {
ExecResult::ok(format!("{}\n", cmd_name))
// command -v: print name/path if it's a known command
let output = if self.functions.contains_key(cmd_name.as_str())
|| self.builtins.contains_key(cmd_name.as_str())
|| is_keyword(cmd_name)
{
Some(cmd_name.to_string())
} else {
self.resolve_command_path(cmd_name).await
};
let mut result = if let Some(name) = output {
ExecResult::ok(format!("{}\n", name))
} else {
ExecResult {
stdout: String::new(),
Expand All @@ -5133,6 +5166,8 @@ impl Interpreter {
format!("{} is a shell builtin\n", cmd_name)
} else if is_keyword(cmd_name) {
format!("{} is a shell keyword\n", cmd_name)
} else if let Some(path) = self.resolve_command_path(cmd_name).await {
format!("{} is {}\n", cmd_name, path)
} else {
return Ok(ExecResult::err(
format!("bash: command: {}: not found\n", cmd_name),
Expand Down
62 changes: 62 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/command_v.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# command -v and command -V tests
# Tests that command -v searches PATH for external scripts (issue #1120)

### command_v_finds_builtin
# command -v finds builtins
command -v echo
echo "exit=$?"
### expect
echo
exit=0
### end

### command_v_finds_function
# command -v finds functions
myfunc() { true; }
command -v myfunc
echo "exit=$?"
### expect
myfunc
exit=0
### end

### command_v_not_found
# command -v returns 1 for unknown commands
command -v nonexistent_cmd_xyz_12345
echo "exit=$?"
### expect
exit=1
### end

### command_v_searches_path
### skip: VFS-only test — real bash doesn't have /scripts on disk
# command -v finds executable scripts on PATH
mkdir -p /scripts
echo '#!/bin/bash' > /scripts/myscript
chmod +x /scripts/myscript
export PATH="/scripts:$PATH"
command -v myscript
echo "exit=$?"
### expect
/scripts/myscript
exit=0
### end

### command_V_builtin
# command -V describes builtins
command -V echo
### expect
echo is a shell builtin
### end

### command_V_path_script
### skip: VFS-only test — real bash doesn't have /scripts on disk
# command -V shows full path for scripts on PATH
mkdir -p /scripts
echo '#!/bin/bash' > /scripts/pathcmd
chmod +x /scripts/pathcmd
export PATH="/scripts:$PATH"
command -V pathcmd
### expect
pathcmd is /scripts/pathcmd
### end
Loading