【ArchSummit架构师峰会】探讨数据与人工智能相互驱动的关系>>> 了解详情
写点什么

如何编写 bash 自动补全脚本

  • 2018-09-03
  • 本文字数:12524 字

    阅读完需:约 41 分钟

什么是 bash 的自动补全

Bash 自动补全是为了帮助用户能够更快、更容易输入命令的一项功能。它能够在用户输入命令时敲击 tab 键后,提供可能的选项。

复制代码
$ git<tab><tab>
git                 git-receive-pack    git-upload-archive  
gitk                git-shell           git-upload-pack     
$ git-s<tab>
$ git-shell

工作原理

Bash 补全脚本是一段使用 bash 内置命令command的代码,用于定义哪些补全建议可以对特定的可执行程序显示。这些补全建议既可以是简单的静态内容,也可以是高度复杂的。

为什么要使用

自动补全功能能够为用户提供以下便利:

  • 当可以自动完成时,帮助用户减少文本输入;
  • 让用户知道输入的命令后续可以有哪些可选的参数;
  • 避免输入错误,同时通过用户已经输入的内容隐藏或者展示可选项以提高用户体验。

开始上手

下面我们将开始一个演示。

首选,我们将会创建一个名为 dothis 的模拟可执行脚本。该脚本接受一个参数,表示用户执行历史中的序号,并执行序号对应的历史命令。例如,以下命令将会执行用户历史命令中序号为 235 的命令(我电脑上对应的是ls -a命令):

dothis 235

然后,我们将创建一个 bash 自动补全脚本,用以展示用户历史命令信息,并和dothis命令“绑定”起来。

$ dothis <tab><tab>
215 ls
216 ls -la
217 cd ~
218 man history
219 git status
220 history | cut -c 8-

读者可以在位于 GitHub 上的本教程代码仓库中看见gif 演示动图。

现在让我们开始吧。

创建可执行脚本

在工作目录中创建名为dothis的文件,并添加以下代码:

if [ -z "$1" ]; then
  echo "No command number passed"
  exit 2
fi

exists=$(fc -l -1000 | grep ^$1 -- 2>/dev/null)

if [ -n "$exists" ]; then
  fc -s -- "$1"
else
  echo "Command with number $1 was not found in recent history"
  exit 2
fi

注意:

  • 脚本首先检查调用时是否跟随这一个参数。
  • 检查输入的数字是否在最近 1000 个命令中:
    • 如果存在则使用fc命令执行对应的命令;
    • 如果不存在则显示错误信息。

使用以下命令给脚本添加可执行权限:

chmod +x ./dothis

由于在后面的教程中将多次执行这个脚本,因此我建议将其放到系统 PATH 环境变量所指定的目录中,这样我们就能够直接输入dothis来执行它。

我将这个脚本安装到了我的$HOME/bin目录中:

install ./dothis ~/bin/dothis

如果您的系统中~/bin目录也在PATH环境变量中,也可以用这种方式安装。

现在让我们来验证脚本:

dothis

我们应该可以看见这样的输出:

$ dothis
No command number passed

搞定。

创建自动补全脚本

创建一个名为dothis-completion.bash的文件,为了方便描述,从现在开始称该文件为自动补全脚本。

一旦在该文件中添加了一些代码,我们都需要source它以生效。注意,后面每次修改文件之后,都需要source这个文件。

后续我们将讨论如何让这个自动补全脚本在 bash 每次打开时自动生效。

静态补全

假设dothis应用支持一系列子命令,例如:

  • now
  • tomorrow
  • never

我们可以使用 bash 内置的complete命令来注册这个补全列表。用专业术语来说,我们通过complete命令为我们的应用定义了一个补全规范(completion specification,compspec)。

将以下内容添加到自动补全脚本中:

#/usr/bin/env bash
complete -W "now tomorrow never" dothis

上述内容使用 complete 命令定义了:

  • 通过 -W 参数提供了补全词列表;
  • 指定该补全词列表适用的应用程序(这里作为dothis命令参数)。

