From 11aa05921e2f8d5a6809aba91cc3c88730143f03 Mon Sep 17 00:00:00 2001 From: David Mandelberg Date: Sat, 26 Apr 2025 20:23:26 -0400 Subject: [PATCH] feat(tmux): new completion From https://github.com/tmux/tmux/pull/259 it doesn't look like the tmux maintainers were interested in having a bash completion script in the upstream repo. However, I added license headers to put these new files under either bash-completion's license or tmux's, in case they want it there in the future. I'm aware of a handful of existing tmux bash completion scripts, below. As far as I can tell, they all hard-code a decent amount of tmux's available options, commands, etc. Some are also abandoned and out of date with more recent versions of tmux. Rather than base this code off of those, I decided to implement completion using tmux's own introspection commands as much as possible. Hopefully that will reduce the ongoing maintenance work and make it stay up to date with most tmux changes automatically. This commit has a relatively minimal set of completions, see the TODO in _comp_cmd_tmux__value(). I have code for more completions in varying states of readiness, but this commit is already pretty large. I'll make follow-up PR(s) for those. I'm willing to maintain this script. (And I'm hoping that the design mentioned above will make that easier.) Existing implementations that I'm aware of: * https://github.com/Bash-it/bash-it/blob/master/completion/available/tmux.completion.bash * https://github.com/Boruch-Baum/tmux_bash_completion * https://github.com/imomaliev/tmux-bash-completion * https://github.com/scop/bash-completion/pull/81 * https://github.com/srsudar/tmux-completion --- completions/Makefile.am | 1 + completions/tmux | 318 ++++++++++++++++++++++++++++++++++++++++ test/t/Makefile.am | 1 + test/t/test_tmux.py | 169 +++++++++++++++++++++ 4 files changed, 489 insertions(+) create mode 100644 completions/tmux create mode 100644 test/t/test_tmux.py diff --git a/completions/Makefile.am b/completions/Makefile.am index f40943db9d7..d6ec001e358 100644 --- a/completions/Makefile.am +++ b/completions/Makefile.am @@ -446,6 +446,7 @@ cross_platform = 2to3 \ tcpnice \ timeout \ tipc \ + tmux \ _tokio-console \ tox \ tracepath \ diff --git a/completions/tmux b/completions/tmux new file mode 100644 index 00000000000..b3ccafbd294 --- /dev/null +++ b/completions/tmux @@ -0,0 +1,318 @@ +# tmux(1) completion -*- shell-script -*- +# SPDX-License-Identifier: GPL-2.0-or-later OR ISC + +# Log a message to help with debugging. +# If BASH_COMPLETION_DEBUG is set, it will be printed to stderr. +# When running with `set -x`, the _comp_cmd_tmux__log call itself will be +# printed. +# +# @param $1 Message to log +_comp_cmd_tmux__log() +{ + if [[ ${BASH_COMPLETION_DEBUG-} ]]; then + printf 'tmux bash completion: %s\n' "$1" >&2 + fi +} + +# Run the tmux command being completed. +# +# @param $@ args to tmux +_comp_cmd_tmux__run() +{ + local -a REPLY + _comp_dequote "${comp_args[0]-}" || REPLY=("${comp_args[0]-}") + LC_ALL=C "${REPLY[0]}" "$@" +} + +# Parse usage output from tmux. +# +# @param $1 Usage from tmux, not including the (sub)command name. +# @var[out] options associative array mapping options to their value types, or +# to the empty string if the option doesn't take a value +# @var[out] args indexed array of positional arg types +_comp_cmd_tmux__parse_usage() +{ + options=() + args=() + + local i j + local words + _comp_split words "$1" + for ((i = 0; i < ${#words[@]}; i++)); do + case ${words[i]} in + "[-"*"]") + # One or more options that don't take arguments, either of the + # form `[-abc]` or `[-a|-b|-c]` + for ((j = 2; j < ${#words[i]} - 1; j++)); do + if [[ ${words[i]:j:1} != [-\|] ]]; then + options+=(["-${words[i]:j:1}"]="") + fi + done + ;; + "[-"*) + # One option that does take an argument. + if [[ ${words[i + 1]-} != *"]" ]]; then + _comp_cmd_tmux__log \ + "Can't parse option: '${words[*]:i:2}' in '$1'" + break + fi + options+=(["${words[i]#"["}"]="${words[i + 1]%"]"}") + ((i++)) + ;; + -*) + _comp_cmd_tmux__log "Can't parse option '${words[i]}' in '$1'" + break + ;; + *) + # Start of positional arguments. + args=("${words[@]:i}") + break + ;; + esac + done + + if [[ ${BASH_COMPLETION_DEBUG-} || -o xtrace ]]; then + local arg + for arg in "${!options[@]}"; do + _comp_cmd_tmux__log "option: ${arg} ${options["$arg"]}" + done + for arg in "${args[@]}"; do + _comp_cmd_tmux__log "arg: ${arg}" + done + fi +} + +# Complete a value either as the argument to an option or as a positional arg. +# +# @param $1 subcommand that the value is for, or 'tmux' if it's top-level +# @param $2 type of the value, from _comp_cmd_tmux__parse_usage() +_comp_cmd_tmux__value() +{ + local subcommand=$1 option_type=$2 + _comp_cmd_tmux__log \ + "Trying to complete '$option_type' for subcommand '$subcommand'" + + # To get a list of these argument types, look at `tmux -h` and: + # + # tmux list-commands -F "#{command_list_usage}" | + # sed 's/[][ ]/\n/g' | + # grep -v ^- | + # sort -u + # + # TODO: Complete more option types. + case $option_type in + command) + _comp_compgen_split -l -- "$(_comp_cmd_tmux__run \ + list-commands -F "#{command_list_name}")" + ;; + directory | *-directory) + _comp_compgen_filedir -d + ;; + file | *-file | path | *-path) + _comp_compgen_filedir + ;; + esac +} + +# Parse command line options to tmux or a subcommand. +# +# @param $@ args to tmux or a subcommand, starting with the (sub)command +# itself, ending before the current word to complete +# @var[in] options from _comp_cmd_tmux__parse_usage() +# @var[out] option_type if the word to complete is the value of an option, this +# is the type of that value, otherwise it's empty +# @var[out] positional_start if option_type is empty, index in $@ of the first +# positional argument, or the last index plus 1 if the next word is the +# first positional argument, or -1 if the next word could be either the +# first positional argument or another option +_comp_cmd_tmux__options() +{ + local command_args=("$@") + option_type="" + positional_start=-1 + + local i + for ((i = 1; i < ${#command_args[@]}; i++)); do + if [[ $option_type ]]; then + # arg to the previous option + option_type="" + elif [[ ${command_args[i]} == -- ]]; then + option_type="" + ((positional_start = i + 1)) + return + elif [[ ${command_args[i]} == -?* ]]; then + # 1 or more options, possibly also with the value of an option. + # E.g., if `-a` and `-b` take no values and `-c` does, `-ab` would + # be equivalent to `-a -b` and `-acb` would be `-a` and `-c` with a + # value of `b`. + local j + for ((j = 1; j < ${#command_args[i]}; j++)); do + if [[ $option_type ]]; then + # arg has both the option and its value + option_type="" + break + fi + option_type=${options["-${command_args[i]:j:1}"]-} + done + else + # first positional arg + ((positional_start = i)) + return + fi + done +} + +# Complete arguments to a subcommand. +# +# @param $@ the subcommand followed by its args, ending before the current word +# to complete +_comp_cmd_tmux__subcommand() +{ + local subcommand_args=("$@") + local usage=$(_comp_cmd_tmux__run list-commands \ + -F "#{command_list_name} #{command_list_usage}" -- "$1" 2>/dev/null) + if [[ ! $usage ]]; then + _comp_cmd_tmux__log "Unknown tmux subcommand: '$1'" + return + fi + local subcommand=${usage%% *} # not $1, because it could be an alias + _comp_cmd_tmux__log "Attempting completion for 'tmux $subcommand'" + + local -A options + local -a args + _comp_cmd_tmux__parse_usage "${usage#* }" + + local option_type + local positional_start + _comp_cmd_tmux__options "${subcommand_args[@]}" + + if [[ $option_type ]]; then + _comp_cmd_tmux__value "$subcommand" "$option_type" + return + elif ((positional_start < 0)) && [[ $cur == -* ]]; then + _comp_compgen -- -W '"${!options[@]}"' + return + elif ((positional_start < 0)); then + # $cur (one past the end of subcommand_args) is the first positional + # arg + positional_start=${#subcommand_args[@]} + fi + + if [[ $subcommand == display-menu ]]; then + # display-menu has a non-trivial repeating pattern of positional args + # that would need custom logic to support correctly, and it's probably + # used in config files or shell scripts more than interactively anyway. + _comp_cmd_tmux__log \ + "Not completing positional args for 'tmux $subcommand'" + return + fi + + _comp_cmd_tmux__log \ + "'tmux $subcommand' first positional arg: '${subcommand_args[positional_start]-}'" + + local args_index=$positional_start + local usage_args_index + local prev_arg_type="" + for ((\ + usage_args_index = 0; \ + usage_args_index < ${#args[@]}; \ + args_index++, usage_args_index++)); do + local arg_type=${args[usage_args_index]##+(\[)} + arg_type=${arg_type%%+(\])} + if [[ $arg_type == ... ]]; then + if ((usage_args_index == 0)); then + _comp_cmd_tmux__log "'tmux $subcommand' first arg is '...'" + return + elif ((usage_args_index != ${#args[@]} - 1)); then + _comp_cmd_tmux__log \ + "'tmux $subcommand' usage has '...' before last arg" + return + fi + # https://man.openbsd.org/style says "Usage statements should take + # the same form as the synopsis in manual pages." + # https://mandoc.bsd.lv/mdoc/intro/synopsis_util.html says "Some + # commands can optionally take more than one argument of the same + # kind. This is indicated by an ellipsis trailing the respective Ar + # macro." tmux seems to mostly use `...` in that way to only repeat + # the previous argument. In display-menu it uses `...` to repeat + # all arguments instead, but that subcommand is explicitly ignored + # above. + _comp_cmd_tmux__value "$subcommand" "$prev_arg_type" + return + elif [[ $arg_type == arguments ]]; then + if [[ $prev_arg_type == command ]] && + ((usage_args_index == ${#args[@]} - 1)); then + # The usage ends in `command arguments`, so recurse to the new + # subcommand. + _comp_cmd_tmux__subcommand \ + "${subcommand_args[@]:args_index-1}" + return + else + _comp_cmd_tmux__log \ + "'tmux $subcommand' has unsupported 'arguments' in usage" + return + fi + elif ((args_index == ${#subcommand_args[@]})); then + # The usage arg is 1 past the end of $subcommand_args, so complete + # it. + _comp_cmd_tmux__value "$subcommand" "$arg_type" + return + fi + prev_arg_type=$arg_type + done + + _comp_cmd_tmux__log "Too many args to 'tmux $subcommand'" +} + +_comp_cmd_tmux() +{ + local cur prev words cword comp_args + _comp_initialize -- "$@" || return + + local usage + usage=$(_comp_cmd_tmux__run -h 2>&1) + # Before https://github.com/tmux/tmux/pull/4455 (merged 2025-04-09), `-h` + # produced usage information because it was an error, so we have to trim + # the error message too. + usage=${usage#$'tmux: unknown option -- h\n'} + usage=${usage#usage: tmux } + + local -A options + local -a args + _comp_cmd_tmux__parse_usage "$usage" + + local option_type + local positional_start + _comp_cmd_tmux__options "${words[@]:0:cword}" + + if [[ $option_type ]]; then + _comp_cmd_tmux__value tmux "$option_type" + return + elif ((positional_start < 0)) && [[ $cur == -* ]]; then + _comp_compgen -- -W '"${!options[@]}"' + return + elif ((positional_start < 0)); then + ((positional_start = cword)) + fi + + local i + local -a REPLY + local subcommand_start=$positional_start + for ((i = positional_start; i < cword; i++)); do + if _comp_dequote "${words[i]}" && [[ ${REPLY[-1]-} =~ (\\*)\;$ ]] && + ((${#BASH_REMATCH[1]} % 2 == 0)); then + # end of current command + ((subcommand_start = i + 1)) + fi + done + + if ((cword == subcommand_start)); then + _comp_cmd_tmux__value tmux command + else + _comp_cmd_tmux__subcommand \ + "${words[@]:subcommand_start:cword-subcommand_start}" + fi +} && + complete -F _comp_cmd_tmux tmux + +# ex: filetype=sh diff --git a/test/t/Makefile.am b/test/t/Makefile.am index 8b70c935cee..7ed95e424fa 100644 --- a/test/t/Makefile.am +++ b/test/t/Makefile.am @@ -624,6 +624,7 @@ EXTRA_DIST = \ test_time.py \ test_timeout.py \ test_tipc.py \ + test_tmux.py \ test_totem.py \ test_touch.py \ test_tox.py \ diff --git a/test/t/test_tmux.py b/test/t/test_tmux.py new file mode 100644 index 00000000000..73ee0a54a9b --- /dev/null +++ b/test/t/test_tmux.py @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: GPL-2.0-or-later OR ISC + +import pytest + +from conftest import assert_complete + + +@pytest.mark.bashcomp(cmd="tmux", require_cmd=True) +class TestTmux: + # Tests for _comp_cmd_tmux__parse_usage(). Most of this function is tested + # elsewhere since almost everything else depends on it. These are just + # tests for corner cases that aren't tested elsewhere. + + @pytest.mark.complete("tmux wait-for -") + def test_parse_usage_alternative_options(self, completion): + """Tests the [-a|-b|-c] form of option parsing.""" + assert "-L" in completion + assert "-S" in completion + assert "-U" in completion + assert "-|" not in completion + assert "--" not in completion + + # Tests for _comp_cmd_tmux__value() + + @pytest.mark.complete("tmux new-") + def test_value_command(self, completion): + assert "new-session" in completion + assert "new-window" in completion + + @pytest.mark.complete("tmux new-window -c ", cwd="shared/default") + def test_value_directory(self, completion): + assert completion == ["bar bar.d/", "foo.d/"] + + @pytest.mark.complete("tmux -f ", cwd="shared/default") + def test_value_file(self, completion): + assert completion == ["bar", "bar bar.d/", "foo", "foo.d/"] + + # Tests for _comp_cmd_tmux__options() + + @pytest.mark.complete("tmux -f /dev/null ") + def test_option_with_value(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -Nvf /dev/null ") + def test_option_multiple_with_value(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -f -f ") + def test_option_with_value_with_dash(self, completion): + """Tests that the second -f is a filename not an option.""" + assert "new-session" in completion + + @pytest.mark.complete("tmux -- ") + def test_option_explicit_end(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -f/dev/null ") + def test_option_with_attached_value(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -Nvf/dev/null ") + def test_option_multiple_with_attached_value(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -v ") + def test_option_without_value(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux -Nv ") + def test_option_multiple_without_value(self, completion): + assert "new-session" in completion + + # Tests for _comp_cmd_tmux__subcommand() + + @pytest.mark.complete("tmux this-is-not-a-real-subcommand-i-hope ") + def test_subcommand_unknown(self, completion): + assert not completion + + @pytest.mark.complete("tmux new-window -c ", cwd="shared/default") + def test_subcommand_option_value(self, completion): + assert completion == ["bar bar.d/", "foo.d/"] + + @pytest.mark.complete("tmux new-window -") + def test_subcommand_options(self, completion): + assert "-a" in completion # takes no value + assert "-c" in completion # takes a value + + @pytest.mark.complete("tmux display-menu ") + def test_subcommand_no_positional_arg_completion(self, completion): + assert not completion + + @pytest.mark.complete("tmux source-file abc def ", cwd="shared/default") + def test_subcommand_repetition(self, completion): + assert completion == ["bar", "bar bar.d/", "foo", "foo.d/"] + + @pytest.mark.complete( + "tmux bind-key C-a new-window -c ", + cwd="shared/default", + ) + def test_subcommand_recursion(self, completion): + assert completion == ["bar bar.d/", "foo.d/"] + + @pytest.mark.complete("tmux source-file ", cwd="shared/default") + def test_subcommand_positional_arg_1(self, completion): + assert completion == ["bar", "bar bar.d/", "foo", "foo.d/"] + + @pytest.mark.complete("tmux bind-key C-a ") + def test_subcommand_positional_arg_2(self, completion): + assert "new-session" in completion + + @pytest.mark.complete("tmux start-server ") + def test_subcommand_no_positional_args(self, completion): + assert not completion + + @pytest.mark.complete("tmux choose-tree abc def ghi ") + def test_subcommand_too_many_positional_args(self, completion): + assert not completion + + # Tests for _comp_cmd_tmux() + + @pytest.mark.complete("tmux -f ", cwd="shared/default") + def test_tmux_option_value(self, completion): + assert completion == ["bar", "bar bar.d/", "foo", "foo.d/"] + + @pytest.mark.complete("tmux -") + def test_tmux_options(self, completion): + assert "-f" in completion # takes a value + assert "-v" in completion # takes no value + + @pytest.mark.parametrize( + "other_commands", + [ + r"foo ';'", + r"foo';'", + r"foo '\\;'", # backslash escaped in tmux + r"foo'\\;'", + ], + ) + def test_tmux_multiple_commands(self, bash, other_commands): + completion = assert_complete( + bash, + f"tmux {other_commands} bind-key C-a ", + ) + assert "new-session" in completion + + @pytest.mark.parametrize( + "semicolon_arg", + [ + r"foo '\;'", # semicolon escaped in tmux + r"foo'\;'", + r"foo '\\\;'", # escaped backslash then escaped semicolon in tmux + r"foo'\\\;'", + r"foo';'bar", # semicolon doesn't need to be escaped in the middle + ], + ) + def test_tmux_semicolon_within_subcommand(self, bash, semicolon_arg): + completion = assert_complete( + bash, + # Note that the next arg is a file, not a subcommand. If it were a + # subcommand, it wouldn't be possible to tell if the completion was + # parsing semicolon_arg correctly. + f"tmux source-file {semicolon_arg} ", + cwd="shared/default", + ) + assert completion == ["bar", "bar bar.d/", "foo", "foo.d/"] + + @pytest.mark.complete("tmux ") + def test_tmux_subcommand(self, completion): + assert "new-session" in completion