amtoaer

晓风残月

叹息似的渺茫,你仍要保存着那真!
github
x
telegram
steam
nintendo switch
email

Fish Shell Scripting Guide

This article is translated from FISH SHELL SCRIPTING MANUAL. Due to my limited knowledge, there may be errors or awkward phrasing, and I hope readers will kindly provide feedback in the comments.

Learn how to write fish shell scripts through examples.

Shebang Line at the Top of the Script#

To run fish scripts in the terminal, you need to do two things:

  1. Add the following shebang line at the top of the script: #!/usr/bin/env fish.
  2. Mark the file as executable using the following command: chmod +x <your fish script filename>.

How to Set Variables#

Note that all types of values assigned to variables in fish are strings; there are no concepts of boolean values, integers, or floating-point numbers. Here is a simple example of assigning a value to a variable. Here is more information about this.

set MY_VAR "some value"

One of the most useful things you can do is store the output of a command executed in the shell into a variable. This is useful when you are testing whether a program or command returns a value that requires you to execute other commands (using string comparisons, if statements, and switch statements). Here are some examples of doing this.

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

Variable Scope: local, global, global-export#

Sometimes you need to export a variable to a subprocess, sometimes you need to export a variable to the global scope. There are also times when you want to restrict a variable to the local scope of the function you are writing. The set function section in the fish documentation has more information about this.

  • To restrict a variable to the local scope of a function (even if there is a global variable with the same name), use set -l. This type of variable is not available throughout the fish shell. An example is a local variable used only to store a value within the function's scope, such as set -l fname (realpath).
  • Use set -x to export a variable (available only in the current fish shell). For example, to set the DISPLAY environment variable for an X11 session in a fish function running in a crontab headless environment.
  • Use set -gx to globally export a variable (available to any program in the operating system, not just the currently running fish shell process). For example, to set the JAVA_HOME environment variable for all programs running on the machine.

Lists#

Here is an example of appending values to a variable. By default, fish variables are lists.

set MY_VAR $MY_VAR "another value"

This is how to create a list.

set MY_LIST "value1" "value2" "value3"

Storing the Return Value of a Command#

Here is an example of storing the value returned by executing a command into a variable.

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

Since all fish variables are lists, you can access individual elements using the [n] operator, where n=1 represents the first element (not 0). Here is an example. Negative numbers indicate accessing elements from the end.

set LIST one two three
echo $LIST[1]  # one
echo $LIST[2]  # two
echo $LIST[3]  # three
echo $LIST[-1] # equivalent to the previous line

Ranges#

You can use ranges with variables/lists, continuing from the previous example.

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

How to Write a For Loop#

Since variables by default contain lists, it is easy to iterate over them. Here is an example:

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

You can also place the set command on the same line to simplify the above code, like this:

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

You can also place the entire for statement on a single line, like this:

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

How to Write an If Statement#

The key to writing an if statement is to use the test command to evaluate a boolean value for an expression. This can be string comparisons or even testing for the existence of files or folders. Here are some examples. You can also use the not operator as a prefix to test to check for the inverse condition.

Common Conditions#

Check the size of an array. $argv contains the list of arguments passed to the script from the command line.

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

String comparison of variables.

if test $hostname = "mymachine"
  echo "hostname is mymachine"
end

Check if a file exists:

if test -e "somefile"
  echo "somefile exists"
end

Check if a folder exists:

if test -d "somefolder"
  echo "somefolder exists"
end

Checking for the existence of file wildcards is slightly different from checking for files and folders. This is due to how fish handles wildcards—fish expands them before executing any commands.

