该文章翻译自FISH SHELL SCRIPTING MANUAL,因本人才疏学浅,难免有错误/不通顺的地方,还望读者在评论区不吝赐教。

通过例子学习如何编写 fish shell 脚本。

脚本顶部的 shebang 行

为了在终端中运行 fish 脚本,你需要做如下两件事:

  1. 将以下 shebang 行添加到脚本顶部:#!/usr/bin/env fish
  2. 使用以下命令将文件标记为可执行:chmod +x <你的 fish 脚本文件名>

如何设置变量

注意,在 fish 中可以赋给变量的所有类型的值都是字符串,没有布尔值、整数或浮点数等概念。下面是一个给变量赋值的简单示例。这里有关于此的更多信息

1
set MY_VAR "some value"

你可以做的最有用的事情之一就是把 shell 中执行命令的输出存储到一个变量中。在你测试某个程序或命令是否返回了一些需要你执行其它命令的值(使用字符串比较、 if 语句和 switch 语句)时,这是很有用的。下面是一些这样做的例子。

1
2
set CONFIG_FILE_DIFF_OUTPUT (diff ~/Downloads/config.fish ~/.config/fish/config.fish)
set GIT_STATUS_OUTPUT (git status --porcelain)

变量作用域: local,global,global-export

有时你需要把变量导出到子进程,有时你需要把变量导出到全局作用域。还有一些时候,你希望把变量限制在正在编写的函数的局部作用域内。fish 文档中的 set函数部分有关于此的更多信息。

  • 把变量限制在函数的局部作用域内(即使有同名的全局变量)应该使用set -l。这种类型的变量不在整个 fish shell 可用。一个例子是仅用于保存函数范围内某个值的局部变量,如set -l fname (realpath)
  • 使用set -x导出变量(仅在当前 fish shell 可用)。例如在一个运行在crontab headless 环境的 fish 函数中为 X11 会话设置DISPLAY环境变量。
  • 使用set -gx全局导出变量(可用于操作系统的任何程序,而不仅仅是当前运行的 fish shell 进程)。例如为本机上运行的所有程序设置JAVA_HOME环境变量。

列表

以下是一个在变量中追加值的例子。默认情况下 fish 变量是列表。

1
set MY_VAR $MY_VAR "another value"

这是创建列表的方法。

1
set MY_LIST "value1" "value2" "value3"

存储运行命令的返回值

下面是一个将命令执行返回的值存储到变量中的示例。

1
2
3
set OUR_VAR (math 1+2)
set OUR_VAR (date +%s)
set OUR_VAR (math $OUR_VAR / 60)

因为所有的 fish 变量都是列表,你可以使用 [n]运算符访问单个元素,其中n=1表示第一个元素(而不是0)。下面是一个例子。负数表示从末尾访问元素。

1
2
3
4
5
set LIST one two three
echo $LIST[1] # one
echo $LIST[2] # two
echo $LIST[3] # three
echo $LIST[-1] # 和上一行等同

范围

你可以使用变量/列表的范围,接着上面的例子。

1
2
3
4
set LIST one two three
echo $LIST[1..2] # one two
echo $LIST[2..3] # two three
echo $LIST[-1..2] # three two

如何编写 for 循环

因为变量默认包含列表,所以很容易遍历他们。这是一个例子:

1
2
3
4
5
6
set FOLDERS bin
set FOLDERS $FOLDERS .atom
set FOLDERS $FOLDERS "my foldername"
for FOLDER in $FOLDERS
echo "item: $FOLDER"
end

你也可以把set命令放在同一行以简化上述代码,就像这样:

1
2
3
4
set FOLDERS bin .atom "my foldername"
for FOLDER in $FOLDERS
echo "item: $FOLDER"
end

还可以把整个 for 语句放到单行,像这样:

1
2
set FOLDERS bin .atom "my foldername"
for FOLDER in $FOLDERS ; echo "item: $FOLDER" ; end

如何编写 if 语句

编写 if 语句的关键是使用test命令对某个表达式求布尔值。可以是字符串比较,甚至是测试文件或文件夹的存在。下面是一些例子。你还可以使用not运算符作为test的前缀,以检查逆条件。

常用的条件

检查数组的大小。$argv包含从命令行传递给脚本的参数列表。

1
2
3
4
5
6
if test (count $argv) -lt 2
echo "Usage: my-script <arg1> <arg2>"
echo "Eg: <arg1> can be 'foo', <arg2> can be 'bar'"
else
echo "👋 Do something with $arg1 $arg2"
end

