Skip to content

fix(cd): generate only cdable_vars containing valid directory paths #1361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 9, 2025
Merged
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
97 changes: 58 additions & 39 deletions completions/cd
Original file line number Diff line number Diff line change
@@ -1,59 +1,78 @@
# cd(1) completion -*- shell-script -*-

# This meta-cd function observes the CDPATH variable, so that `cd`
# additionally completes on directories under those specified in CDPATH.
_comp_cmd_cd()
_comp_cmd_cd__compgen_cdable_vars()
{
local cur prev words cword comp_args
_comp_initialize -- "$@" || return
shopt -q cdable_vars || return 1

if [[ $cur == -* ]]; then
_comp_compgen_help -c help "$1"
compopt +o nospace
return
fi
local vars
_comp_compgen -v vars -- -v || return "$?"

local i j k
# Remove variables that do not contain a valid directory path.
local _i
for _i in "${!vars[@]}"; do
# Note: ${!vars[_i]} produces the "nounset" error when vars[_i] is an
# empty array name.
[[ -d ${!vars[_i]-} ]] || unset -v 'vars[_i]'
done

compopt -o filenames
_comp_compgen -U vars set "${vars[@]}"
}

# This generator function observes the CDPATH variable, to additionally
# complete directories under those specified in CDPATH.
_comp_cmd_cd__compgen_cdpath()
{
local _p

# Use standard dir completion if no CDPATH or parameter starts with /,
# Generate CDPATH completions when the parameter does not start with /,
# ./ or ../
if [[ ! ${CDPATH-} || $cur == ?(.)?(.)/* ]]; then
_comp_compgen_filedir -d
return
fi
[[ ! ${CDPATH-} || $cur == ?(.)?(.)/* ]] && return 1

local mark_dirs="" mark_symdirs=""
_comp_readline_variable_on mark-directories && mark_dirs=set
_comp_readline_variable_on mark-symlinked-directories && mark_symdirs=set
local _mark_dirs="" _mark_symdirs=""
_comp_readline_variable_on mark-directories && _mark_dirs=set
_comp_readline_variable_on mark-symlinked-directories && _mark_symdirs=set

local -a _cdpaths=()

# we have a CDPATH, so loop on its contents
local paths dirs
local paths dirs _d
_comp_split -F : paths "$CDPATH"
for i in "${paths[@]}"; do
for _p in "${paths[@]}"; do
# create an array of matched subdirs
k=${#COMPREPLY[@]}
_comp_compgen -v dirs -c "$i/$cur" -- -d
for j in "${dirs[@]}"; do
if [[ ($mark_symdirs && -L $j || $mark_dirs && ! -L $j) && ! -d ${j#"$i/"} ]]; then
j+="/"
_comp_compgen -v dirs -c "$_p/$cur" -- -d
for _d in "${dirs[@]}"; do
if [[ ($_mark_symdirs && -L $_d || $_mark_dirs && ! -L $_d) && ! -d ${_d#"$_p/"} ]]; then
_d+="/"
fi
COMPREPLY[k++]=${j#"$i/"}
_cdpaths+=("${_d#"$_p/"}")
done
done
_comp_unlocal paths dirs

_comp_compgen -a filedir -d

if ((${#COMPREPLY[@]} == 1)); then
i=${COMPREPLY[0]}
if [[ $i == "$cur" && $i != "*/" ]]; then
COMPREPLY[0]="${i}/"
if ((${#_cdpaths[@]} == 1)); then
_p=${_cdpaths[0]}
if [[ $_p == "$cur" && $_p != */ ]]; then
_cdpaths[0]=$_p/
fi
fi

_comp_compgen_set "${_cdpaths[@]}"
}

_comp_cmd_cd()
{
local cur prev words cword comp_args
_comp_initialize -- "$@" || return

if [[ $cur == -* ]]; then
_comp_compgen_help -c help "$1"
compopt +o nospace
return
fi

compopt -o filenames
_comp_cmd_cd__compgen_cdable_vars
_comp_cmd_cd__compgen_cdpath
_comp_compgen -a filedir -d
}
if shopt -q cdable_vars; then
complete -v -F _comp_cmd_cd -o nospace cd pushd
else
complete -F _comp_cmd_cd -o nospace cd pushd
fi
complete -F _comp_cmd_cd -o nospace cd pushd
14 changes: 14 additions & 0 deletions test/t/test_cd.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import pytest

from conftest import assert_complete, bash_env_saved


@pytest.mark.bashcomp(ignore_env=r"^\+CDPATH=$")
class TestCd:
@@ -28,3 +30,15 @@ def test_dir_at_point(self, completion):
@pytest.mark.complete("cd -")
def test_options(self, completion):
assert completion

def test_cdable_vars(self, bash):
with bash_env_saved(bash) as bash_env:
bash_env.shopt("cdable_vars", True)
bash_env.write_variable("foo1", "shared")
bash_env.write_variable("foo2", "shared/default")
bash_env.write_variable("foo3", "nonexistent")
bash_env.write_variable("foo4", "nonexistent")
bash_env.write_variable("foo5", "shared/default/foo")
bash_env.write_variable("foo6", "shared/default/bar")
completion = assert_complete(bash, "cd f")
assert completion == ["foo1", "foo2"]