Zsh Custom Widget

自定义 Zsh Widget 来实现自动注释/反注释功能。

基本命令

查看已有 Widgets: zle -al

新增一个 Widget:zle -N <funcName>

绑定 Widget:bindkey <keycode> <widgetName>

单行版本

.zshrc 中加入下列代码:

autocomment() {
  local CONTENT="$BUFFER"
  if [[ ${#CONTENT} = "0" ]]; then
    return;
  fi
  if [[ "$CONTENT" = "#"* ]]; then
    CURSOR=$((CURSOR - 1))
    BUFFER="${CONTENT:1}"
  else
    CURSOR=$((CURSOR + 1)) # 移动光标位置
    BUFFER="#$BUFFER"
  fi
}

zle -N autocomment
bindkey '^_' autocomment

CURSOR 变量的限制

CURSOR 变量什么时候赋值是有讲究的。

根据 Zle 文档 的说法:

CURSOR (integer)
(…) This is in the range 0 to $#BUFFER (…) Attempts to move the cursor outside the buffer will result in the cursor being moved to the appropriate end of the buffer.

也就是说,给 CURSOR 赋的值不能超过 $#BUFFER

可以简单验证一下:

testCursor() {
  echo "\nOriginal C: $CURSOR"
  BUFFER="a"
  echo "Change Buffer C: $CURSOR"
  CURSOR=$((CURSOR+10))
  echo "Force edit C: $CURSOR"
  CURSOR=100
  echo "Force assign C: $CURSOR"
  BUFFER="ab"
  echo "Change Buffer C: $CURSOR"
  CURSOR=$((CURSOR+10))
  echo "Force edit C: $CURSOR"
  CURSOR=100
  echo "Force assign C: $CURSOR"
}
zle -N testCursor
bindkey '^_' testCursor

其结果是:

variable CURSOR is always in range [0, $#BUFFER]

variable CURSOR is always in range [0, $#BUFFER]

可以看到,CURSOR 变量不能随意赋值。

  # ...
  if [[ "$CONTENT" = "#"* ]]; then
    CURSOR=$((CURSOR - 1))
    BUFFER="${CONTENT:1}"
  else
  # ...

因此对于这一段脚本,假设当前的 CURSOR 已经在文本结尾(等于 $#BUFFER)。

如果先赋值 BUFFER 再赋值 CURSOR,那么实际情形是在给 BUFFER 赋值之后,CURSOR 变量已经被减去了 1,再减一次就会出现问题。

同样的,插入注释符号时,也应该先给 BUFFER 赋值再修改 CURSOR,否则修改会失效。

绑定到组合键

bindkey '^_' autocomment

其中,^_ 代表组合键 Ctrl+/。绑定其他组合键可以使用 read 命令或者按一下 Ctrl+V(在 bash/zsh 里代表 quoted-insert),可以查看组合键对应的输出。

注意,如果使用了 zsh-syntax-highlighting,请按照官方指引,在 .zshrc 结尾处 source 它的脚本,而不是写到 plugins 数组里。

这是因为 z-sy-h 需要 wrap zle widgets 来使 BUFFER 被修改之后能被重新染色。

当然,也可以手动将 _zsh_highlight 注册成 zle,然后在函数结尾通过
zle _zsh_highlight 手动调用——但是为什么要这么做呢?


这个版本的 autocomment 有一个问题:只能对单行输入工作。接下来会将其修改成多行版本。

多行版本

autocomment() {
  local CONTENT="$BUFFER"
  if [[ ${#CONTENT} = "0" ]]; then
    return;
  fi

  local CONTENT_ARRAY
  IFS=$'\n' CONTENT_ARRAY=("${(@f)CONTENT}")

  local COMMENT_MODE="uncomment"
  for ((i = 1; i <= ${#CONTENT_ARRAY[@]}; i++)); do
    if [[ ! ${CONTENT_ARRAY[$i]} = "#"* ]]; then
      COMMENT_MODE="comment"
      break
    fi
  done

  local LINE_NUMBER=1
  local CHARS_POS=0
  for ((i = 1; i <= ${#CONTENT_ARRAY[@]}; i++)); do
    CHARS_POS=$(( CHARS_POS + ${#CONTENT_ARRAY[$i]} + 1 ))
    if (( $CHARS_POS <= $CURSOR )); then
      LINE_NUMBER=$((LINE_NUMBER + 1))
    fi
    if [[ $COMMENT_MODE = "comment" ]]; then
      CONTENT_ARRAY[$i]="#${CONTENT_ARRAY[$i]}"
    else
      CONTENT_ARRAY[$i]="${CONTENT_ARRAY[$i]:1}"
    fi
  done
  if [[ $COMMENT_MODE = "uncomment" ]]; then
    CURSOR=$((CURSOR - LINE_NUMBER))
  fi
  BUFFER=$(join_array $'\n' "${CONTENT_ARRAY[@]}")
  if [[ $COMMENT_MODE = "comment" ]]; then
    CURSOR=$((CURSOR + LINE_NUMBER))
  fi
}

多行版本主要存在如下几个要点:

  1. 从功能上讲,只有所有行都以 # 开头时才能认为输入是被程序化处理过的注释。只要有一行不是注释,其余所有的已注释行都应该认为是人工输入的注释,而非本脚本生成的。 因此首先需要判别 COMMENT_MODE

  2. 由于每行都要插入或删除一个注释符号,通过计算字符长度来判断光标在第几行,并以行号为依据修改光标位置。注意 CHARS_POS 需要额外 +1,代表换行符。

脚本编写的时候需要特别注意的一点是 zsh 中使用 IFS 分割数组的方法,参见 Parameter Expansion Flags

bash 中,可以这样分割数组:

CONTENT="a b c"
IFS=' ' CONTENT_ARRAY=($CONTENT)
echo ${#CONTENT_ARRAY[@]}

而在 zsh 中,需要这样:

CONTENT="a b c"
IFS=' ' CONTENT_ARRAY=("${(@ps:$IFS:)CONTENT}")
echo ${#CONTENT_ARRAY[@]}

其中,@f@ps:\n: 的缩写,上面的脚本中用的就是这个。

join_array

join_array 是取自 shell-utils 的一个函数,具体参见原文。


Reference:

  1. man 1 zshbuiltins
  2. man 1 zshzle
  3. ZLE Doc