前面提到过,每次编辑补全脚本后,都需要source该文件:

source ./dothis-completion.bash

现在让我们尝试在命令行中敲击两次 tab 键:

$ dothis <tab><tab>
never     now       tomorrow

再来试下输入字母 n 之后的效果:

$ dothis n<tab><tab>
never now

神奇!补全列表自动过滤出了只以字母 n 开头的选项。

注意:补全参数列表显示的顺序和我们在补全脚本中定义的顺序不同,它们已经经过自动排序。

除了这里使用的-W参数之外,command命令还有许多其他参数。大部分参数都以固定的方式生成补全列表,这意味着我们无法动态干预过滤它们的输出结果。

例如,如果我们想将当前目录下的子目录名作为dothis应用程序的补全列表,可以将complete命令做如下修改:

complete -A directory dothis

此时,在 dothis 命令之后敲 tab 键,我们可以获取当前目录下子目录的列表:

$ dothis <tab><tab>
dir1/ dir2/ dir3/

更多关于complete命令的参数参见这里

动态补全

本小节中,我们将实现带有以下逻辑的dothis可执行程序的自动补全:

  • 如果用户在命令后面直接按 tab 键,将显示用户执行历史中的最近 50 个命令。
  • 如果用户在输入一个能够从执行历史中匹配到多个命令的数字后按 tab 键,将显示这些命令以及它们的序号。
  • 如果用户在输入一个从执行历史中只能匹配到一个命令的数字后按 tab 键,将自动补全这个数字,而不显示命令内容(如果这个描述有些迷糊,看了后面的内容会能够有更好的理解,放心)。

让我们从定义一个每次dothis命令补全时都会调用的函数。将补全脚本改成这样:

#/usr/bin/env bash
_dothis_completions()
{
  COMPREPLY+=("now")
  COMPREPLY+=("tomorrow")
  COMPREPLY+=("never")
}
 
complete -F _dothis_completions dothis

对该脚本的一些说明:

  • 我们使用complete命令的-F参数定义_dothis_completions函数为 dothis 命令提供补全功能。
  • COMPREPLY 是一个存储补全列表的数组,自动补全机制使用该变量来显示补全内容。

现在让我们重新 source 下补全脚本,验证下补全功能:

$ dothis <tab><tab>
never now tomorrow

完美,补全脚本能够输出和之前一样的补全词列表。等等,好像不是?再来试下:

$ dothis nev<tab><tab>
never     now       tomorrow

我们可以看到,虽然我们在输入了nev字母后再触发了自动补全,显示的补全列表和之前的一样并没有做自动过滤,这是为什么呢?

  • COMPREPLY变量的内容总是会显示,补全函数需要自己处理其中的内容。
  • 如果COMPREPLY变量中只有一个元素,那么这个词会自动补全到命令之后。由于目前的实现总是返回相同的三个词,不会触发这个功能。

使用compgen命令:它是一个用于生成补全列表的内置命令,支持complete命令的大部分参数(例如-W参数指定补全词列表,-d参数补全目录),并能够基于用户已经输入的内容进行过滤。

如果有些迷惑也不用着急,下面通过一些命令及其输出来展示它的使用:

$ compgen -W "now tomorrow never"
now
tomorrow
never
$ compgen -W "now tomorrow never" n
now
never
$ compgen -W "now tomorrow never" t
tomorrow

通过这些示例,我们已经可以使用该命令了,不过在此之前,还需要了解为获取dothis命令已经输入的内容。bash 自动补全功能提供了相关变量以支撑这个自动补全。这里是一些比较重要的变量:

  • COMP_WORDS:当前命令行中已经输入的词数组。
  • COMP_CWORD:当前光标所处词位于COMP_WORDS数组中的索引值。既当按下 tab 键时光标所处词的索引。
  • COMP_LINE:当前命令行。

为了获取dothis命令后面的词,我们可以使用COMP_WORDS[1]的值。

再次修改自动补全脚本:

