17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

bashスクリプトでファイルパスの相対パスを得る(絶対パスの取得やパスの正規化も)

Last updated at Posted at 2014-09-19

bashスクリプトで相対パスが取りたい

bashスクリプトで2つのファイルパス間の相対パスを取ろうとすると結構難儀します。
Linuxであればrealpathコマンドを使えば良いのですが、Mac OS Xだと無かったりします。

シェルスクリプト内部でpythonのワンライナーを呼び出すとか、coreutilsをインストールするとかあるんですけど、bashスクリプトを使うからにはなるべく環境依存は小さくしたいものです。

であれば、pure bashで関数を実装して、それを都度再利用するのが良さそうに思い、やってみました。

今回実装した関数をロードすると、下記のような感じで書けます。

path_get_relative "/Users/omochi" "/usr/local" # => ../../usr/local

入力は相対パスも取れます。

cd "/Users/omochi"
path_get_relative "." "/usr/local"

仕様

  • Mac OS XとLinuxのbashで動きます。
  • パスの途中に//, /./, /../などが入っていたり、指定するパスの末尾に/がついていても正しく動作します。
  • パスに半角スペースが含まれていても正しく動作します。
  • 入力のディレクトリが実在しなくても良いです。

その他、おまけで下記の関数もついています。

# 絶対パスに変換
path_get_absolute "hoge" # => "/Users/omochi/hoge"
# パスの正規化
path_standardize "././a/b/../c//" # => "a/c"
# パスの結合
path_append "/usr/" "local" # => "/usr/local"

使い方

シェルスクリプトにコードを全部コピペするか、sourceコマンドで読み込ませます。
なお、bashのset -ueオプションは有り無し環境どちらでも動くように作ってあります。

sourceコマンドで読み込ませる場合は、下記のようにします。

main.bash
#!/bin/bash
set -ueo pipefail
script_dir=$(cd "$(dirname "$0")"; pwd)
PATH="$PATH:$script_dir"
source pathlib.bash

path_get_absolute "hoge" # => /Users/omochi/hoge

ディレクトリをパスに追加して、sourceで読み込ませています。
PATHを変更せずにsource $script_dir/pathlib.bashを指定する事もできますが、ディレクトリ分けされた複数のライブラリスクリプトが多段でsourceするような場合は、PATHでやらないときついと思います。

その他の呼び出し例やテストコードはgithubにおいてあります。

コード

pathlib.bash
#!/bin/bash
# set -ue 環境からのsourceを想定

# $1: src array name
# $2: dest array name
# 配列をコピーする。
array_copy(){
	eval "$2=(\"\${$1[@]:0}\")"
}