set -l files ~/Downloads/*.mp4 # This wildcard expression is expanded to include the actual files
if test (count $files) -gt 0
  mv ~/Downloads/*.mp4 ~/Videos/
  echo "📹 Moved '$files' to ~/Videos/"
else
  echo "⛔ No mp4 files found in Downloads"
end

An example using the not operator in the above example:

if not test -d "somefolder"
  echo "somefolder does not exist"
end

Exit Codes of Programs, Scripts, or Functions#

The idea of using exit codes is that your function or the entire fish script can be used by other programs that understand exit codes. In other words, there may be if statements that use exit codes to determine a condition. This is a very common pattern when used with other command-line programs. Exit codes are different from the return values of functions.

Here is an example using the exit code of the git command:

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

An example testing whether a command executed without errors:

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

You can also check the value of the $status variable. Fish stores the return value in this variable after executing a command. Here is more information about this.

When writing functions, you can use the following keywords to exit a function or loop: return. A return may be followed by a number, which means:

  1. return or return 0 - indicates normal exit from the function.
  2. return 1 or any other number greater than 0 - indicates that something went wrong in the function.

You can use exit to exit the fish shell itself. The meaning of integer exit codes is the same as described above.

Differences Between set -q and test -z#

There are some subtle differences when using set -q and test -z in if statements to check if a variable is empty.

  1. When using test -z, make sure to wrap the variable in quotes, as the command may fail in some edge cases if the variable is not quoted.
  2. However, you can use set -q to test if a variable is set without needing to wrap it in quotes.

Here is an example:

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

Multi-Condition Checks with and, or Operators#

If you want to combine multiple conditions into a single statement, you can use the or and and operators. If you want to check the inverse of a condition, you can use !. Here is an example of a function that checks for two parameters passed from the command line. This is the logic we described:

  1. If both parameters are missing, it should print help information to the command line and return early.
  2. If one of the parameters is missing, it should display a prompt indicating that one of the parameters is missing and return early.
function requires-two-arguments
  # No parameters passed
  if set -q "$argv"
    echo "Usage: requires-two-arguments arg1 arg2"
    return 1
  end
  # Only one parameter passed
  if test -z "$argv[1]"; or test -z "$argv[2]"
    echo "arg1 or arg2 cannot be empty"
    return 1
  end
  echo "Thank you, got 1) $argv[1] and 2) $argv[2]"
end

Here are some comments on the code:

  1. What does the set -q $variable function do? It returns true when $variable is empty.

  2. If you want to replace set -q with the test function to check if a variable exists, you can use:

    • if test -z "$variable"
    • if test ! -n "$variable" or if not test -n "$variable"
  3. If you want to replace the or check above with test, it would look like: if test -z "$argv[1]"; or test -z "$argv[2]".

    Note: The above code is already test -z, so this statement is somewhat redundant.

    It is speculated that the original code may have been set -q "$argv[1]"; or set -q "$argv[2]", which is why this expression exists.

  4. When using or and and operators, you must use ; to end the conditional expression.

  5. Make sure to wrap variables in empty quotes. If a variable contains an empty string, then without these quotes, the statement will result in an error.

Here is another example for testing if $variable is empty:

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

Here is another example for testing if $variable contains a string:

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

Another Common Operator: not#

Here is an example using the not operator to test if a string contains a substring:

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

References#

  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

How to Split Strings Using Delimiters#

In some cases, you may want to get the output of a command (a string) and then split it using a delimiter to only use part of the output string. For example, to get the SHA checksum of a given file. The command shasum <filename> produces output like df..d8 <filename>. Assuming we only want the first part of this string (SHA), knowing that the delimiter is two space characters, we can do the following to get the checksum part and store it in $checksum. Here is more information about the string split command.

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

How to Perform String Comparisons#

To test for substring matches in a string, you can use the string match command. Here is more information about this command:

  1. Official documentation for string match
  2. Stackoverflow answer on how to use it

Here is a practical example. Note that when using -q or --quiet, if the match condition is satisfied (successful), it will not print the output of the string.

if string match -q "*myname*" $hostname
  echo "$hostname contains myname"
else
  echo "$hostname does not contain myname"
end

Here is an example of exact string matching:

if test $hostname = "machine-name"
  echo "Exact match"
else
  echo "Not exact match"
end

An example of testing if a string is empty:

if set -q $my_variable
  echo "my_variable is empty"
end

Here is a more complex example that tests whether the ruby-dev and ruby-bundler packages are installed. If they are, it runs jekyll; if not, it installs these packages.

# 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

How to Write a Switch Statement for Strings#

To create a switch statement for strings, the test command is also used (just like for if statements). The case statements need to match substrings, which can be represented using wildcards and combinations of the substrings you want to match. Here is an example.

switch $hostname
case "*substring1*"
  echo "Matches $hostname containing substring1"
case "*substring2*"
  echo "Matches $hostname containing substring2"
end

You can also mix these with if statements, and it would look like this:

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

How to Safely Execute Strings#

The safest way to execute strings generated in a script is to use the following pattern.

echo "ls \
  -la" | sh

This not only makes debugging easier but also avoids strange errors when using \ for multi-line breaks.

How to Write Functions#

A fish function is simply a list of commands that optionally accept parameters. These parameters are passed as a list (since all fish variables are lists).

Here is an example:

function say_hi
  echo "Hi $argv"
end
say_hi
say_hi everybody!
say_hi you and you and you

After writing a function, you can check what it is by using type. For example: type say_hi will show you the function you just created.

Passing Parameters to Functions#

In addition to using $argv to find out the parameters passed to a function, you can provide a named parameter list that the function expects. Here is more information in the official documentation.

Some key points to remember:

  1. Parameter names cannot contain - characters; use _ instead.
  2. Do not use ( and ) to pass parameters to functions; just pass them in a single line with spaces.

Here is an example:

function testFunction -a param1 param2
  echo "arg1 = $param1"
  echo "arg2 = $param2"
end
testFunction A B

Here is another example testing whether the parameters passed to the function exist:

# 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

Returning Values from Functions#

You may need to return values from a function (usually just a string), and you can return many strings separated by new lines. In any case, the mechanism to achieve this is the same. Just use echo to dump the return value to stdout.

Here is an example:

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

How to Handle Dependency File and Folder Paths#

As scripts become more complex, you may need to deal with loading multiple scripts. In this case, you can use source my-script.sh to import other scripts from the current script. However, fish looks for the my-script.sh file in the current directory, which is the directory where you started executing the script, and that directory may not match the location where you need to load this dependency. This happens if your main script is in $PATH and the dependency is not. In this case, you can do the following in the main script:

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

What this code does is get the folder where the main script is running and store it in MY_FOLDER_PATH, then you can use the source command to load any dependencies. This method has a limitation in that MY_FOLDER_PATH stores a path relative to the execution location of the main script. This is a subtle detail you may not care about unless you need an absolute pathname. In that case, you can do the following:

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

Using realpath provides the absolute path of the folder for use when needed.

How to Write Single-Line Strings to Files#

In many cases, you need to write strings and multi-line strings to new or existing files in a script.

Here is an example of writing a single-line string to a file:

# 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

Here is an example of writing multi-line strings to a file:

# 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

How to Create Colored Echo Output#

The set_color function allows fish to color and format the text content printed to stdout using echo. This is useful when creating text output that requires different foreground and background colors, as well as bold, italic, or underlined output. There are many ways to use this command; here are two examples (inline with echo statements and used separately):

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

Note:

  1. You must call set_color normal to reset the formatting options set in previous statements.
  2. set_color -u indicates underline, and set_color -o indicates bold.

How to Get User Input#

In some cases, you may need to ask the user for confirmation before performing some potentially destructive operations or may need user input for certain parameters of a function (that are not passed via the command line). In these cases, you can read stdin to get user input using the read function.

The following function simply returns 0 for 'Y'/'y' and 1 for 'N'/'n'.

# 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

Here is an example of using the _promptUserForConfirmation function:

if _promptUserForConfirmation "Delete branch $featureBranchName"
  git branch -D $featureBranchName
  echo "👍 Successfully deleted $featureBranchName"
else
  echo "⛔ Did not delete $featureBranchName"
end

How to Use sed#

This is useful for removing unwanted file snippets, especially when piping find results with xargs.

Here is an example of removing './' from the beginning of each file:

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

A more complex example using sed, find, and xargs together:

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

How to Use xargs#

This is useful for passing the output of some commands as arguments to more commands.

Here is a simple example: ls | xargs echo "folders: ".

  1. The output produced is: folders: idea-http-proxy-settings images tmp
  2. Note how the parameters are concatenated in the output.

Here is a slightly different example, using -I % to place the parameters anywhere (not just at the end).

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

Produces the following output:

folder: idea-http-proxy-settings
folder: images
folder: tmp

Note that each parameter is on a separate line.

How to Use cut to Split Strings#

Suppose you have a string "token1:token2" and you want to split this string and keep only its first part; this can be done using the following cut command.

echo "token1:token2" | cut -d ':' -f 1
  • -d ':' - splits the string by the : delimiter
  • -f 1 - keeps the first field of the tokenized string

Here is an example of finding all HTML files containing fonts.googleapis in ~/github/developerlife.com and opening them with subl:

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

How to Measure the Time Taken to Run a Script#

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
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.