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
2 changes: 0 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ on:
- mkdocs.yml
- LICENSE
pull_request:
branches:
- main

permissions:
contents: read
Expand Down
9 changes: 7 additions & 2 deletions src/tabular/shell.cr
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,13 @@ module Tabular(T)
end

buffer
rescue IO::Error
raise Exception.new stderr.gets_to_end
rescue err : IO::Error
# Surface anything the simulator wrote to stderr — and if it stayed
# silent, fall back to the original IO::Error so the failure isn't
# reported as a bare "Error:" with no detail.
diag = stderr.gets_to_end rescue ""
diag = "#{err.class}: #{err.message} (no stderr captured)" if diag.empty?
raise Exception.new "[#{self} simulator] #{diag}"
end
end

Expand Down
28 changes: 28 additions & 0 deletions src/tabular/shell/bash.ecr
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
# History expansion turns the `!` in `_filedir`'s xspec into a syntax error and
# leaves descriptions unformatted when `COLUMNS` is unset, so harden both up
# front before any completion machinery is loaded.
set +H
export COLUMNS="${COLUMNS:-200}"

<% {% if flag?(:darwin) %} -%>
init="/opt/homebrew/etc/profile.d/bash_completion.sh"
<% {% end %} -%>
<% {% if flag?(:linux) %} -%>
init="/etc/profile.d/bash_completion.sh"
<%- {% end %} -%>
[[ -f "${init}" ]] && . "${init}"

