該文章翻譯自FISH SHELL SCRIPTING MANUAL,因本人才疏學淺,難免有錯誤 / 不通順的地方,還望讀者在評論區不吝賜教。
通過例子學習如何編寫 fish shell 腳本。
腳本頂部的 shebang 行#
為了在終端中運行 fish 腳本,你需要做如下兩件事:
- 將以下 shebang 行添加到腳本頂部:
#!/usr/bin/env fish
。 - 使用以下命令將文件標記為可執行:
chmod +x <你的 fish 腳本文件名>
。
如何設置變量#
注意,在 fish 中可以賦給變量的所有類型的值都是字符串,沒有布爾值、整數或浮點數等概念。下面是一個給變量賦值的簡單示例。這裡有關於此的更多信息。
set MY_VAR "some value"
你可以做的最有用的事情之一就是把 shell 中執行命令的輸出存儲到一個變量中。在你測試某個程序或命令是否返回了一些需要你執行其它命令的值(使用字符串比較、 if 語句和 switch 語句)時,這是很有用的。下面是一些這樣做的例子。
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 變量是列表。
set MY_VAR $MY_VAR "another value"
這是創建列表的方法。
set MY_LIST "value1" "value2" "value3"
存儲運行命令的返回值#
下面是一個將命令執行返回的值存儲到變量中的示例。
set OUR_VAR (math 1+2)
set OUR_VAR (date +%s)
set OUR_VAR (math $OUR_VAR / 60)
因為所有的 fish 變量都是列表,你可以使用 [n]
運算符訪問單個元素,其中n=1
表示第一個元素(而不是 0)。下面是一個例子。負數表示從末尾訪問元素。
set LIST one two three
echo $LIST[1] # one
echo $LIST[2] # two
echo $LIST[3] # three
echo $LIST[-1] # 和上一行等同
範圍#
你可以使用變量 / 列表的範圍,接著上面的例子。
set LIST one two three
echo $LIST[1..2] # one two
echo $LIST[2..3] # two three
echo $LIST[-1..2] # three two
如何編寫 for 循環#
因為變量默認包含列表,所以很容易遍歷他們。這是一個例子:
set FOLDERS bin
set FOLDERS $FOLDERS .atom
set FOLDERS $FOLDERS "my foldername"
for FOLDER in $FOLDERS
echo "item: $FOLDER"
end
你也可以把set
命令放在同一行以簡化上述代碼,就像這樣:
set FOLDERS bin .atom "my foldername"
for FOLDER in $FOLDERS
echo "item: $FOLDER"
end
還可以把整個 for 語句放到單行,像這樣:
set FOLDERS bin .atom "my foldername"
for FOLDER in $FOLDERS ; echo "item: $FOLDER" ; end
如何編寫 if 語句#
編寫 if 語句的關鍵是使用test
命令對某個表達式求布爾值。可以是字符串比較,甚至是測試文件或文件夾的存在。下面是一些例子。你還可以使用not
運算符作為test
的前綴,以檢查逆條件。
常用的條件#
檢查數組的大小。$argv
包含從命令行傳遞給腳本的參數列表。
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
變量的字符串比較。
if test $hostname = "mymachine"
echo "hostname is mymachine"
end
檢查文件是否存在:
if test -e "somefile"
echo "somefile exists"
end
檢查文件夾是否存在:
if test -d "somefolder"
echo "somefolder exists"
end
檢查文件通配符是否存在與文件和文件夾的檢查略有不同。原因在於 fish 處理通配符的方式 —— 在對它們執行任何命令之前,fish 會首先將其展開。
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
運算符的示例:
if not test -d "somefolder"
echo "somefolder does not exist"
end
程序,腳本或函數的退出碼#
使用退出碼的想法是,你的函數或者整個 fish 腳本可以被能夠理解退出碼的其他程序使用。換句話說,可能會有 if 語句使用退出碼來確定某個條件。這是與其它命令行程序一起使用的一個非常常見的模式。退出碼不同於函數的返回值。
下面是一個使用git
命令的退出碼的示例:
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
一個測試命令執行是否沒有錯誤的示例:
if sudo umount /media/user/mountpoint
echo "Successfully unmounted /media/user/mountpoint"
end
你還可以檢查$status
變量的值。fish 會在執行命令後將返回值存儲在這個變量中。這兒有關於此的更多信息。
當你寫函數時,可以使用以下關鍵字來退出函數或循環:return
。return
後可能會跟隨一個數字,它的意思是:
return
或return 0
- 表示函數正常退出。return 1
或者其他大於 0 的數字 - 表示函數出了些問題。
你可以使用exit
退出 fish shell 本身。整數退出碼的含義與上述相同。
set -q 和 test -z 的不同#
在 if 語句中使用set -q
和test -z
檢查變量是否為空時,有一些細微的區別。
- 在使用
test -z
時,請確保使用引號包裹變量,因為如果變量不在引號中,該命令可能在某些邊緣情況下出錯。 - 不過,你可以使用
set -q
來測試是否已經設置了變量,而無需將其括在引號中。
下面是示例:
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 運算符的多條件判斷#
如果想把多個條件組合到單個語句中,可以使用or
和and
運算符。如果想檢查條件的逆,你可以使用!
。下面是一個函數的示例,該函數用於檢查命令行傳遞的兩個參數。這是我們描述的邏輯:
- 如果兩個參數都缺失,應該向命令行打印幫助信息,然後提前
return
。 - 如果其中一個參數缺失,則顯示一個提示,說明其中一個參數缺失,然後提前
return
。
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
下面是一些代碼的註釋:
-
set -q $variable
函數做了什麼?當$variable
是空時,它返回 true。 -
如果你想使用
test
函數替換掉set -q
來判斷一個變量是否存在,可以使用:if test -z "$variable"
if test ! -n "$variable"
或if not test -n "$variable"
-
如果你想要把上面的
or
檢查替換為test
,它看起來會像:if test -z "$argv[1]"; or test -z "$argv[2]"
。译者注:上面的代码本来就是
test -z
,这句话有点画蛇添足了。推测作者的代码原文可能是
set -q "$argv[1]"; or set -q "$argv[2]"
,所以才会有这种表述。 -
當你使用
or
,and
運算符時,你必須使用;
來結束條件表達式 。 -
確保把變量括在空引號中。如果變量中包含一個空字符串,那麼如果沒有這些引號,語句將導致錯誤。
這是另一個用於測試$variable
是否為空的例子:
if test -z "$variable" ; echo "empty" ; else ; echo "non-empty" ; end
這是另一個用於測試$variable
是否包含字符串的例子:
if test -n "$variable" ; echo "non-empty" ; else ; echo "empty" ; end
另一個常見運算符: not#
下面是一個使用not
運算符測試字符串是否包含字符串片段的示例:
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
參考資料#
- test command
- set command
- if command
- stackoverflow answer: how to check if fish variable is empty
- stackoverflow answer: how to put multiple conditions in fish if statement
如何使用分隔符拆分字符串#
在某些情況下,您希望獲取命令的輸出(一個字符串),然後用某個分隔符將其分割,以便只使用輸出字符串的一部分。例如獲取給定文件的 SHA 校驗和。命令shasum <filename>
產生類似df..d8 <filename>
的輸出。假設我們只想要這個字符串的第一部分(SHA),已知分隔符是兩個空格字符,我們可以執行以下操作來獲取校驗和部分,並將其存儲在$checksum
中。這裡有關於string split
命令的更多信息。
set CHECKSUM_ARRAY_STRING (shasum $FILENAME)
set CHECKSUM_ARRAY (string split " " $SOURCE_CHECKSUM_ARRAY)
set CHECKSUM $CHECKSUM_ARRAY[1]
如何執行字符串比較#
為了測試字符串中的子串匹配,你可以使用string match
命令。這裡有關於該命令的更多信息:
下面是一個實際應用的例子。注意,使用-q
或--quiet
時,如果匹配條件滿足(成功),它不會打印字符串的輸出。
if string match -q "*myname*" $hostname
echo "$hostname contains myname"
else
echo "$hostname does not contain myname"
end
下面是一個字符串精確匹配的示例:
if test $hostname = "machine-name"
echo "Exact match"
else
echo "Not exact match"
end
一個測試字符串是否為空的示例:
if set -q $my_variable
echo "my_variable is empty"
end
下面是一個複雜的示例,測試是否安裝了ruby-dev
和ruby-bundler
包。如果是,則運行jekyll
;如果不是,則安裝這些包。
# 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
語句需要匹配子字符串,可以使用通配符和想要匹配的子字符串的組合來表示子字符串。這是一個示例。
switch $hostname
case "*substring1*"
echo "Matches $hostname containing substring1"
case "*substring2*"
echo "Matches $hostname containing substring2"
end
你也可以把這些和 if 語句混合使用,最終看起來會像這樣:
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
如何執行字符串#
執行腳本中生成的字符串最安全的方法是使用以下模式。
echo "ls \
-la" | sh
這不僅使調試更容易,還在使用\
進行多行換行時避免了奇怪的錯誤。
如何編寫函數#
一個 fish 函數只是可選地接受參數的命令列表。這些參數作為列表傳入(因為 fish 的所有變量都是列表)。
這是一個例子:
function say_hi
echo "Hi $argv"
end
say_hi
say_hi everbody!
say_hi you and you and you
寫完一個函數後,你可以通過使用type
查看它是什麼。例如:type say_hi
將展示你剛剛創建的函數。
向函數傳遞參數#
除了使用$argv
找出傳遞給函數的參數外,你還可以提供一個函數所期望的具名參數列表。這是官方文檔中的更多信息。
需要記住的一些關鍵事項:
- 參數名不能含有
-
字符,使用_
替代。 - 不要使用
(
和)
向函數傳遞參數,只需要在帶空格的單行中傳遞參數即可。
以下是個示例:
function testFunction -a param1 param2
echo "arg1 = $param1"
echo "arg2 = $param2"
end
testFunction A B
下面是另一個測試傳遞給函數的參數是否存在的示例:
# 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 即可。
這是一個示例:
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
中而依賴項不在,就會發生這種情況。在這種情況下,可以在主腳本中執行以下操作:
set MY_FOLDER_PATH (dirname (status --current-filename))
source $MY_FOLDER_PATH/my-script.fish
這段代碼實際做的是獲取主腳本運行的文件夾,並將其存儲在MY_FOLDER_PATH
中,然後就可以使用source
命令加載任何依賴項了。這種方法有一個限制,即在MY_FOLDER_PATH
中存儲的是相對於主腳本執行位置的路徑。這是一個你可能不關心的微妙細節,除非你需要一個絕對路徑名。在這種情況下,你可以執行以下操作:
set MY_FOLDER_PATH (realpath (dirname (status --current-filename)))
source $MY_FOLDER_PATH/my-script.fish
使用realpath
可以提供文件夾的絕對路徑,以便在需要此功能的情況下使用。
如何將多行字符串寫入文件#
在許多情況下,你需要在腳本中將字符串和多行字符串寫入到新的或者現有的文件。
以下是一個向文件中寫入單行字符串的例子:
# 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
下面是一個向文件寫入多行字符串的例子:
# 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
語句內聯,以及單獨使用):
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
注意:
- 必須調用
set_color normal
來重置以前語句中設置的格式化選項。 set_color -u
表示下劃線,set_color -o
表示粗體。
如何獲取用戶輸入#
在某些情況下,你需要在執行一些可能具有破壞性的操作之前要求用戶進行確認,或者可能需要用戶輸入函數的某些參數(該參數不是通過命令行傳遞的)。在這些情況下,可以通過使用read
函數讀取stdin
以獲取用戶輸入。
下面的函數簡單地為 'Y'/'y' 返回 0,為 'N'/'n' 返回 1。
# 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
函數的一個示例:
if _promptUserForConfirmation "Delete branch $featureBranchName"
git branch -D $featureBranchName
echo "👍 Successfully deleted $featureBranchName"
else
echo "⛔ Did not delete $featureBranchName"
end
如何使用 sed#
這對刪除不需要的文件片段非常有用。特別是在使用xargs
對find
結果進行管道處理時。
以下是一個從每個文件的開頭刪除 './' 的示例:
echo "./.Android" | sed 's/.\///g'
一個一起使用sed
,find
和xargs
的更複雜的例子:
set folder .Android*
find ~ -maxdepth 1 -name $folder | sed 's/.\///g' | \
xargs -I % echo "cleaned up name: %"
如何使用 xargs#
這對於將某些命令的輸出作為更多命令的參數很有用。
這是一個簡單的例子:ls | xargs echo "folders: "
。
- 產生的輸出是:
folders: idea-http-proxy-settings images tmp
- 注意參數在輸出中是如何連接的
下面是一個稍微不同的例子,使用-I %
可以將參數放在任何地方(而不僅僅是放在末尾)。
ls | xargs -I % echo "folder: %"
產生如下輸出:
folder: idea-http-proxy-settings
folder: images
folder: tmp
注意,每個參數都在單獨的一行中。
如何使用 cut 切分字符串#
假設你有一個字符串"token1:token2"
,你想切分這個字符串並只保留它的第一部分,這可以使用下述的cut
命令完成。
echo "token1:token2" | cut -d ':' -f 1
-d ':'
- 將通過:
分隔符分割字符串-f 1
- 保留 token 化的字符串中的第一個字段
下面是一個在~/github/developerlife.com
中查找所有含有fonts.googleapis
的 HTML 文件,然後使用subl
打開的示例:
cd ~/github/developerlife.com
echo \
"find . -name '*html' | \
xargs grep fonts.googleapis | \
cut -d ':' -f 1 | \
xargs subl" \
| sh
如何計算腳本運行所需的時間#
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