Bash select 菜单:输入选项文字,为什么没有选中那一项

前天 3阅读

用 Bash 做一个简单菜单,用户输入选项文字以后,变量却变成空串;再按一次空回车,循环体甚至没有执行。select 不等于“把输入文本赋给变量”的普通读取,它会先把选项编号,然后按输入编号选择对应的词。

菜单变量保存选中的选项值,REPLY 保存这一轮读到的输入。两者用途不同:一个用于决定执行哪种动作,一个用于解释用户输入了什么。先区分这两条信息,就不会把非法输入误认为某个空选项,也不会用选项文字去猜编号。

让一组预设输入走过同一份菜单

将代码保存为 demo.sh,执行 bash demo.sh。本文在 Bash 5.2.37 验证。为了可重复,输入来自脚本中的固定文本,菜单和提示收进临时日志;实际交互时去掉这组输入重定向即可从终端读取。示例只打印选择,不执行导出动作。

Bash select 菜单:输入选项文字,为什么没有选中那一项

AI概念插图:用抽象图形表现本文讨论的关系,并非软件截图或真实运行结果。

#!/usr/bin/env bash
set -euo pipefail
demo_dir=$(mktemp -d)
trap 'rm -rf -- "$demo_dir"' EXIT
options=('preview report' 'export copy' 'quit')
seen=()
PS3='Pick a number: '
select choice in "${options[@]}"; do
  seen+=("$REPLY")
  if [[ -z $choice ]]; then
    printf 'invalid: <%s>\n' "$REPLY"
    continue
  fi
  printf 'selected: <%s> reply=<%s>\n' "$choice" "$REPLY"
  [[ $choice == quit ]] && break
done 2>"$demo_dir/menu.log" <<'INPUT'
9
export copy

2
3
INPUT
[[ ${#seen[@]} == 4 ]]
[[ ${seen[0]} == 9 && ${seen[1]} == 'export copy' ]]
[[ ${seen[2]} == 2 && ${seen[3]} == 3 ]]
[[ $choice == quit ]]
[[ -s "$demo_dir/menu.log" ]]
printf 'body calls=%s; menu captured on stderr\n' "${#seen[@]}"

eof_status=0
if select unused in a b; do
  printf 'unexpected selection\n'
  exit 1
done </dev/null 2>>"$demo_dir/menu.log"; then
  eof_status=0
else
  eof_status=$?
fi
[[ $eof_status == 1 ]]
printf 'EOF status=%s\n' "$eof_status"

越界编号与选项文字都会进入无效分支

第一条输入九,超出三个选项的编号范围,因此 choice 为空,REPLY 是九。第二条直接输入 export copy,也没有按编号选中选项,REPLY 保留这段文字。两次都打印 invalid,再通过 continue 等待下一轮。

空行的行为不同:select 重新显示菜单和提示,不执行本轮循环体。因此 seen 最后只有四条记录,而不是输入的五行。这个计数断言避免了把“重新显示菜单”误当成一次已经完成的选择。

之后输入二,choice 才是包含空格的 export copy,REPLY 则是字符二。数组使用双引号展开,使每个菜单标签保持完整。若把标签拼成一串再按空格拆开,菜单编号与业务选项的对应关系就会改变。

输入三后选中 quit,脚本显式 break。选择合法项并不会自动让 select 结束;结束条件由循环体决定。需要一次选择就退出的菜单,应在完成确认后明确退出,不能依赖 select 自己猜测这轮交互是否够了。

标准输出、提示和输入结束各有通道

菜单列表与 PS3 提示写向标准错误,所以代码可以把它们收进日志,同时让选中结果继续留在标准输出。这适合需要捕获结果的命令,但不能因此丢弃真正的错误信息。把提示送到哪里,应是接口设计的一部分。

末尾的第二个 select 从空输入读取,循环体一次都不执行,并在本次测试版本返回状态一。脚本将它放进条件判断中,保存并验证状态。真实交互里的输入结束应有明确退出路径,不能继续沿用上一次合法选择去执行动作。

示例的所有选项都非空,所以用 choice 为空识别无效输入成立。如果业务允许空字符串作为合法标签,空值就不能单独区分两种情况,需要改成明确非空的显示标签和独立标识。保持菜单值简单,通常比增加模糊例外更可靠。

选项文字只是显示内容,不应直接当命令执行。接入真实动作时,用固定分支把选项映射到事先写好的函数,并在需要时确认目标。REPLY 也只是用户输入的记录,本例不会把它交给算术或代码解释器再次求值。

测试一个小菜单,至少覆盖合法编号、越界编号、文字输入、空行和输入结束。菜单看起来正常不代表异常分支正确;把循环执行次数和最终选择都写进断言,能更早发现重复执行或错误复用旧值的问题。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。