#/usr/bin/env bash
_dothis_completions()
{
  COMPREPLY=($(compgen -W "now tomorrow never" "${COMP_WORDS[1]}"))
}

complete -F _dothis_completions dothis

source 该文件查看效果:

$ dothis
never     now       tomorrow  
$ dothis n
never  now

现在,让我们抛开now、never、tomorrow这些词,从命令执行历史中抓取真实的数字。

fc -l命令后面增加一个负数 -n 可以显示最近执行过的 n 条命令。因此我们将会使用:

fc -l -50

命令来显示执行历史中的最近 50 条命令以及它们的序号。这里我们唯一需要处理的是将原始命令输出的制表符替换成空格,以便于更好的展示。这个工作由sed来完成。

将自动补全脚本做如下改动:

#/usr/bin/env bash
_dothis_completions()
{
  COMPREPLY=($(compgen -W "$(fc -l -50 | sed 's/\t//')" -- "${COMP_WORDS[1]}"))
}

complete -F _dothis_completions dothis

在控制台中 source 该脚本并验证:

$ dothis <tab><tab>
632 source dothis-completion.bash   649 source dothis-completion.bash   666 cat ~/.bash_profile
633 clear                           650 clear                           667 cat ~/.bashrc
634 source dothis-completion.bash   651 source dothis-completion.bash   668 clear
635 source dothis-completion.bash   652 source dothis-completion.bash   669 install ./dothis ~/bin/dothis
636 clear                           653 source dothis-completion.bash   670 dothis
637 source dothis-completion.bash   654 clear                           671 dothis 6546545646
638 clear                           655 dothis 654                      672 clear
639 source dothis-completion.bash   656 dothis 631                      673 dothis
640 source dothis-completion.bash   657 dothis 150                      674 dothis 651
641 source dothis-completion.bash   658 dothis                          675 source dothis-completion.bash
642 clear                           659 clear                           676 dothis 651
643 dothis 623  ls -la              660 dothis                          677 dothis 659
644 clear                           661 install ./dothis ~/bin/dothis   678 clear
645 source dothis-completion.bash   662 dothis                          679 dothis 665
646 clear                           663 install ./dothis ~/bin/dothis   680 clear
647 source dothis-completion.bash   664 dothis                          681 clear
648 clear                           665 cat ~/.bashrc

效果不错。但是还存在一个问题,当我们输入一个数字之后再按 tab 键,会出现:

$ dothis 623<tab>
$ dothis 623  ls 623  ls -la
...
$ dothis 623  ls 623  ls 623  ls 623  ls 623  ls -la

出现这个问题是因为在自动补全脚本中,我们使用了${COMP_WORDS[1]}来获取dothis命令之后的第一个词(在上述代码片段中为 623)。因此当 tab 键按下时,相同的自动补全列表会一再出现。

要修复这个问题,我们将在已经输入了至少一个参数之后,不再允许继续进行自动补全。因此需要在函数中增加对COMP_WORDS数组大小的前置判断:

#/usr/bin/env bash
_dothis_completions()
{
  if [ "${#COMP_WORDS[@]}" != "2" ]; then
    return
  fi

  COMPREPLY=($(compgen -W "$(fc -l -50 | sed 's/\t//')" -- "${COMP_WORDS[1]}"))
}

complete -F _dothis_completions dothis

source 脚本并重试:

$ dothis 623<tab>
$ dothis 623 ls -la<tab> # 成功:此时没有触发自动补全

当前脚本还有一个不尽如人意的地方。我们希望展示历史记录序号给用户的同时展示对应的命令,以帮助用户决定选择哪个历史命令。但是当补全建议中有且只有一个时候,应该能够通过自动补全机制自动选择,而不要追加命令文本

因为dothis命令实际只接受一个表示执行历史序号的参数,并且没有对多余参数进行校验。当我们的自动补全函数计算出只有一个结果时,应该去除序号后面的命令文本,只返回命令序号。

