Skip to content

Commit 0ed8058

Browse files
committed
feat(tmux): new completion
From tmux/tmux#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 * #81 * https://github.com/srsudar/tmux-completion
1 parent b1374fc commit 0ed8058

File tree

4 files changed

+473
-0
lines changed

4 files changed

+473
-0
lines changed

completions/Makefile.am

+1
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ cross_platform = 2to3 \
445445
tcpnice \
446446
timeout \
447447
tipc \
448+
tmux \
448449
_tokio-console \
449450
tox \
450451
tracepath \

completions/tmux

+297
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# tmux(1) completion -*- shell-script -*-
2+
# SPDX-License-Identifier: GPL-2.0-or-later OR ISC
3+
4+
# Log a message to help with debugging.
5+
# If BASH_COMPLETION_DEBUG is set, it will be printed to stderr.
6+
# When running with `set -x`, the _comp_cmd_tmux__log call itself will be
7+
# printed.
8+
#
9+
# @param $1 Message to log
10+
_comp_cmd_tmux__log()
11+
{
12+
if [[ ${BASH_COMPLETION_DEBUG-} ]]; then
13+
printf 'tmux bash completion: %s\n' "$1" >&2
14+
fi
15+
}
16+
17+
# Parse usage output from tmux.
18+
#
19+
# @param $1 Usage from tmux, not including the (sub)command name.
20+
# @var[out] options associative array mapping options to their value types, or
21+
# to the empty string if the option doesn't take a value
22+
# @var[out] args indexed array of positional arg types
23+
_comp_cmd_tmux__parse_usage()
24+
{
25+
options=()
26+
args=()
27+
28+
local i j
29+
local words
30+
_comp_split words "$1"
31+
for ((i = 0; i < ${#words[@]}; i++)); do
32+
case "${words[i]}" in
33+
"[-"*"]")
34+
# One or more options that don't take arguments, either of the
35+
# form `[-abc]` or `[-a|-b|-c]`
36+
for ((j = 2; j < ${#words[i]} - 1; j++)); do
37+
if [[ ${words[i]:j:1} != [-\|] ]]; then
38+
options+=(["-${words[i]:j:1}"]="")
39+
fi
40+
done
41+
;;
42+
"[-"*)
43+
# One option that does take an argument.
44+
if [[ ${words[i + 1]} != *"]" ]]; then
45+
_comp_cmd_tmux__log \
46+
"Can't parse option: '${words[*]:i:2}' in '$1'"
47+
break
48+
fi
49+
options+=(["${words[i]#"["}"]="${words[i + 1]%"]"}")
50+
((i++))
51+
;;
52+
-*)
53+
_comp_cmd_tmux__log "Can't parse option '${words[i]}' in '$1'"
54+
break
55+
;;
56+
*)
57+
# Start of positional arguments.
58+
args=("${words[@]:i}")
59+
break
60+
;;
61+
esac
62+
done
63+
64+
local arg
65+
for arg in "${!options[@]}"; do
66+
_comp_cmd_tmux__log "option: ${arg} ${options["$arg"]}"
67+
done
68+
for arg in "${args[@]}"; do
69+
_comp_cmd_tmux__log "arg: ${arg}"
70+
done
71+
}
72+
73+
# Complete a value either as the argument to an option or as a positional arg.
74+
#
75+
# @param $1 subcommand that the value is for, or 'tmux' if it's top-level
76+
# @param $2 type of the value, from _comp_cmd_tmux__parse_usage()
77+
_comp_cmd_tmux__value()
78+
{
79+
local subcommand="$1" option_type="$2"
80+
_comp_cmd_tmux__log \
81+
"Trying to complete '$option_type' for subcommand '$subcommand'"
82+
83+
# To get a list of these argument types, look at `tmux -h` and:
84+
#
85+
# tmux list-commands -F "#{command_list_usage}" |
86+
# sed 's/[][ ]/\n/g' |
87+
# grep -v ^- |
88+
# sort -u
89+
#
90+
# TODO: Complete more option types.
91+
case "$option_type" in
92+
command)
93+
_comp_compgen_split -l -- \
94+
"$(LC_ALL=C tmux list-commands -F "#{command_list_name}")"
95+
;;
96+
directory | *-directory)
97+
_comp_compgen_filedir -d
98+
;;
99+
file | *-file | path | *-path)
100+
_comp_compgen_filedir
101+
;;
102+
esac
103+
}
104+
105+
# Parse command line options to tmux or a subcommand.
106+
#
107+
# @param $@ args to tmux or a subcommand, starting with the (sub)command
108+
# itself, ending before the current word to complete
109+
# @var[in] options from _comp_cmd_tmux__parse_usage()
110+
# @var[out] option_type if the word to complete is the value of an option, this
111+
# is the type of that value, otherwise it's empty
112+
# @var[out] positional_start if option_type is empty, index in $@ of the first
113+
# positional argument, or the last index plus 1 if the next word is the
114+
# first positional argument, or -1 if the next word could be either the
115+
# first positional argument or another option
116+
_comp_cmd_tmux__options()
117+
{
118+
local command_args=("$@")
119+
option_type=""
120+
positional_start=-1
121+
122+
local i
123+
for ((i = 1; i < ${#command_args[@]}; i++)); do
124+
if [[ $option_type ]]; then
125+
# arg to the previous option
126+
option_type=""
127+
elif [[ ${command_args[i]} == -- ]]; then
128+
option_type=""
129+
((positional_start = i + 1))
130+
return
131+
elif [[ ${command_args[i]} == -?* ]]; then
132+
# 1 or more options, possibly also with the value of an option.
133+
# E.g., if `-a` and `-b` take no values and `-c` does, `-ab` would
134+
# be equivalent to `-a -b` and `-acb` would be `-a` and `-c` with a
135+
# value of `b`.
136+
local j
137+
for ((j = 1; j < ${#command_args[i]}; j++)); do
138+
if [[ $option_type ]]; then
139+
# arg has both the option and its value
140+
option_type=""
141+
break
142+
fi
143+
option_type="${options["-${command_args[i]:j:1}"]-}"
144+
done
145+
else
146+
# first positional arg
147+
((positional_start = i))
148+
return
149+
fi
150+
done
151+
}
152+
153+
# Complete arguments to a subcommand.
154+
#
155+
# @param $@ the subcommand followed by its args, ending before the current word
156+
# to complete
157+
_comp_cmd_tmux__subcommand()
158+
{
159+
local subcommand_args=("$@")
160+
local usage="$(LC_ALL=C tmux list-commands \
161+
-F "#{command_list_name} #{command_list_usage}" -- "$1" 2>/dev/null)"
162+
if [[ ! $usage ]]; then
163+
_comp_cmd_tmux__log "Unknown tmux subcommand: '$1'"
164+
return
165+
fi
166+
local subcommand="${usage%% *}" # not $1, because it could be an alias
167+
_comp_cmd_tmux__log "Attempting completion for 'tmux $subcommand'"
168+
169+
local -A options
170+
local -a args
171+
_comp_cmd_tmux__parse_usage "${usage#* }"
172+
173+
local option_type
174+
local positional_start
175+
_comp_cmd_tmux__options "${subcommand_args[@]}"
176+
177+
if [[ $option_type ]]; then
178+
_comp_cmd_tmux__value "$subcommand" "$option_type"
179+
return
180+
elif ((positional_start < 0)) && [[ $cur == -* ]]; then
181+
_comp_compgen -- -W '"${!options[@]}"'
182+
return
183+
elif ((positional_start < 0)); then
184+
# $cur (one past the end of subcommand_args) is the first positional
185+
# arg
186+
positional_start=${#subcommand_args[@]}
187+
fi
188+
189+
_comp_cmd_tmux__log \
190+
"'tmux $subcommand' first positional arg: '${subcommand_args[positional_start]-}'"
191+
192+
local args_index="$positional_start"
193+
local usage_args_index
194+
local prev_arg_type=""
195+
for ((\
196+
usage_args_index = 0; \
197+
usage_args_index < ${#args[@]}; \
198+
args_index++, usage_args_index++)); do
199+
local arg_type="${args[usage_args_index]##+(\[)}"
200+
arg_type="${arg_type%%+(\])}"
201+
if [[ $arg_type == ... ]]; then
202+
if ((usage_args_index == 0)); then
203+
# Prevent an infinite loop.
204+
_comp_cmd_tmux__log "'tmux $subcommand' first arg is '...'"
205+
return
206+
elif ((usage_args_index != ${#args[@]} - 1)); then
207+
_comp_cmd_tmux__log \
208+
"'tmux $subcommand' usage has '...' before last arg"
209+
return
210+
fi
211+
# Repeat from the beginning of args.
212+
usage_args_index=-1
213+
((args_index--))
214+
elif [[ $arg_type == arguments ]]; then
215+
if [[ $prev_arg_type == command ]] &&
216+
((usage_args_index == ${#args[@]} - 1)); then
217+
# The usage ends in `command arguments`, so recurse to the new
218+
# subcommand.
219+
_comp_cmd_tmux__subcommand \
220+
"${subcommand_args[@]:args_index-1}"
221+
return
222+
else
223+
_comp_cmd_tmux__log \
224+
"'tmux $subcommand' has unsupported 'arguments' in usage"
225+
return
226+
fi
227+
elif ((args_index == ${#subcommand_args[@]})); then
228+
# The usage arg is 1 past the end of $subcommand_args, so complete
229+
# it.
230+
_comp_cmd_tmux__value "$subcommand" "$arg_type"
231+
return
232+
fi
233+
prev_arg_type="$arg_type"
234+
done
235+
236+
_comp_cmd_tmux__log "Too many args to 'tmux $subcommand'"
237+
}
238+
239+
_comp_cmd_tmux()
240+
{
241+
local cur prev words cword comp_args
242+
_comp_initialize -- "$@" || return
243+
244+
local usage
245+
usage="$(LC_ALL=C tmux -h 2>&1)"
246+
# Before https://github.com/tmux/tmux/pull/4455 (merged 2025-04-09), `-h`
247+
# produced usage information because it was an error, so we have to trim
248+
# the error message too.
249+
usage="${usage#$'tmux: unknown option -- h\n'}"
250+
usage="${usage#usage: tmux }"
251+
252+
local -A options
253+
local -a args
254+
_comp_cmd_tmux__parse_usage "$usage"
255+
256+
local option_type
257+
local positional_start
258+
_comp_cmd_tmux__options "${words[@]:0:cword}"
259+
260+
if [[ $option_type ]]; then
261+
_comp_cmd_tmux__value tmux "$option_type"
262+
return
263+
elif ((positional_start < 0)) && [[ $cur == -* ]]; then
264+
_comp_compgen -- -W '"${!options[@]}"'
265+
return
266+
elif ((positional_start < 0)); then
267+
((positional_start = cword))
268+
fi
269+
270+
local i
271+
local subcommand_start="$positional_start"
272+
for ((i = positional_start; i < cword; i++)); do
273+
if [[ ${words[i]} =~ (\\*)\;$ ]] &&
274+
((${#BASH_REMATCH[1]} % 4 == 1)); then
275+
# end of current command
276+
((subcommand_start = i + 1))
277+
elif [[ ${words[i]} =~ (\\*)\;\'$ ]] &&
278+
((${#BASH_REMATCH[1]} % 2 == 0)); then
279+
# end of current command
280+
((subcommand_start = i + 1))
281+
elif [[ ${words[i]} =~ (\\*)\;\"$ ]] &&
282+
(((${#BASH_REMATCH[1]} + 1) % 4 <= 1)); then
283+
# end of current command
284+
((subcommand_start = i + 1))
285+
fi
286+
done
287+
288+
if ((cword == subcommand_start)); then
289+
_comp_cmd_tmux__value tmux command
290+
else
291+
_comp_cmd_tmux__subcommand \
292+
"${words[@]:subcommand_start:cword-subcommand_start}"
293+
fi
294+
} &&
295+
complete -F _comp_cmd_tmux tmux
296+
297+
# ex: filetype=sh

test/t/Makefile.am

+1
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,7 @@ EXTRA_DIST = \
623623
test_time.py \
624624
test_timeout.py \
625625
test_tipc.py \
626+
test_tmux.py \
626627
test_totem.py \
627628
test_touch.py \
628629
test_tox.py \

0 commit comments

Comments
 (0)