# Patch `_filedir` so an empty `cur` still expands extension filters. Stock
# bash-completion routes empty cur through `_quote_readline_by_ref`, which
# yields the literal `''` and then matches no files; real bash papers over
# this with `complete -o default`, but the mock has no shell to fall back to.
if declare -F _filedir >/dev/null 2>&1; then
eval "__tabular_orig_filedir() $(declare -f _filedir | tail -n +2)"
_filedir() {
if [[ -z ${cur-} && -n ${1-} && ${1-} != "-d" ]]; then
local __xspec="!*.@(${1}|${1^^})"
local __reset
__reset=$(shopt -po noglob)
set -o noglob
COMPREPLY+=( $(compgen -f -X "$__xspec" -o plusdirs -- "") )
$__reset
(( ${#COMPREPLY[@]} > 0 )) && compopt -o filenames 2>/dev/null
return 0
fi
__tabular_orig_filedir "$@"
}
fi

<% completers.each do |comp| -%>
eval "$(<%= comp %> completion <%= bin %>)"
<% end %>
Expand Down
11 changes: 10 additions & 1 deletion src/tabular/shell/fish.ecr
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<% completers.each do |comp| -%>
<%= comp %> completion <%= bin %> | source
<% end %>
while read -l LINE < /dev/stdin
# fish 3.x's `read` builtin closes stdin after the first line when invoked
# from a `-c` script with a piped stdin. The historical workaround was
# `read -l LINE < /dev/stdin`, but on macOS BSD that opens a fresh fd via
# /dev/fd/0 which EOFs on the second iteration and silently kills the loop.
# `head -n 1` sidesteps the buffering quirk on both platforms; relying on
# the simulator being synchronous (one line in / one response out) keeps
# its read-ahead from eating subsequent lines.
while true
set -l LINE (head -n 1)
test -z "$LINE"; and break
complete -C "<%= prog %> $LINE"
printf '\0'
end
133 changes: 83 additions & 50 deletions src/tabular/shell/zsh.ecr
Original file line number Diff line number Diff line change
@@ -1,59 +1,92 @@
# SEE: https://unix.stackexchange.com/questions/668618/how-to-write-automated-tests-for-zsh-completion/668827#668827
#
# Background:
# The Stack Exchange recipe runs `vared -c tmp` inside `zpty {,}fn`, but on
# glibc-based zsh 5.9 builds (Ubuntu 24.04, Debian sid, recent Homebrew)
# that combination corrupts the heap and the pty emits `free(): invalid
# pointer` instead of any completions. Spawning a fresh interactive
# `zsh -fi -c 'source <file>'` inside the zpty gives ZLE its own session and
# sidesteps the crash. We also avoid an EXIT trap on the outer shell — zpty
# propagates inherited traps to the child, so an outer cleanup trap would
# delete the source file out from under the next iteration.
#
# The inner shell prints a READY sentinel right before `vared` activates, so
# the outer shell waits for it before sending input. Without that handshake,
# macOS pty buffering races with `zpty -w` and the TAB lands before ZLE has
# bound it, hanging on `vared` forever.
autoload -U compinit
compinit
compinit -u

<% completers.each do |comp| -%>
eval "$(<%= comp %> completion <%= bin %>)"
<% end %>

# `mktemp -p` is GNU-only; BSD `mktemp` (macOS) treats `-p` as the prefix flag.
# Use an explicit path under TMPDIR (or /tmp) instead, which both honor.
INNER="${TMPDIR:-/tmp}/tabular_<%= pty %>.$$"
: > "${INNER}" || exit 1
{
print -r -- 'autoload -U compinit'
print -r -- 'compinit -u'
<% completers.each do |comp| -%>
print -r -- 'eval "$(<%= comp %> completion <%= bin %>)"'
<% end -%>
print -r -- 'D_OPN=$'\''\C-B'\'
print -r -- 'D_CLS=$'\''\C-C'\'
print -r -- 'D_RDY=$'\''\C-E'\'
print -r -- 'zstyle ":completion:*" max-matches-width 10'
print -r -- 'zstyle ":completion:*" list-colors ""'
print -r -- 'zle -C {,,}complete-word'
print -r -- 'complete-word () {'
print -r -- ' unset "compstate[vared]"'
print -r -- ' compstate[insert]=menu'
print -r -- ' compadd -x "${D_OPN}"'
print -r -- ' _main_complete "$@"'
print -r -- ' if [ -n "$compstate[unambiguous]" ] && [ "$compstate[nmatches]" = "1" ]; then'
print -r -- ' result=${compstate[unambiguous]/#$BASH_REMATCH}'
print -r -- ' echo -e "${D_OPN}\n${result}\n${D_CLS}"'
print -r -- ' fi'
print -r -- ' compadd -J -last- -x "${D_CLS}"'
print -r -- ' exit'
print -r -- '}'
print -r -- 'bindkey "^I" complete-word'
# Trailing newline is required: pty stdout is line-buffered, so a bare
# `\C-E` would sit in the inner shell's buffer and the outer would block.
print -r -- 'print -- "${D_RDY}"'
print -r -- 'vared -c tmp'
} > "${INNER}"

D_OPN=$'\C-B'
D_CLS=$'\C-C'
D_RDY=$'\C-E'

zmodload zsh/zpty

while read -r -t10 -A ARGS; do
zpty <%= pty %> "zsh -fi -c 'source ${INNER}'"

compmock () {
export D_OPN=$'\C-B'
export D_CLS=$'\C-C'

<%= pty %> () {
zstyle ':completion:*' max-matches-width 10 # Ensure alt-names on separate rows
zstyle ':completion:*' list-colors '' # Disable colouring

# Bind a custom widget to TAB.
bindkey '^I' complete-word
zle -C {,,}complete-word
complete-word () {
unset 'compstate[vared]' # Disguise a "normal" command-line
compstate[insert]=menu # Ensure menu is always displayed
compadd -x "${D_OPN}" # Open delimiter
_main_complete "$@" # Run completion
# Intercept unambiguous match
if [ -n "$compstate[unambiguous]" ] && [ "$compstate[nmatches]" = '1' ]; then
result=${compstate[unambiguous]/#$BASH_REMATCH}
echo -e "${D_OPN}\n${result}\n${D_CLS}"
fi
compadd -J -last- -x "${D_CLS}" # close delimiter
exit
}

vared -c tmp # Start line editor
}

zmodload zsh/zpty # Load the pseudo terminal module.
trap 'zpty -d' ABRT EXIT HUP INT QUIT TERM # Delete the pty.

while read -r -t10 -A ARGS; do
zpty {,}<%= pty %> # Create a new pty and run our function in it.
zpty -w -n <%= pty %> "$1 $ARGS[*]"$'\t' # Simulate a command being typed and tabbed.

# Capture terminal output
PTY_LINES=()
while zpty -r <%= pty %> REPLY; do
[[ "${REPLY}" =~ "${D_CLS}" ]] && break
[[ "${REPLY}" =~ "${D_OPN}" ]] \
&& PTY_LINES=() \
|| PTY_LINES+=("${REPLY%%$'\n'}")
done

print -nrC1 -- "${PTY_LINES[@]}" | sed -r -e 's/\x1b\[[0-9;]*m?//g' -e 's/\r//g' -e 's/ *$//g'
print -n -- $'\C-@'
zpty -d <%= pty %>
# Wait until the inner shell has bound TAB and is parked in `vared`.
while zpty -r <%= pty %> REPLY; do
[[ "${REPLY}" == *"${D_RDY}"* ]] && break
done
}

compmock <%= prog %>
zpty -w -n <%= pty %> "<%= prog %> ${ARGS[*]}"$'\t'

PTY_LINES=()
while zpty -r <%= pty %> REPLY; do
[[ "${REPLY}" == *"${D_CLS}"* ]] && break
if [[ "${REPLY}" == *"${D_OPN}"* ]]; then
REPLY="${REPLY##*${D_OPN}}"
PTY_LINES=()
fi
PTY_LINES+=("${REPLY%%$'\n'}")
done

print -nrC1 -- "${PTY_LINES[@]}" \
| sed -r -e 's/\x1b\[[0-9;]*m?//g' -e 's/\r//g' -e 's/ *$//g' \
| sed '/^$/d'
print -n -- $'\C-@'
zpty -d <%= pty %>
done

rm -f "${INNER}"
Loading