In a bash 3 script for OSX machines, I needed the functional equivalent of the realpath
command, complete with support for the --relative-to
and --relative-base
options.
I would normally just install the homebrew coreutils
formula, but I need this script to work when bootstrapping a new machine with no network or XCode available yet. So the code below includes a replacement implementation that's only used when the command is not already installed. Normal usage would be
for resolved_path in $(realpath [--relative-to=...] [--relative-base=...] [one or more paths]); do
# ...
done
It's meant to be used as a sourced library (. realpathlib.bash
), but if run directly with bash realpathlib.bash
it runs a simple test suite.
I've been picking up bash best practices as I went along, but would love feedback on all aspects, such as naming conventions and other coding style aspects, techniques, problems I've overlooked, etc.
# shellcheck shell=bash
set -euo pipefail
_contains() {
# return true if first argument is present in the other arguments
local elem value
value="$1"
shift
for elem in "$@"; do
if [[ $elem == "$value" ]]; then
return 0
fi
done
return 1
}
_canonicalize_filename_mode() {
# resolve any symlink targets, GNU readlink -f style
# where every path component except the last should exist and is
# resolved if it is a symlink. This is essentially a re-implementation
# of canonicalize_filename_mode(path, CAN_ALL_BUT_LAST).
# takes the path to canonicalize as first argument
local path result component seen
seen=()
path="$1"
result="/"
if [[ $path != /* ]]; then # add in current working dir if relative
result="$PWD"
fi
while [[ -n $path ]]; do
component="${path%%/*}"
case "$component" in
'') # empty because it started with /
path="${path:1}" ;;
.) # ./ current directory, do nothing
path="${path:1}" ;;
..) # ../ parent directory
if [[ $result != "/" ]]; then # not at the root?
result="${result%/*}" # then remove one element from the path
fi
path="${path:2}" ;;
*)
# add this component to the result, remove from path
if [[ $result != */ ]]; then
result="$result/"
fi
result="$result$component"
path="${path:${#component}}"
# element must exist, unless this is the final component
if [[ $path =~ [^/] && ! -e $result ]]; then
echo "$1: No such file or directory" >&2
return 1
fi
# if the result is a link, prefix it to the path, to continue resolving
if [[ -L $result ]]; then
if _contains "$result" "${seen[@]+"${seen[@]}"}"; then
# we've seen this link before, abort
echo "$1: Too many levels of symbolic links" >&2
return 1
fi
seen+=("$result")
path="$(readlink "$result")$path"
if [[ $path = /* ]]; then
# if the link is absolute, restart the result from /
result="/"
elif [[ $result != "/" ]]; then
# otherwise remove the basename of the link from the result
result="${result%/*}"
fi
elif [[ $path =~ [^/] && ! -d $result ]]; then
# otherwise all but the last element must be a dir
echo "$1: Not a directory" >&2
return 1
fi
;;
esac
done
echo "$result"
}
_realpath() {
local relative_to relative_base seenerr path
relative_to=
relative_base=
seenerr=
while [[ $# -gt 0 ]]; do
case $1 in
"--relative-to="*)
relative_to=$(_canonicalize_filename_mode "${1#*=}")
shift 1;;
"--relative-base="*)
relative_base=$(_canonicalize_filename_mode "${1#*=}")
shift 1;;
*)
break;;
esac
done
if [[
-n $relative_to
&& -n $relative_base
&& ${relative_to#${relative_base}/} == "$relative_to"
]]; then
# relative_to is not a subdir of relative_base -> ignore both
relative_to=
relative_base=
elif [[ -z $relative_to && -n $relative_base ]]; then
# if relative_to has not been set but relative_base has, then
# set relative_to from relative_base, simplifies logic later on
relative_to="$relative_base"
fi
for path in "$@"; do
if ! real=$(_canonicalize_filename_mode "$path"); then
seenerr=1
continue
fi
# make path relative if so required
if [[
-n $relative_to
&& ( # path must not be outside relative_base to be made relative
-z $relative_base || ${real#${relative_base}/} != "$real"
)
]]; then
local common_part parentrefs
common_part="$relative_to"
parentrefs=
while [[ ${real#${common_part}/} == "$real" ]]; do
common_part="$(dirname "$common_part")"
parentrefs="..${parentrefs:+/$parentrefs}"
done
if [[ $common_part != "/" ]]; then
real="${parentrefs:+${parentrefs}/}${real#${common_part}/}"
fi
fi
echo "$real"
done
if [[ $seenerr ]]; then
return 1
fi
}
if ! command -v realpath > /dev/null 2>&1; then
# realpath is not available on OSX unless you install the `coreutils` brew
realpath() { _realpath "$@"; }
fi
if [[ $0 == "${BASH_SOURCE[0]}" ]]; then
assert_equal() {
local result
while read -r result; do
if [[ $result != "$1" ]]; then
echo -e "\033[0;31mFAIL\033[0m"
echo -e "expected\n\t$1\ngot\n\t$result"
exit 2
fi
shift 1
done
# any expected results left over?
if [[ $# -gt 0 ]]; then
echo -e "\033[0;31mFAIL\033[0m"
echo "expected more results"
printf '\t- %s\n' "$@"
exit 2
fi
echo -e "\033[0;32mOK\033[0m"
}
testdir=$(mktemp -d -t "${0##*/}_tests")
# canonicalize testdir with pwd -P (no .. components, so sufficient)
pushd "$testdir"
testdir=$(pwd -P)
popd >/dev/null
cleanup() {
rm -rf "$testdir"
}
trap cleanup EXIT
mkdir -p "$testdir/foo/bar/baz"
mkdir -p "$testdir/foobar"
touch "$testdir/target"
touch "$testdir/foo/target"
touch "$testdir/foo/bar/target"
touch "$testdir/foobar/target"
ln -s "../link" "$testdir/foo/bar/baz/link"
ln -s "../link" "$testdir/foo/bar/link"
ln -s "../target" "$testdir/foo/link"
ln -s "circular2" "$testdir/foo/circular1"
ln -s "circular1" "$testdir/foo/circular2"
ln -s "../foo/bar" "$testdir/foobar/dirlink"
echo -en "chained symlinks:\t"
_realpath "$testdir/foo/bar/baz/link" \
| assert_equal "$testdir/target"
echo -en "circular symlinks:\t"
{ _realpath "$testdir/foo/circular1" 2>&1 || echo "error exit"; } \
| assert_equal \
"$testdir/foo/circular1: Too many levels of symbolic links" \
"error exit"
echo -en "symlink and .. combo:\t"
_realpath "$testdir/foobar/dirlink/../target" \
| assert_equal \
"$testdir/foo/target"
echo -en "non-existing path:\t"
{ _realpath "$testdir/nonesuch/foo" 2>&1 || echo "error exit"; } \
| assert_equal \
"$testdir/nonesuch/foo: No such file or directory" \
"error exit"
echo -en "file as directory:\t"
{ _realpath "$testdir/target/foo" 2>&1 || echo "error exit"; } \
| assert_equal \
"$testdir/target/foo: Not a directory" \
"error exit"
echo -en "relative paths:\t\t"
pushd "$testdir/foo" > /dev/null
_realpath \
"bar/target" \
"../target" \
"target" \
"$testdir/./foo/../foobar/target" \
| assert_equal \
"$testdir/foo/bar/target" \
"$testdir/target" \
"$testdir/foo/target" \
"$testdir/foobar/target"
popd > /dev/null
echo -en "relative-base inside:\t"
_realpath --relative-base="$testdir/foo" "$testdir/foo/bar/target" \
| assert_equal "bar/target"
echo -en "relative-base outside:\t"
_realpath --relative-base="$testdir/foo/bar" "$testdir/foo/target" \
| assert_equal "$testdir/foo/target"
echo -en "--r-base name prefix:\t"
_realpath --relative-base="$testdir/foo" "$testdir/foobar/target" \
| assert_equal "$testdir/foobar/target"
echo -en "--r-base extra /-s:\t"
_realpath --relative-base="$testdir//foo//" "$testdir/foo/target" \
| assert_equal "target"
echo -en "--r-base relative:\t"
pushd "$testdir/foo/bar" > /dev/null
_realpath --relative-base="../../foo" "$testdir/foo/bar/target" \
| assert_equal "bar/target"
popd > /dev/null
echo -en "multiple --r-base:\t"
_realpath --relative-base="$testdir/foo" \
"$testdir/foo/target" \
"$testdir/target" \
"$testdir/foo/bar/target" \
| assert_equal \
"target" \
"$testdir/target" \
"bar/target"
echo -en "--r-base divergent:\t"
_realpath --relative-base="$testdir" "/dev/null" \
| assert_equal "/dev/null"
echo -en "relative-to inside:\t"
_realpath --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
| assert_equal "bar/target"
echo -en "relative-to outside:\t"
_realpath --relative-to="$testdir/foo/bar" "$testdir/target" \
| assert_equal "../../target"
echo -en "--r-to name prefix:\t"
_realpath --relative-to="$testdir/foo" "$testdir/foobar/target" \
| assert_equal "../foobar/target"
echo -en "--r-to extra /-s:\t"
_realpath --relative-to="$testdir//foo//" "$testdir/foo/target" \
| assert_equal "target"
echo -en "--r-to relative:\t"
pushd "$testdir/foo" > /dev/null
_realpath --relative-to="../foobar" "$testdir/foo/target" \
| assert_equal "../foo/target"
popd > /dev/null
echo -en "multiple --r-to:\t"
_realpath --relative-to="$testdir/foo" \
"$testdir/foo/target" \
"$testdir/target" \
"$testdir/foo/bar/target" \
| assert_equal \
"target" \
"../target" \
"bar/target"
echo -en "combined inside both:\t"
_realpath --relative-base="$testdir" --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
| assert_equal "bar/target"
echo -en "combined outside one:\t"
_realpath --relative-base="$testdir" --relative-to="$testdir/foo/bar" "$testdir/target" \
| assert_equal "../../target"
echo -en "combined outside both:\t"
_realpath --relative-base="$testdir/foo" --relative-to="$testdir/foo/bar" "$testdir/target" \
| assert_equal "$testdir/target"
echo -en "multiple combined:\t"
_realpath --relative-base="$testdir/foo" --relative-to="$testdir/foo/bar" \
"$testdir/foo/target" "$testdir/target" "$testdir/foo/bar/target" \
| assert_equal \
"../target" \
"$testdir/target" \
"target"
echo -en "combined errorcase:\t"
# -base should be a parent path of -to. If not, the arguments are ignored
_realpath --relative-base="$testdir/foo/bar" --relative-to="$testdir/foo" "$testdir/foo/bar/target" \
| assert_equal "$testdir/foo/bar/target"
fi
--build-from-source
flag once and reusing the same pre-installed package' a lot more work. Seems like a lot less work then writing your own bootstrap ecosystem. With some use of chroot you could probably remove any need for the flag either. \$\endgroup\$