为了实现这个功能,我们需要将compgen命令的返回值保存到数组变量中,并且检查当其大小,当大小为 1 时,去除这个唯一的值数字后面跟随的文本;否则直接返回这个数组。

将自动补全脚本修改成:

#/usr/bin/env bash
_dothis_completions()
{
  if [ "${#COMP_WORDS[@]}" != "2" ]; then
    return
  fi

  # keep the suggestions in a local variable
  local suggestions=($(compgen -W "$(fc -l -50 | sed 's/\t/ /')" -- "${COMP_WORDS[1]}"))

  if [ "${#suggestions[@]}" == "1" ]; then
    # if there's only one match, we remove the command literal
    # to proceed with the automatic completion of the number
    local number=$(echo ${suggestions[0]/%\ */})
    COMPREPLY=("$number")
  else
    # more than one suggestions resolved,
    # respond with the suggestions intact
    COMPREPLY=("${suggestions[@]}")
  fi
}

complete -F _dothis_completions dothis

注册自动补全脚本

如果我们希望将自动补全脚本应用到个人账户,可以在.bashrc 文件中 source 这个脚本:

source <path-to-your-script>/dothis-completion.bash

如果我们需要为机器上的所有用户启动这个自动补全脚本,可以将该脚本复制到/etc/bash_completion.d/目录中,这样 bash 会自动加载。

最后调优

为了有更好的展示效果,额外增加几个步骤:)

在新行中展示每个条目

在我实际工作中编写的 bash 自动补全脚本中,补全建议也由两部分组成。我希望能够将第一部分用默认颜色展示,而第二部分用灰色展示,以告知用户这仅仅是帮助文本。以本教程为例,应该把数字用默认颜色展示,而命令文本用另一个不那么花哨的颜色展示。