变量的字符串比较。

1
2
3
if test $hostname = "mymachine"
echo "hostname is mymachine"
end

检查文件是否存在:

1
2
3
if test -e "somefile"
echo "somefile exists"
end

检查文件夹是否存在:

1
2
3
if test -d "somefolder"
echo "somefolder exists"
end

检查文件通配符是否存在与文件和文件夹的检查略有不同。原因在于 fish 处理通配符的方式——在对它们执行任何命令之前,fish 会首先将其展开。

1
2
3
4
5
6
7
set -l files ~/Downloads/*.mp4 # 这个通配符表达式被展开,包含了实际的文件
if test (count $files) -gt 0
mv ~/Downloads/*.mp4 ~/Videos/
echo "📹 Moved '$files' to ~/Videos/"
else
echo "⛔ No mp4 files found in Downloads"
end

一个在上述示例中使用not运算符的示例:

1
2
3
if not test -d "somefolder"
echo "somefolder does not exist"
end

程序,脚本或函数的退出码

使用退出码的想法是,你的函数或者整个 fish 脚本可以被能够理解退出码的其他程序使用。换句话说,可能会有 if 语句使用退出码来确定某个条件。这是与其它命令行程序一起使用的一个非常常见的模式。退出码不同于函数的返回值

下面是一个使用git命令的退出码的示例:

1
2
3
4
5
if (git pull -f --rebase)
echo "git pull with rebase worked without any issues"
else
echo "Something went wrong that requires manual intervention, like a merge conflict"
end

一个测试命令执行是否没有错误的示例:

1
2
3
if sudo umount /media/user/mountpoint
echo "Successfully unmounted /media/user/mountpoint"
end

你还可以检查$status变量的值。fish 会在执行命令后将返回值存储在这个变量中。这儿有关于此的更多信息

当你写函数时,可以使用以下关键字来退出函数或循环:returnreturn后可能会跟随一个数字,它的意思是:

  1. returnreturn 0 - 表示函数正常退出。
  2. return 1或者其他大于0的数字 - 表示函数出了些问题。

你可以使用exit退出 fish shell 本身。整数退出码的含义与上述相同。

set -q 和 test -z 的不同

在 if 语句中使用set -qtest -z检查变量是否为空时,有一些细微的区别。

  1. 在使用test -z时,请确保使用引号包裹变量,因为如果变量不在引号中,该命令可能在某些边缘情况下出错。
  2. 不过,你可以使用set -q来测试是否已经设置了变量,而无需将其括在引号中。

下面是示例:

1
2
3
set GIT_STATUS (git status --porcelain)
if set -q $GIT_STATUS ; echo "No changes in repo" ; end
if test -z "$GIT_STATUS" ; echo "No changes in repo" ; end

带有 and,or 运算符的多条件判断

如果想把多个条件组合到单个语句中,可以使用orand运算符。如果想检查条件的逆,你可以使用!。下面是一个函数的示例,该函数用于检查命令行传递的两个参数。这是我们描述的逻辑:

  1. 如果两个参数都缺失,应该向命令行打印帮助信息,然后提前return
  2. 如果其中一个参数缺失,则显示一个提示,说明其中一个参数缺失,然后提前return
1
2
3
4
5
6
7
8
9
10
11
12
13
function requires-two-arguments
# 没有参数传入
if set -q "$argv"
echo "Usage: requires-two-arguments arg1 arg2"
return 1
end
# 只传入一个参数
if test -z "$argv[1]"; or test -z "$argv[2]"
echo "arg1 or arg2 can not be empty"
return 1
end
echo "Thank you, got 1) $argv[1] and 2) $argv[2]"
end

下面是一些代码的注释:

  1. set -q $variable函数做了什么?当$variable是空时,它返回 true。

  2. 如果你想使用test函数替换掉set -q来判断一个变量是否存在,可以使用:

    • if test -z "$variable"
    • if test ! -n "$variable"if not test -n "$variable"
  3. 如果你想要把上面的or检查替换为test,它看起来会像:if test -z "$argv[1]"; or test -z "$argv[2]"

    译者注:上面的代码本来就是test -z,这句话有点画蛇添足了。

    推测作者的代码原文可能是set -q "$argv[1]"; or set -q "$argv[2]",所以才会有这种表述。

  4. 当你使用orand运算符时,你必须使用;来结束条件表达式 。

  5. 确保把变量括在空引号中。如果变量中包含一个空字符串,那么如果没有这些引号,语句将导致错误。

这是另一个用于测试$variable是否为空的例子:

1
if test -z "$variable" ; echo "empty" ; else ; echo "non-empty" ; end

这是另一个用于测试$variable是否包含字符串的例子:

1
if test -n "$variable" ; echo "non-empty" ; else ; echo "empty" ; end

另一个常见运算符: not

下面是一个使用not运算符测试字符串是否包含字符串片段的示例:

1
2
3
4
5
if not string match -q "*md" $argv[1]
echo "The argument passed does not end in md"
else
echo "The argument passed ends in md"
end

参考资料

  1. test command
  2. set command
  3. if command
  4. stackoverflow answer: how to check if fish variable is empty
  5. stackoverflow answer: how to put multiple conditions in fish if statement

如何使用分隔符拆分字符串

在某些情况下,您希望获取命令的输出(一个字符串),然后用某个分隔符将其分割,以便只使用输出字符串的一部分。例如获取给定文件的 SHA 校验和。命令shasum <filename>产生类似df..d8 <filename>的输出。假设我们只想要这个字符串的第一部分(SHA),已知分隔符是两个空格字符,我们可以执行以下操作来获取校验和部分,并将其存储在$checksum中。这里有关于string split命令的更多信息

1
2
3
set CHECKSUM_ARRAY_STRING (shasum $FILENAME)
set CHECKSUM_ARRAY (string split " " $SOURCE_CHECKSUM_ARRAY)
set CHECKSUM $CHECKSUM_ARRAY[1]

如何执行字符串比较

为了测试字符串中的子串匹配,你可以使用string match命令。这里有关于该命令的更多信息:

  1. string match 的官方文档
  2. 关于如何使用它的 Stackoverflow 回答

下面是一个实际应用的例子。注意,使用-q--quiet时,如果匹配条件满足(成功),它不会打印字符串的输出。

1
2
3
4
5
if string match -q "*myname*" $hostname
echo "$hostname contains myname"
else
echo "$hostname does not contain myname"
end

下面是一个字符串精确匹配的示例:

1
2
3
4
5
if test $hostname = "machine-name"
echo "Exact match"
else
echo "Not exact match"
end

一个测试字符串是否为空的示例:

1
2
3
if set -q $my_variable
echo "my_variable is empty"
end

下面是一个复杂的示例,测试是否安装了ruby-devruby-bundler包。如果是,则运行jekyll;如果不是,则安装这些包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Return "true" if $packageName is installed, and "false" otherwise.
# Use it in an if statement like this:
#
# if string match -q "false" (isPackageInstalled my-package-name)
# echo "my-package-name is not installed"
# else
# echo "my-package-name is installed"
# end
function isPackageInstalled -a packageName
set packageIsInstalled (dpkg -l "$packageName")
if test -z "$packageIsInstalled"
set packageIsInstalled false
else
set packageIsInstalled true
end
echo $packageIsInstalled
end

# More info to find if a package is installed: https://askubuntu.com/a/823630/872482
if test (uname) = "Linux"

echo "🐒isPackageInstalled does-not-exist:" (isPackageInstalled does-not-exist)

if string match -q "false" (isPackageInstalled ruby-dev) ;
or string match -q "false" (isPackageInstalled ruby-bundler)
# Install ruby
echo "ruby-bundler or ruby-dev are not installed; installing now..."
echo sudo apt install -y ruby-bundler ruby-dev
else
bundle install
bundle update
bundle exec jekyll serve
end

end

如何为字符串编写 switch 语句

为了为字符串创建 switch 语句,这里也使用了test命令(就像用于 if 语句一样)。case语句需要匹配子字符串,可以使用通配符和想要匹配的子字符串的组合来表示子字符串。这是一个示例。

1
2
3
4
5
6
switch $hostname
case "*substring1*"
echo "Matches $hostname containing substring1"
case "*substring2*"
echo "Matches $hostname containing substring2"
end

你也可以把这些和 if 语句混合使用,最终看起来会像这样:

1
2
3
4
5
6
7
8
9
10
11
if test (uname) = "Darwin"
echo "Machine is running macOS"
switch $hostname
case "*MacBook-Pro*"
echo "hostname has MacBook-Pro in it"
case "*MacBook-Air*"
echo "hostname has MacBook-Air in it"
end
else
echo "Machine is not running macOS"
end

如何执行字符串

执行脚本中生成的字符串最安全的方法是使用以下模式。

1
2
echo "ls \
-la" | sh

这不仅使调试更容易,还在使用\进行多行换行时避免了奇怪的错误。

如何编写函数

一个 fish 函数只是一个可选地接受参数的命令列表。这些参数作为列表传入(因为 fish 的所有变量都是列表)。

这是一个例子:

1
2
3
4
5
6
function say_hi
echo "Hi $argv"
end
say_hi
say_hi everbody!
say_hi you and you and you

写完一个函数后,你可以通过使用type查看它是什么。例如:type say_hi将展示你刚刚创建的函数。

向函数传递参数

除了使用$argv找出传递给函数的参数外,你还可以提供一个函数所期望的具名参数列表。这是官方文档中的更多信息

需要记住的一些关键事项:

  1. 参数名不能含有-字符,使用_替代。
  2. 不要使用()向函数传递参数,只需要在带空格的单行中传递参数即可。

以下是一个示例:

1
2
3
4
5
function testFunction -a param1 param2
echo "arg1 = $param1"
echo "arg2 = $param2"
end
testFunction A B

下面是另一个测试传递给函数的参数是否存在的示例:

1
2
3
4
5
6
7
8
9
# Note parameter names can't have dashes in them, only underscores.
function my-function -a extension search_term
if test (count $argv) -lt 2
echo "Usage: my-function <extension> <search_term>"
echo "Eg: <extension> can be 'fish', <search_term> can be 'test'"
else
echo "✋ Do something with $extension $search_term"
end
end

从函数中返回值

你可能需要从函数返回值(通常只是一个字符串),还可以返回许多由新行分割的字符串。无论如何,实现这一目标的机制是相同的。只需要使用echo将返回值转储到 stdout 即可。

这是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getSHAForFilePath -a filepath
set NULL_VALUE ""
# No $filepath provided, or $filepath does not exist -> early return w/ $NULL_VALUE.
if set -q $filepath; or not test -e $filepath
echo $NULL_VALUE
return 0
else
set SHASUM_ARRAY_STRING (shasum $filepath)
set SHASUM_ARRAY (string split " " $SHASUM_ARRAY_STRING)
echo $SHASUM_ARRAY[1]
end
end

function testTheFunction
echo (getSHAForFilePath ~/local-backup-restore/does-not-exist.fish)
echo (getSHAForFilePath)
set mySha (getSHAForFilePath ~/local-backup-restore/test.fish)
echo $mySha
end

testTheFunction

如何处理依赖项的文件和文件夹路径

随着脚本变得更加复杂,您可能需要处理加载多个脚本的问题。在这种情况下,您可以使用source my-script.sh从当前脚本导入其它脚本。然而,fish 会在当前目录,即你开始执行脚本的目录寻找my-script.sh文件,而该目录可能与你需要加载此依赖项的位置不匹配。如果你的主脚本在$PATH中而依赖项不在,就会发生这种情况。在这种情况下,可以在主脚本中执行以下操作:

1
2
set MY_FOLDER_PATH (dirname (status --current-filename))
source $MY_FOLDER_PATH/my-script.fish

这段代码实际做的是获取主脚本运行的文件夹,并将其存储在MY_FOLDER_PATH中,然后就可以使用source命令加载任何依赖项了。这种方法有一个限制,即在MY_FOLDER_PATH中存储的是相对于主脚本执行位置的路径。这是一个你可能不关心的微妙细节,除非你需要一个绝对路径名。在这种情况下,你可以执行以下操作:

1
2
set MY_FOLDER_PATH (realpath (dirname (status --current-filename)))
source $MY_FOLDER_PATH/my-script.fish

使用realpath可以提供文件夹的绝对路径,以便在需要此功能的情况下使用。

如何将多行字符串写入文件

在许多情况下,你需要在脚本中将字符串和多行字符串写入到新的或者现有的文件。

以下是一个向文件中写入单行字符串的例子:

1
2
3
4
5
6
# echo "echo 'ClientAliveInterval 60' >> recurring-tasks.log" | xargs -I% sudo sh -c %
set linesToAdd "TCPKeepAlive yes" "ClientAliveInterval 60" "ClientAliveCountMax 120"
for line in $linesToAdd
set command "echo '$line' >> /etc/ssh/sshd_config"
executeString "$command | xargs -I% sudo sh -c %"
end

下面是一个向文件写入多行字符串的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# More info on writing multiline strings: https://stackoverflow.com/a/35628657/2085356
function _workflowWriteEmptyMarkdownContentToFile --argument datestr filename
echo > $filename "\
---
Title: About $filename
Date: $datestr
---
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->

<!-- END doctoc generated TOC please keep comment here to allow auto update -->

# Your heading
"
end

如何创建彩色的 echo 输出

set_color函数允许 fish 对使用echo打印到stdout的文本内容进行着色和格式化。这在创建需要不同前景、背景颜色以及粗体、斜体或下划线输出的文本输出时非常有用。有很多方法来使用该命令,以下是两个例子(echo语句内联,以及单独使用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function myFunction
if test (count $argv) -lt 2
set -l currentFunctionName (status function)
echo "Usage: "(set_color -o -u)"$currentFunctionName"(set_color normal)\
(set_color blue)" <arg1> "\
(set_color yellow)"<arg2>"(set_color normal)
set_color blue
echo "- <arg1>: Something about arg1."
set_color yellow
echo "- <arg2>: Something about arg2"
set_color normal
return 1
end
end

注意:

  1. 必须调用set_color normal来重置以前语句中设置的格式化选项。
  2. set_color -u表示下划线,set_color -o表示粗体。

如何获取用户输入

在某些情况下,你需要在执行一些可能具有破坏性的操作之前要求用户进行确认,或者可能需要用户输入函数的某些参数(该参数不是通过命令行传递的)。在这些情况下,可以通过使用read函数读取stdin以获取用户输入。

下面的函数简单地为 ‘Y’/‘y’ 返回 0,为 ‘N’/‘n’ 返回 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# More info on prompting a user for confirmation using fish read function: https://stackoverflow.com/a/16673745/2085356
# More info about fish `read` function: https://fishshell.com/docs/current/cmds/read.html
function _promptUserForConfirmation -a message
if not test -z "$message"
echo (set_color brmagenta)"🤔 $message?"
end

while true
# read -l -P '🔴 Do you want to continue? [y/N] ' confirm
read -l -p "set_color brcyan; echo '🔴 Do you want to continue? [y/N] ' ; set_color normal; echo '> '" confirm
switch $confirm
case Y y
return 0
case '' N n
return 1
end
end
end

下面是使用_promptUserForConfirmation函数的一个示例:

1
2
3
4
5
6
if _promptUserForConfirmation "Delete branch $featureBranchName"
git branch -D $featureBranchName
echo "👍 Successfully deleted $featureBranchName"
else
echo "⛔ Did not delete $featureBranchName"
end

如何使用 sed

这对删除不需要的文件片段非常有用。特别是在使用xargsfind结果进行管道处理时。

以下是一个从每个文件的开头删除 ‘./‘ 的示例:

1
echo "./.Android" | sed 's/.\///g'

一个一起使用sedfindxargs的更复杂的例子:

1
2
3
set folder .Android*
find ~ -maxdepth 1 -name $folder | sed 's/.\///g' | \
xargs -I % echo "cleaned up name: %"

如何使用 xargs

这对于将某些命令的输出作为更多命令的参数很有用。

这是一个简单的例子:ls | xargs echo "folders: "

  1. 产生的输出是:folders: idea-http-proxy-settings images tmp
  2. 注意参数在输出中是如何连接的

下面是一个稍微不同的例子,使用-I %可以将参数放在任何地方(而不仅仅是放在末尾)。

1
ls | xargs -I % echo "folder: %"

产生如下输出:

1
2
3
folder: idea-http-proxy-settings
folder: images
folder: tmp

注意,每个参数都在单独的一行中。

如何使用 cut 切分字符串

假设你有一个字符串"token1:token2",你想切分这个字符串并只保留它的第一部分,这可以使用下述的cut命令完成。

1
echo "token1:token2" | cut -d ':' -f 1
  • -d ':' - 将通过:分隔符分割字符串
  • -f 1 - 保留 token 化的字符串中的第一个字段

下面是一个在~/github/developerlife.com中查找所有含有fonts.googleapis的 HTML 文件,然后使用subl打开的示例:

1
2
3
4
5
6
7
cd ~/github/developerlife.com
echo \
"find . -name '*html' | \
xargs grep fonts.googleapis | \
cut -d ':' -f 1 | \
xargs subl" \
| sh

如何计算脚本运行所需的时间

1
2
3
4
5
6
7
8
9
10
11
12
13
function timed -d Pass the program or function that you want to execute as an argument
set START_TS (date +%s)

# This is where your code would go.
$argv

sleep 5

set END_TS (date +%s)
set RUNTIME (math $END_TS - $START_TS)
set RUNTIME (math $RUNTIME / 60)
echo "⏲ Total runtime: $RUNTIME min ⏲"
end