# $1: string
# $2: needle string
# 部分文字列を探す。見つからなければ-1を返す。
str_pos(){
	local i
	local n=$(( ${#1} - ${#2} + 1 ))
	for (( i = 0 ; i < n ; i++ )) ; do
		if [[ "${1:$i:${#2}}" == "$2" ]] ; then
			echo "$i"
			return 0
		fi
	done
	echo "-1"
}

# $1: string
# $2: delimiter string
# $3: dest array name
# 文字列を分割して配列に格納する。
# 空文字列は空の配列になる。
# デリミタは空文字列はダメ。
str_split(){
	local str=$1
	local __a=()
	if [[ "$2" == "" ]] ; then
		echo "delimiter is empty string" >&2
		return 1
	fi
	if [[ ${#str} -gt 0 ]] ; then
		while true ; do
			local pos="$(str_pos "$str" "$2")"
			if [[ $pos -eq -1 ]] ; then
				__a+=("$str")
				break
			fi
			__a+=("${str:0:$pos}")
			str="${str:$(( pos + ${#2} ))}"
		done
	fi
	array_copy "__a" "$3"
}

# $1: array name
# $2: glue
# 配列を結合した文字列を返す。
array_join(){
	local __a=()
	local i
	local str=""
	array_copy "$1" "__a"
	for (( i = 0 ; i < ${#__a[@]} ; i++ )) ; do
		if [[ $i -gt 0 ]] ; then
			str="$str$2"
		fi
		str="$str${__a[$i]}"
	done
	echo "$str"
}

# $1: path string
# パス文字列が絶対パスかどうかを返り値で返す。
path_is_absolute(){
	[[ "${1:0:1}" == "/" ]]
}

# $1: base path string
# $2: append path string
# パス文字列を追加する。
# baseの末尾に[/]が無ければ追加する。
# 追加するパスは絶対パスはダメ。
path_append(){
	if path_is_absolute "$2" ; then
		echo "absolute path can't append: $2" >&2
		return 1
	fi
	if [[ "$1" == "" ]] ; then
		echo "$2"
		return 0
	fi
	echo "${1%/}/$2"
}

# $1: path string
# $2: dest array name
# パス文字列をパス要素配列に分割する。
# 絶対パスの場合、配列の第一要素は[/]になる。
# 例
# a/b//c/ => [ "a", "b", "", "c", "" ]
# /a/b//c => [ "/", "a", "b", "", "c"]
# 結果の配列は、path_array_joinで元のパス文字列に戻る。
path_split(){
	local __a2=() # 呼び出し先での衝突回避
	if path_is_absolute "$1" ; then
		str_split "${1:1}" "/" "__a2"
		__a2=("/" "${__a2[@]:0}")
	else
		str_split "$1" "/" "__a2"
	fi
	array_copy "__a2" "$2"
}

# $1: array name
# パス要素配列を結合してパス文字列にする。
# パス要素配列についてはpath_splitを参照。
path_array_join(){
	local __a2=() # 呼び出し先での衝突回避
	array_copy "$1" "__a2"
	if [[ "${__a2[@]:0:1}" == "/" ]] ; then
		# 先頭要素を剥がす
		echo -n "/"
		__a2=("${__a2[@]:1}")
	fi
	array_join "__a2" "/"
}

# $1: path string
# パス文字列を正規化する。
# 連続する[/]は1つにする。
# [.]は除去する。
# [..]はできるだけ除去する。
path_standardize(){
	local i=0
	local src_items=()
	local ret_items=()
	path_split "$1" "src_items"
	while [[ $i -lt ${#src_items[@]} ]] ; do
		local item=${src_items[$i]}
		if [[ "$item" == "" || "$item" == "." ]] ; then
			# 何もしない
			:
		elif [[ "$item" == ".." && ${#ret_items[@]} -gt 0 ]] ; then
			# ..が来ていて、左側に要素がある
			local n=${#ret_items[@]}
			local last=${ret_items[$n-1]}
			if [[ "$last" == "/" ]] ; then
				# ルートの/は..を飲み込む。
				:
			elif [[ "$last" == ".." ]] ; then
				# ..に..は連結する
				ret_items+=("..")
			else
				# 通常は末尾を食う
				ret_items=("${ret_items[@]:0:$n-1}")
			fi
		else
			# 付けたす
			ret_items+=("$item")
		fi
		# 次に進む
		i=$(( i + 1 ))
	done
	path_array_join "ret_items"
}

# $1: path string
# パス文字列を正規化した絶対パスにして返す。
# 相対パスだった場合、カレントディレクトリを用いて絶対化する。
path_get_absolute(){
	local path=$1
	if ! path_is_absolute "$1" ; then
		path=$(path_append "$(pwd)" "$path")
	fi
	path_standardize "$path"
}

# $1: from path string
# $2: to path string
# fromパスからtoパスへの相対パスを求める。
# 結果の相対パスをfromに結合すると、toを示す。
path_get_relative(){
	local from_items=()
	local to_items=()
	path_split "$(path_get_absolute "$1")" "from_items"
	path_split "$(path_get_absolute "$2")" "to_items"

	local i
	# 一致する限り進む
	for (( i = 0 ; i < ${#from_items[@]} && i < ${#to_items[@]} ; i++ )) ; do
		if [[ "${from_items[$i]}" != "${to_items[$i]}" ]] ; then
			break
		fi
	done
	local start_i=$i
	# fromの残りを..の繰り返しに変換する
	local ret_items=()
	for (( i = $start_i ; i < ${#from_items[@]} ; i++ )) ; do
		ret_items+=("..")
	done
	# toの残りをつなげる
	for (( i = $start_i ; i < ${#to_items[@]} ; i++ )) ; do
		ret_items+=("${to_items[$i]}")
	done
	path_array_join "ret_items"
}

17
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?