不幸的是,目前为止这个功能还无法实现,因为自动补全项仅仅以纯文本方式展示,而不会处理其中的颜色指令(例如:\e[34mBlue)。

因此这里我们对于提升用户体验(也有可能没有提升:D)的方法是将每一个补全项换行显示。这个方案实现起来也没有那么方便,因为我们无法简单的通过在每个COMPREPLY项后追加换行符来实现。为了实现这个功能,这里采用了 hach 的方式将补全建议文本填充到控制台的宽度。

通过printf命令可以实现将字符串填充到指定长度。如果需要这项功能,将自动补全脚本做如下修改:

#/usr/bin/env bash
_dothis_completions()
{
  if [ "${#COMP_WORDS[@]}" != "2" ]; then
    return
  fi

  local IFS=$'\n'
  local suggestions=($(compgen -W "$(fc -l -50 | sed 's/\t//')" -- "${COMP_WORDS[1]}"))

  if [ "${#suggestions[@]}" == "1" ]; then
    local number="${suggestions[0]/%\ */}"
    COMPREPLY=("$number")
  else
    for i in "${!suggestions[@]}"; do
      suggestions[$i]="$(printf '%*s' "-$COLUMNS"  "${suggestions[$i]}")"
    done

    COMPREPLY=("${suggestions[@]}")
  fi
}

complete -F _dothis_completions dothis

source 并验证:

dothis <tab><tab>
...
499 source dothis-completion.bash                   
500 clear
...       
503 dothis 500

可定制行为

在我们的之前的自动补全脚本中,将补全项数量写死了最后 50 个执行历史。这在实际使用中不太友好。我们应该让每个用户能够有自己的选择余地,如果他们没有选择,再使用默认值 50。

为了实现这个功能,我们将检查是否设置了环境变量DOTHIS_COMPLETION_COMMANDS_NUMBER

最后一次修改自动补全脚本:

#/usr/bin/env bash
_dothis_completions()
{
  if [ "${#COMP_WORDS[@]}" != "2" ]; then
    return
  fi

  local commands_number=${DOTHIS_COMPLETION_COMMANDS_NUMBER:-50}
  local IFS=$'\n'
  local suggestions=($(compgen -W "$(fc -l -$commands_number | sed 's/\t//')" -- "${COMP_WORDS[1]}"))

  if [ "${#suggestions[@]}" == "1" ]; then
    local number="${suggestions[0]/%\ */}"
    COMPREPLY=("$number")
  else
    for i in "${!suggestions[@]}"; do
      suggestions[$i]="$(printf '%*s' "-$COLUMNS"  "${suggestions[$i]}")"
    done

    COMPREPLY=("${suggestions[@]}")
  fi
}

complete -F _dothis_completions dothis

source 并验证:

export DOTHIS_COMPLETION_COMMANDS_NUMBER=5
$ dothis <tab><tab>
505 clear
506 source ./dothis-completion.bash
507 dothis clear
508 clear
509 export DOTHIS_COMPLETION_COMMANDS_NUMBER=5

有用的链接

源码和评论

本教程源码位于 GitHub 。任何反馈、评论、勘误请在代码仓库中提交 issue

结尾,上猫照

让我来介绍下我的调试器。



(译者注:原作者特意嘱咐我们别忘了上猫照 ^_^)

查看英文原文: Creating a bash completion script

感谢张婵对本文的审校。

2018-09-03 18:215452

评论

发布
暂无评论
发现更多内容

Java基础 ——入坑必读

攻城狮杰森

Java 7月月更

图的存储结构及方法(一)

乔乔

7月月更

教你学c++算法题中最简单的二分,我不允许还有人不会!!!!

KEY.L

7月月更

沉淀2年的 Jira 自动化经验分享

跟YY哥学Jira

RPA 自动化 Jira

K3S - 轻量级Kubernetes集群

mengzyou

DevOps k8s k3s

百度搜索打击盗版网文站点:互联网内容侵权现象为何屡禁不止

石头IT视角

MySQL消息队列表结构

极客土豆

来,滑动到下一个小姐姐

岛上码农

flutter ios 前端 安卓开发 7月月更

Go 并发编程基础:什么是上下文

宇宙之一粟

并发编程 Go 语言 7月月更

【K8s入门必看】第二篇 —— 快速部署集群指南

Albert Edison

Docker Kubernetes 容器 云原生 7月月更

Docker 常用命令整合

宁在春

Docker 7月月更

网络安全之ARP欺骗防护

网络安全学海

网络安全 安全 信息安全 渗透测试 漏洞挖掘

【刷题记录】18. 四数之和

WangNing

7月月更

主题域模型

奔向架构师

数据仓库 7月月更

项目升级遇到的坑

技术小生

7月月更

Istio架构扩展机制

阿泽🧸

istio 7月月更

利用Python浅尝算法分析

迷彩

算法复杂度 7月月更 算法分析

思维导图学《On Java》基础卷

Yano

Java

互联网流量编排方案

穿过生命散发芬芳

7月月更 流量编排

QDS08 curl 安装

耳东@Erdong

curl qds 7月月更

Redis 事务学习有感

恒山其若陋兮

7月月更

zookeeper-curator开源框架介绍

zarmnosaj

7月月更

谈谈文字两端对齐的css问题

南极一块修炼千年的大冰块

7月月更

Python干货——内置函数

Java学术趴

7月日更

Java 虚拟机的概念是怎么来的

HoneyMoose

iOS中#define和const

NewBoy

ios 前端 移动端 iOS 知识体系 7月月更

springboot 项目打包优化(核心 class 与依赖 jar 分离)

安逸的咸鱼

Java maven SpringBoot 2 7月月更

Java多线程之锁优化与JUC常用类

未见花闻

7月月更

OSI七层模型有哪七层?每一层分别有啥作用,这篇文章讲的明明白白!

wljslmz

OSI七层协议 网络技术 7月月更

【函数式编程实战】(三)Lambda表达式原理与函数式接口精讲

小明Java问道之路

Java Lambda 后端 java8 7月月更

python小知识-python格式化

AIWeker

Python python小知识 7月月更

如何编写bash自动补全脚本_语言 & 开发_Lazarus Lazaridis_InfoQ精选文章