シェルスクリプト

2010/07/24新規 | 2010/10/25更新 | 2014/01/28更新 | 2014/01/30更新 | 2014/01/31更新 | 2014/02/03更新 | 2014/02/08更新

筆者が書いた sh スクリプトのうち、もしかしたら他の人にも有用かもしれないものを、何かのついでに紹介します。

Contents

同名のファイルを探す「samenames.sh

「同名のファイルをリストアップしたい」。そんな欲求が生じる時はないでしょうか。目的のファイル名がわかっているときは、find . -name name すれば事足りますが、例えば、ファイル群を一カ所にすべて移動させようとする際に、既に同名のファイルがあると、移動できないか、上書きの恐れがあります。よって、予め同名のファイルをリストアップしてみるのがよさげです。以下のように使います。

$ samenames.sh . -type f

ちなみに、コマンドライン引数に与えているのは、単に、find のための引数です。このようにすると、カレントディレクトリ配下のファイル群にて、同名のファイルが表示されます。

そのシェルスクリプト「samenames.sh」は以下の通りです。

samenames.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ $# -lt 1 ]; then
  cat <<EOF
$me - find same named files
usage:
	$me find_pathname ... [find_expression ...]
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
c=0
find "$@" -print | while read p; do
  dir="`dirname \"$p\"`"
  base="`basename \"$p\"`"
  echo "$base	$dir"
done | sort | while IFS="	" read base1 dir1; do
  if [ "$c" = 0 ]; then
    base0="$base1"
    dir0="$dir1"
    flag=false
  else
    if [ "$base1" = "$base0" ]; then
      [ "$c" = 1 ] &&
      echo "$dir0/$base0"
      echo "$dir1/$base1"
      flag=:
    else
      base0="$base1"
      dir0="$dir1"
      c=0
      $flag && echo
      flag=false
    fi
  fi
  c=`expr $c + 1`
done

これにより、探したい場所のファイルそれぞれが互いに同名をもつか調べる事が出来ます。このシェルスクリプトの要は、find で列挙したパス名をベース名を行頭に列挙し直し、それを sort で並べ直した上で、同一のベース名が複数行に続く行のみを表示していることにあります。

[2011/10/20] 「while read」の箇所を「while IFS=" " read(空白はタブ)」に修正し、スペースを含むファイル名に対応しました。

同名のファイルで内容が異なるファイルを探す「samenamescmp.sh

先の発展で、同名のファイルで内容が異なるファイルを探しましょう(使い方も同じくします)。そのためのシェルスクリプト「samenamescmp.sh」は以下の通りです。

samenamescmp.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ $# -lt 1 ]; then
  cat <<EOF
$me - find same named and different files/timestamps for same contents
usage:
	$me find_pathname ... [find_expression ...]
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
case "$me" in
*time*) timestamp=:;;
*)      timestamp=false;;
esac
c=0
n=0
find "$@" ! -type d -print | while read p; do
  dir="`dirname \"$p\"`"
  base="`basename \"$p\"`"
  echo "$base	$dir"
done | sort | while IFS="	" read base1 dir1; do
  if [ "$c" = 0 ]; then
    base0="$base1"
    dir0="$dir1"
    flag=false
  else
    if [ "$base1" = "$base0" ]; then
      cmp -s "$dir0/$base0" "$dir1/$base1" || flag=:
      if $flag; then
        [ "$n" = 0 ] &&
        echo "$dir0/$base0"
        echo "$dir1/$base1	differ in contents"
      elif $timestamp; then
        { test "$dir0/$base0" -nt "$dir1/$base1" ||
	  test "$dir0/$base0" -ot "$dir1/$base1"; } && flag=:
	if $flag; then
          [ "$n" = 0 ] &&
          echo "$dir0/$base0"
          echo "$dir1/$base1	differ in timestamp"
        fi
      fi
      $flag && n=`expr $n + 1`
    else
      base0="$base1"
      dir0="$dir1"
      c=0
      [ "$n" != 0 ] && echo
      n=0
      flag=false
    fi
  fi
  c=`expr $c + 1`
done

[2011/10/20] 「while read」の箇所を「while IFS=" " read(空白はタブ)」に修正し、スペースを含むファイル名に対応しました。

同名のファイルで内容が等しいがタイムスタンプが異なるファイルを探す「samenamescmptime.sh

さらに発展で、同名のファイルで内容は等しいがタイムスタンプが異なるファイルを探しましょう(使い方も同じくします)。そのためのシェルスクリプト「samenamescmptime.sh」は先の「samenamescmp.sh」のシンボリックリンクにするとそのように動作します。このシェルスクリプトの要は、先の例と同様に find で列挙したパス名をベース名を行頭に列挙し直し、それを sort で並べ直した上で、同一のベース名が複数行に続く行について、cmptest で内容の同一性やタイムスタンプを検証している点にあります。

ディレクトリ内で大文字小文字を無視して同名のファイルを探す「samenameicase.sh

これは、大文字小文字を区別するファイルシステムのアイテムを区別しないファイルシステムへ移行するに先立ち、大文字小文字を無視すると同名になってしまうファイル名を検索するためのシェルスクリプトです。以下のように使います。

$ samenameicase.sh ~/bin ~/lib

そのシェルスクリプト「samenameicase.sh」は以下の通り。

samenameicase.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ $# -lt 1 ]; then
  cat <<EOF
$me - find case-insensitive same named files non-recursively
usage:
	$me pathname ...
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
while [ "$1" != "" ]; do
  find "$1" -maxdepth 1 ! -name "`basename \"$1\"`" -print | while read p; do
    dir="`dirname \"$p\"`"
    base="`basename \"$p\"`"
    echo "$base	$dir"
  done | while IFS="	" read b d; do
    find "$d" ! -path "$d" -maxdepth 1 -iname "$b" ! -name "$b" -print
  done | sort -f
  shift
done

UnixのファイルシステムからMacのファイルシステム等にファイルをコピーする際だけでなく、他人にファイルを渡す前に、余計なトラブルを避けるためにこれでチェックするとよいでしょう。このシェルスクリプトの要は、find を「while read」を介して二段に渡って利用していることにあります。

ディレクトリ内で大文字小文字を無視して同名のファイルを再帰的に探す「samenamesicase.sh

上記とほぼ同様ですが、ディレクトリを再帰的にすべて探したい場合はこちらを以下のように使います。

$ samenamesicase.sh ~/

そのシェルスクリプト「samenamesicase.sh」は以下の通り。先のと微妙に異なるだけです。

samenamesicase.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ $# -lt 1 ]; then
  cat <<EOF
$me - find case-insensitive same named files recursively
usage:
	$me pathname ...
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
while [ "$1" != "" ]; do
  find "$1" -type d -print | while read q; do
    find "$q" -maxdepth 1 ! -name "`basename \"$q\"`" -print | while read p; do
      dir="`dirname \"$p\"`"
      base="`basename \"$p\"`"
      echo "$base	$dir"
    done | while IFS="	" read b d; do
      find "$d" ! -path "$d" -maxdepth 1 -iname "$b" ! -name "$b" -print
    done | sort -f
  done
  shift
done

UnixのファイルシステムからMacのファイルシステムにホームディレクトリを移行するに先立ち、これでチェックするとよいでしょう。このシェルスクリプトの要は、find を「while read」を介して三段に渡って利用していることにあります。

一意なファイル

一意にファイル命名「uv.sh

例えば、文書をダウンロードしたけど「どうも前にも同じようなものダウンロードしてたみたいだけど、それが微妙に変更されているのか」、同一かもしれない文書ファイルが重複して溜まってるかもしれず、以下のような欲求がでてきます:

ダウンロードする前にすでに保持しているものと同一かどうか調べればよいのでしょうが、現実的には困難です。よって、ダウンロードした後、どうすればよいか考えてみます。

ひとつの方法は、内容によって「一意のファイル名」を命名することです。「命名しようとした時に既に存在するファイル名があるなら警告」そういったシェルスクリプトがあると便利ではないでしょうか。以下のように使います。

$ cd ~/Downloads
$ ls article1.pdf	# たった今ダウンロードしたファイル
$ uv.sh -s .pdf *.pdf	# カレントディレクトリにある *.pdf ファイルを一意なファイル名+拡張子「.pdf」にリネイム(mv)してしまう

既に同一のファイル名が存在するならリネイムが失敗して、その旨が表示されます。このシェルスクリプト「uv.sh」は以下の通りです。

uv.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ "$#" -lt 1 ]; then
  cat <<EOF
$me - rename files by message digest
usage:
	$me [-m md5|sha1] [-s suf] file(s)
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
mdc=sha1
endoptions=false
keepsuffix=:
suf=
while [ "$1" != "" ]; do
  $endoptions ||
  case "$1" in
  --) endoptions=:; shift; continue;;
  -m) shift; mdc="$1"; shift; continue;;
  -s) keepsuffix=false; shift; suf="$1"; shift; continue;;
  esac
  $keepsuffix && suf="`echo \"$1\" | sed -ne 's/.*\(\.[^.][^.]*\)$/\1/p'`"
  mds="`openssl \"$mdc\" \"$1\" | sed -ne 's/.*= \(.*\)/\1/p'`"
  dst="`dirname \"$1\"`"/"$mds$suf"
  if [ ! -f "$dst" ]; then
    echo mv "$1" "$dst"
    mv "$1" "$dst" &&
    case "$me" in
    *ln*)
      echo ln -s "$dst" "$1"
      ln -s "$dst" "$1"
      ;;
    esac
  else
    echo "already exists $dst for $1"
  fi
  shift
done

このシェルスクリプトを活用すれば、内容が同一のファイルを保持しないように心がける事が簡単になります。

ちなみに、このシェルスクリプトファイル名を「uvln.sh」のようにシンボリックリンクしたりすると、mv するのではなく ln -s するように動作を変えるようになっています。オリジナルのファイル名を保持したい場面で有用かと思います。このシェルスクリプトの要は、openssl(1) を使ってファイルのダイジェストを生成し、その文字列を新たなファイル名とすることにあります。

一意でないファイルを探す「samefiles.sh

上述のシェルスクリプトだと、ファイルをリネームしてしまうので、「単に内容が同一のファイルを探したいだけなのに」という場面もあるかと思います。

先のシェルスクリプトよりも計算量は掛かりますが、内容が同一のファイルを表示する事も可能です。以下のように使います。

$ cd ~/Downloads
$ samefiles.sh . -name '*.pdf'

ちなみに、コマンドライン引数に与えているのは、単に、find のための引数です。このようにすると、カレントディレクトリ配下のファイル群にて、ファイル名が '*.pdf' にマッチするファイルから、内容が同一のファイルが表示されます。

そのシェルスクリプト「samefiles.sh」は以下の通りです。

samefiles.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ $# -lt 1 ]; then
  cat <<EOF
$me - find same files by message digest
usage:
	$me find_pathname ... [find_expression ...]
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi
mdc=md5
c=0
find "$@" -type f ! -type l -print | while read file; do
  mds="`openssl \"$mdc\" \"$file\" | sed -ne 's/.*= \(.*\)/\1/p'`"
  echo $mds	"$file"
done | sort | while read mds1 file1; do
  if [ "$c" = 0 ]; then
    mds0="$mds1"
    file0="$file1"
    flag=false
  else
    if [ "$mds1" = "$mds0" ]; then
      [ "$c" = 1 ] &&
      echo "$file0"
      echo "$file1"
      flag=:
    else
      mds0="$mds1"
      file0="$file1"
      c=0
      $flag && echo
      flag=false
    fi
  fi
  c=`expr $c + 1`
done

これにより、探したい場所のファイルそれぞれが互いに内容が同一か否か調べる事が出来ます。このシェルスクリプトの要は、openssl(1) を使ってファイルのダイジェストを生成し、その文字列とパス名を sort で並べ直した上で、同一のダイジェストが複数行に続く行についてのみパス名を表示していることにあります。

ファイルの慎重な複製・移動・更新

ファイルの慎重な複製「cp.sh

ファイルを cp しようとしたとき、同名のファイルが存在したなら、複製を続行するか否かは cp -i で対話的に決めることが出来ますが、その判断をする際に、ファイルの内容が同一なのか、ファイルのタイムスタンプやサイズ等で決定したい場合があります。

以下のシェルスクリプト cp.sh は、ターゲットファイルがソースファイルと同一なら何もせず、さもなくば、双方のファイルの諸情報を表示して対話的に cp を続行します。ターゲットファイルが存在しなければ、単に複製を行ないます。

cp.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ "$#" -lt 2 ]; then
  cat <<EOF
$me - copy file to another or copy file(s) into directory
usage:
	$me file file
	$me file(s) directory
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi

copy_one_to_dst(){
  f="$1"
  b="`basename \"$f\"`"
  [ ! -d "$f" ] || return 1
  [ -f "$f" ] || return 1
  if [ -d "$dst" ]; then
    if [ -f "$dst/$b" ]; then
      if cmp "$f" "$dst/$b" > /dev/null 2>&1; then
	return 0
      fi
      ls -Tl "$f" "$dst/$b"
      echo "$me: overwrite:	$f	$dst/$b"
      cp -i -p "$f" "$dst"
      return 0
    fi
    echo cp -p "$f" "$dst"
    cp -p "$f" "$dst"
  else
    if [ -f "$dst" ]; then
      if cmp "$f" "$dst" > /dev/null 2>&1; then
	return 0
      fi
      ls -Tl "$f" "$dst"
      echo "$me: overwrites:	$f	$dst"
      cp -i "$f" "$dst"
      return 0
    fi
    echo cp -p "$f" "$dst"
    cp -p "$f" "$dst"
  fi
  return 0
}

eval dst="\${$#}"
if [ ! -d "$dst" ]; then
  if [ $# -ne 2 ]; then
    echo "$me: error:	[ ! -d \"$dst\" ] && \$#($#) != 2"
  else
    copy_one_to_dst "$1"
  fi
else
  i=1
  while [ $i -lt $# ]; do
    eval src="\${$i}"
    copy_one_to_dst "$src"
    i=`expr $i + 1`
  done
fi

ファイルの慎重な移動「mv.sh

ファイルを mv しようとしたとき、同名のファイルが存在したなら、複製を続行するか否かは mv -i で対話的に決めることが出来ますが、その判断をする際に、ファイルの内容が同一なのか、ファイルのタイムスタンプやサイズ等で決定したい場合があります。

以下のシェルスクリプト mv.sh は、ターゲットファイルがソースファイルと同一なら対話的にソースファイルの消去を促し、さもなくば、双方のファイルの諸情報を表示して対話的に mv を続行します。ターゲットファイルが存在しなければ、単に移動を行ないます。

mv.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ "$#" -lt 2 ]; then
  cat <<EOF
$me - move file to another or move file(s) into directory
usage:
	$me file file
	$me file(s) directory
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi

move_one_to_dst(){
  f="$1"
  b="`basename \"$f\"`"
  [ ! -d "$f" ] || return 1
  [ -f "$f" ] || return 1
  if [ -d "$dst" ]; then
    if [ -f "$dst/$b" ]; then
      if cmp "$f" "$dst/$b" > /dev/null 2>&1; then
	echo "$me: remove:	$f"
	rm -i "$f"
	return 0
      fi
      ls -Tl "$f" "$dst/$b"
      echo "$me: overwrite:	$f	$dst/$b"
      mv -i "$f" "$dst"
      [ ! -f "$f" ] || {
        echo "$me: remove:	$f"
        rm -i "$f"
      }
      return 0
    fi
    echo mv "$f" "$dst"
    mv "$f" "$dst"
  else
    if [ -f "$dst" ]; then
      if cmp "$f" "$dst" > /dev/null 2>&1; then
	echo "$me: remove:	$f"
	rm -i "$f"
	return 0
      fi
      ls -Tl "$f" "$dst"
      echo "$me: overwrites:	$f	$dst"
      mv -i "$f" "$dst"
      [ ! -f "$f" ] || {
        echo "$me: remove:	$f"
	rm -i "$f"
      }
      return 0
    fi
    echo mv "$f" "$dst"
    mv "$f" "$dst"
  fi
  return 0
}

eval dst="\${$#}"
if [ ! -d "$dst" ]; then
  if [ $# -ne 2 ]; then
    echo "$me: error:	[ ! -d \"$dst\" ] && \$#($#) != 2"
  else
    move_one_to_dst "$1"
  fi
else
  i=1
  while [ $i -lt $# ]; do
    eval src="\${$i}"
    move_one_to_dst "$src"
    i=`expr $i + 1`
  done
fi

ファイルの慎重な更新「ow.sh

上記 cp, mv と非常に似通っていますが、ターゲットファイルが存在する場合のみ、対話的に複製を行ないたい場合があります。

以下のシェルスクリプト ow.sh は、ターゲットファイルがソースファイルと同一なら何もせず、さもなくば、双方のファイルの諸情報を表示して対話的に cp を続行します。ターゲットファイルが存在しなければ、何もしません。

ow.sh
#!/bin/sh
me="`basename \"$0\"`"
if [ "$#" -lt 2 ]; then
  cat <<EOF
$me - overwrite file to another or overwrite file(s) into directory
usage:
	$me file file
	$me file(s) directory
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
  exit
fi

overwrite_one_to_dst(){
  f="$1"
  b="`basename \"$f\"`"
  [ ! -d "$f" ] || return 1
  [ -f "$f" ] || return 1
  if [ -d "$dst" ]; then
    if [ -f "$dst/$b" ]; then
      if cmp "$f" "$dst/$b" > /dev/null 2>&1; then
	return 0
      fi
      ls -Tl "$f" "$dst/$b"
      echo "$me: overwrite:	$f	$dst/$b"
      cp -i -p "$f" "$dst"
      return 0
    fi
  else
    if [ -f "$dst" ]; then
      if cmp "$f" "$dst" > /dev/null 2>&1; then
	return 0
      fi
      ls -Tl "$f" "$dst"
      echo "$me: overwrites:	$f	$dst"
      cp -i "$f" "$dst"
      return 0
    fi
  fi
  return 0
}

eval dst="\${$#}"
if [ ! -d "$dst" ]; then
  if [ $# -ne 2 ]; then
    echo "$me: error:	[ ! -d \"$dst\" ] && \$#($#) != 2"
  else
    overwrite_one_to_dst "$1"
  fi
else
  i=1
  while [ $i -lt $# ]; do
    eval src="\${$i}"
    overwrite_one_to_dst "$src"
    i=`expr $i + 1`
  done
fi

これらのシェルスクリプトの要は、cmp の返り値によって挙動を制御していることに加えて、cp -i, rm -i, mv -i の対話モードを有効利用していることにあります。

リモートのファイルとの diff を見る「rdiff.sh

rsync で複数のマシンで rsync -aunv * remote:path_to/ してると rsync で diff のようなファイル毎の差異を見たい時があります。逆に言えば、diff に rsync のような使い勝手が欲しい時があります。

「リモート diff」でウェブサイトを検索してみると、たくさん要望はあるようなのですが、どれもこちらの要望に微妙に合いません。よって、自前でシェルスクリプトを書くことにしました。以下のように使います。

$ rdiff.sh -u .xinitrc remote_host:
$ rdiff.sh --reverse -u ~/bin/* remote_host:bin/ | less

"--reverse" オプションは diff コマンドの二つのファイル並びを入れ替えることになるオプションで、diff と GNU patch のノリで使い手が意図してる新旧と、rsync のノリでコマンドラインの末尾にリモートホストを指定しなければならない制約の双方を満たすためのものです。それ以外の後に続くオプションはそのまま diff コマンドに渡されます。

まず実装にあたって、素のシェルでは煩雑になってしまうことが予想されるので、POSIX準拠の ksh の拡張機能を使い、bash や zsh でも意図した動作をすることを目標にしました。それが以下のシェルスクリプトです。

rdiff.sh
#!/bin/ksh
reverse=false
while [ ! -z "$1" ]; do
  case "$1" in
  --reverse) reverse=:;;
  *) break;;
  esac
  shift
done

rdiff(){
  rdiff_args=("$@")
  files=("${rdiff_args[${#rdiff_args[@]}-2]}" "${rdiff_args[${#rdiff_args[@]}-1]}")
  $reverse && files=("${rdiff_args[${#rdiff_args[@]}-1]}" "${rdiff_args[${#rdiff_args[@]}-2]}")
  for ((i=0; i<${#rdiff_args[@]}-2; i++)); do
    rdiff_opts[$i]="${rdiff_args[$i]}"
  done

  equiv_path=
  for i in 0 1; do
    case "${files[$i]}" in
    *:) equiv_path=$(echo "${files[($i+1)%2]}" | sed 's/^.*://');;
    esac
  done
  for i in 0 1; do
    case "${files[$i]}" in
    */)
      case "${files[($i+1)%2]}" in
      */) ;;
      *) files[$i]+=$(basename "${files[($i+1)%2]}");;
      esac
    esac
  done

  for i in 0 1; do
    case "${files[$i]}" in
    *:*)
      eval $(echo "${files[$i]}" | sed -e 's|\([^:]*\):\([^:]*\)|host=\1;path=\2|')
      if [ -z "$path" -a -n "$equiv_path" ]; then
        path="$equiv_path"
      fi
      [ -z "$equiv_path" ] || case "$path" in
        [^/]*)
          prefix=$(pwd | sed "s|^$HOME/||")
	  path="$prefix/$path"
          ;;
      esac
      if [ -z "$host" ]; then
        files[$i]="$path"
      else
        files[$i]="<(ssh $host cat \"$path\" 2> /dev/null)"
      fi
      ;;
    esac
  done
  echo diff "${rdiff_opts[@]}" "${files[@]}"
  eval diff "${rdiff_opts[@]}" "${files[@]}"
}

args=("$@")
case "${args[${#args[@]}-1]}" in
*:|*/)
  for ((i=0; i<${#args[@]}; i++)); do
    case "${args[$i]}" in
    -*) opts[$i]="${args[$i]}";;
    *) break;;
    esac
  done
  for ((j=i; j<${#args[@]}-1; j++)); do
    rdiff "${opts[@]}" "${args[$j]}" "${args[${#args[@]}-1]}"
  done
  ;;
*)
  rdiff "$@";;
esac

一行目の #!/bin/ksh は bash の場合は #!/bin/bash に置き換えることが出来、zsh の場合は、#!/bin/zsh -o KSH_ARRAYS に置き換えれば同様に動作します。

このシェルスクリプトの要は、シェルコマンド言語では規定されていない配列変数プロセス置換を積極的に利用していることにあります。

ファイル名の交換

例えば、何かファイルをストリームエディタsed(1)等で編集し、

$ sed -e 's/  *$//' main.c > main.c~

編集内容を確認:

$ diff -u main.c main.c~

それで問題なければ、これらのファイル名を交換したい、そういう場合、単純作業ながらちょっとだけ面倒で、万が一間違えるとファイルが失われてしまいます。以下の二つのシェルスクリプトはどちらもこのようなファイル名の交換の手間を、以下のように軽減するためのものです。

$ swapnames main.c main.c~
$ rotatenames main.c main.c~

ファイル名を逆順で交換「swapnames

以下のシェルスクリプト swapnames は、A, B, C, D というファイルをコマンドライン引数に指定した場合、その内容はこの順でそのままで、順に D, C, B, A というファイル名に変更します。

swapnames
#!/bin/sh
swapapair(){
  if [ "$1" = "$2" ]; then
    :
  elif [ -f "$1" -a -f "$2" ]; then
    tmpname="$1.tmp"
#    name="`basename $1`"; tmpname="`mktemp \"./$name.XXXXXX\"`"
    mv "$1" $tmpname
    mv "$2" "$1"
    mv $tmpname "$2"
  elif [ -f "$1" -a ! -f "$2" ]; then
    mv "$1" "$2"
  elif [ ! -f "$1" -a -f "$2" ]; then
    mv "$2" "$1"
  fi
}
l=1
u=$#
if [ $# = 0 ]; then
  me="`basename \"$0\"`"
  cat <<EOF
$me - swap filename(s)
usage:
        $me file1 file2 ...
EOF
  exit
fi
while [ $l -lt $u ]; do
  eval lfile="\${$l}"
  eval ufile="\${$u}"
  echo swapapair "$lfile" "$ufile"
  swapapair "$lfile" "$ufile"
  l=`expr $l + 1`
  u=`expr $u - 1`
done

連番でファイル名を付けたけど、やっぱり逆順の方がいいや、というときに便利です(そんなことは稀かとは思いますが…)。

ファイル名を回転で交換「rotatenames

以下のシェルスクリプト rotatenames は、A, B, C, D というファイルをコマンドライン引数に指定した場合、その内容はこの順でそのままで、順に D, A, B, C というファイル名に変更します。

rotatenames
#!/bin/sh
swapapair(){
  if [ "$1" = "$2" ]; then
    :
  elif [ -f "$1" -a -f "$2" ]; then
#    tmpname="$1.tmp"
    name="`basename $1`"; tmpname="`mktemp \"./$name.XXXXXX\"`"
    mv "$1" $tmpname
    mv "$2" "$1"
    mv $tmpname "$2"
  elif [ -f "$1" -a ! -f "$2" ]; then
    mv "$1" "$2"
  elif [ ! -f "$1" -a -f "$2" ]; then
    mv "$2" "$1"
  fi
}
n=1
while [ "$1" != "" ]; do
  case "$1" in
  --) shift; break;;
  -n) shift; n="$1";;
  *) break;
  esac
  shift
done
i=1
if [ $# = 0 ]; then
  me="`basename \"$0\"`"
  cat <<EOF
$me - rotate filename(s)
usage:
	$me [-n number] file1 file2 ...
EOF
  exit
fi
sed_script=
while [ $i -lt $# ]; do
  j=`expr \( $i - 1 + $# + $n \) % $# + 1`
  eval ifile="\${$i}"
  eval jfile="\${$j}"
  ifile="`echo \"$ifile\" | sed -e \"$sed_script\"`"
  echo swapapair "$ifile" "$jfile"
  swapapair "$ifile" "$jfile"
  i=`expr $i + 1`
  sed_script="${sed_script}s:^$jfile\$:$ifile:;"
done

オプションに -n 3 を指定し、A, B, C, D というファイルをコマンドライン引数に指定した場合、その内容はこの順でそのままで、順に B, C, D, A というファイル名に変更します。

連番でファイル名を付けたけど、1つずつずれちゃった、というときに便利です(そんなことは稀かとは思いますが…)。

これらのシェルスクリプトの要は、eval で任意の添字のコマンドライン引数の変数展開を行なっている点にあります。

各種書庫の変換・展開・格納

Unix には tar をはじめ、cpio, pax, xar などの書庫形式があり、さらに、圧縮形式も gzip, bzip2, xz など様々です。また、Windows 系では lha, zip, 7zip, rar などの圧縮兼書庫形式があり、コマンドラインで扱う場合にはオプションの指定の仕方がそれぞれ微妙に異なるので、あまり使わない書庫などでは使い方を覚えるのは苦痛を伴います。

そこで、ラッパーとして多くの書庫に対応したシェルスクリプトを書きました。まずは、そのヘルプを見てみます。

usage:
	rear.sh [-h|--help] [-l] [-f art] -t art archive.art [...]
	unar.sh [-h|--help] [-l] [-f art] archive.art [...]
	ar.sh [-h|--help] [-l] -t art directory [...]
requirement(s):
	tar, compress, uncompress, lha, gzip, unzip, zip, bzip2,
	lzma, xz, cpio, pax, xar, unrar and
	rpmtocpout (see rpmtocpout.c) for RPM format
source archive type:
	lzh, zip, tar, taz, tgz, tbz2, tlz, txz, 7z, t7z, cpio, cpz, cpgz, cpbz2, cplz, cpxz, pax, paz, pgz, pbz2, plz, pxz, xar, rpm, rar, ..
target archive type:
	lzh, zip, tar, taz, tgz, tbz2, tlz, txz, 7z, t7z, cpio, cpz, cpgz, cpbz2, cplz, cpxz, pax, paz, pgz, pbz2, plz, pxz, xar, ..
author(s):
	Taiji Yamada <taiji@aihara.co.jp>

art に指定するのは一部は短縮した書庫の拡張子です。RPM と RAR を除き、展開・伸長と格納・圧縮の双方に対応しています。具体的な使い方の前に、このシェルスクリプトのソースコードを紹介します。

rear.sh
#!/bin/sh
prefix(){
  echo "$1" | sed -e 's|\(\.[^./]*\)$||'
}
suffix(){
  echo "$1" | sed -ne 's|^\(.*\)\(\.[^./]*\)$|\2|p'
}
me="`basename \"$0\"`"
unar_types='
lzh
zip
tar
taz
tgz
tbz2
tlz
txz
7z
t7z
cpio
cpz
cpgz
cpbz2
cplz
cpxz
pax
paz
pgz
pbz2
plz
pxz
xar

rpm
rar
'
ar_types='
lzh
zip
tar
taz
tgz
tbz2
tlz
txz
7z
t7z
cpio
cpz
cpgz
cpbz2
cplz
cpxz
pax
paz
pgz
pbz2
plz
pxz
xar
'
usage(){
  cat <<EOF
usage:
	rear.sh [-h|--help] [-l] [-f art] -t art archive.art [...]
	unar.sh [-h|--help] [-l] [-f art] archive.art [...]
	ar.sh [-h|--help] [-l] -t art directory [...]
requirement(s):
	tar, compress, uncompress, lha, gzip, unzip, zip, bzip2,
	lzma, xz, cpio, pax, xar, unrar and
	rpmtocpout (see rpmtocpout.c) for RPM format
source archive type:
	`for art in $unar_types; do printf "%s, " $art; done; echo ..`
target archive type:
	`for art in $ar_types; do printf "%s, " $art; done; echo ..`
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
}
presuffix(){
  pre="`prefix \"$1\"`"
  suf="`suffix \"$1\"`"
  pre0="$pre"
  suf0="$suf"
  while [ "$suf0" != "" ]; do
    pre1="`prefix \"$pre0\"`"
    suf1="`suffix \"$pre0\"`$suf"
    [ "$suf0" = "$suf1" ] && break
    case "$suf1" in
    .tar.Z|.tar.gz|.tar.bz2|.tar.lzma|.tar.xz|.tar.7z)	pre="$pre1"; suf="$suf1"; break	;;
    .cpio.Z|.cpio.gz|.cpio.bz2|.cpio.lzma|.cpio.xz)	pre="$pre1"; suf="$suf1"; break	;;
    .pax.Z|.pax.gz|.pax.bz2|.pax.lzma|.pax.xz)		pre="$pre1"; suf="$suf1"; break	;;
    esac
    pre0="$pre1"
    suf0="$suf1"
  done
}
preferedsuffix(){
  case "$1" in
  .taz)		echo .tar.Z	;;
  .tgz)		echo .tar.gz	;;
  .tbz2)	echo .tar.bz2	;;
  .tlz)		echo .tar.lzma	;;
  .txz)		echo .tar.xz	;;
  .t7z)		echo .tar.7z	;;
  .cpz)		echo .cpio.Z	;;
  .cpgz)	echo .cpio.gz	;;
  .cpbz2)	echo .cpio.bz2	;;
  .cplz)	echo .cpio.lzma	;;
  .cpxz)	echo .cpio.xz	;;
  .paz)		echo .pax.Z	;;
  .pgz)		echo .pax.gz	;;
  .pbz2)	echo .pax.bz2	;;
  .plz)		echo .pax.lzma	;;
  .pxz)		echo .pax.xz	;;
  *)		echo "$1"	;;
  esac
}
check_ar_types(){
  for t in $ar_types; do
    [ "$t" = "$1" ] && break
  done
}
art_lzh(){	lha "$1"; }
art_zip(){	unzip -Z "$1"; }
art_tar(){	tar tf "$1"; }
art_taz(){	uncompress "$1"	| tar tf -; }
art_tgz(){	gzip -dc "$1"	| tar tf -; }
art_tbz2(){	bzip2 -dc "$1"	| tar tf -; }
art_tlz(){	lzma -dc "$1"	| tar tf -; }
art_txz(){	xz -dc "$1"	| tar tf -; }
art_7z(){	7z l "$1"; }
art_t7z(){	7z x -so "$1"	| tar tf -; }
art_cpio(){	cpio -it --quiet -I "$1"; }
art_cpZ(){	cpio -it --quiet -I "$1"; }
art_cpgz(){	cpio -it --quiet -I "$1"; }
art_cpbz2(){	cpio -it --quiet -I "$1"; }
art_cplz(){	cpio -it --quiet -I "$1"; }
art_cpxz(){	cpio -it --quiet -I "$1"; }
#art_pax(){	pax -f "$1"; }
art_pax(){	cpio -it --quiet -I "$1"; }
art_paz(){	cpio -it --quiet -I "$1"; }
art_pgz(){	cpio -it --quiet -I "$1"; }
art_pbz2(){	cpio -it --quiet -I "$1"; }
art_plz(){	cpio -it --quiet -I "$1"; }
art_pxz(){	cpio -it --quiet -I "$1"; }
art_xar(){	xar -tf "$1"; }

art_rpm(){	rpmtocpout "$1"	| cpio -it --quiet; }
art_rar(){	unrar l "$1"; }

unar_lzh(){	lha xqf "$1"; }
unar_zip(){	unzip -qo "$1"; }
unar_tar(){	tar xf "$1"; }
unar_taz(){	uncompress "$1"	| tar xf -; }
unar_tgz(){	gzip -dc "$1"	| tar xf -; }
unar_tbz2(){	bzip2 -dc "$1"	| tar xf -; }
unar_tlz(){	lzma -dc "$1"	| tar xf -; }
unar_txz(){	xz -dc "$1"	| tar xf -; }
unar_7z(){	7z x "$1"; }
unar_t7z(){	7z x -so "$1"	| tar xf -; }
unar_cpio(){	cpio -idm --quiet -I "$1"; }
unar_cpz(){	cpio -idm --quiet -I "$1"; }
unar_cpgz(){	cpio -idm --quiet -I "$1"; }
unar_cpbz2(){	cpio -idm --quiet -I "$1"; }
unar_cplz(){	cpio -idm --quiet -I "$1"; }
unar_cpxz(){	cpio -idm --quiet -I "$1"; }
#unar_pax(){	pax -r -f "$1"; }
unar_pax(){	cpio -idm --quiet -I "$1"; }
unar_paz(){	cpio -idm --quiet -I "$1"; }
unar_pgz(){	cpio -idm --quiet -I "$1"; }
unar_pbz2(){	cpio -idm --quiet -I "$1"; }
unar_plz(){	cpio -idm --quiet -I "$1"; }
unar_pxz(){	cpio -idm --quiet -I "$1"; }
unar_xar(){	xar -xf "$1"; }

unar_rpm(){	rpmtocpout "$1"	| cpio -idm --quiet; }
unar_rar(){	unrar x "$1"; }

ar_lzh(){	lha cq "$@"; }
ar_zip(){	zip -rq "$@"; }
ar_tar(){	tar cf "$@"; }
ar_taz(){	f="$1" && shift && tar cf - "$@" | compress -c	> "$f"; }
ar_tgz(){	f="$1" && shift && tar cf - "$@" | gzip -c	> "$f"; }
ar_tbz2(){	f="$1" && shift && tar cf - "$@" | bzip2 -c	> "$f"; }
ar_tlz(){	f="$1" && shift && tar cf - "$@" | lzma -c	> "$f"; }
ar_txz(){	f="$1" && shift && tar cf - "$@" | xz -c	> "$f"; }
ar_7z(){	7z a "$@"; }
ar_t7z(){	f="$1" && shift && tar cf - "$@" | 7z a -si "$f"; }
ar_cpio(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -O "$f"; }
ar_cpz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -ZO "$f"; }
ar_cpgz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -zO "$f"; }
ar_cpbz2(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -jO "$f"; }
#ar_cplz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet --lzma -O "$f"; }
ar_cplz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet | lzma -c	> "$f"; }
#ar_cpxz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -JO "$f"; }
ar_cpxz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet | xz -c		> "$f"; }
ar_pax(){	pax -w -f "$@"; }
ar_paz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -H ustar -ZO "$f"; }
ar_pgz(){	pax -w -zf "$@"; }
ar_pbz2(){	pax -w -jf "$@"; }
#ar_plz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -H ustar --lzma -O "$f"; }
ar_plz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -H ustar | lzma -c	> "$f"; }
#ar_pxz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -H ustar -JO "$f"; }
ar_pxz(){	f="$1" && shift && find "$@" -print0 | cpio -o0 --quiet -H ustar | xz -c	> "$f"; }
ar_xar(){	xar -cf "$@"; }

setunar(){
  [ "$fart" = "" ] || { unar="unar_${fart}"; presuffix "$1"; return; }
  unar="7z"
  presuffix "$1"
  [ "$suf" = "" ] || {
    case "$suf" in
    .jar)	unar=zip	;;
    .tar.Z)	unar=taz	;;
    .tar.gz)	unar=tgz	;;
    .tar.bz2|.tbz)	unar=tbz2	;;
    .tar.lzma)	unar=tlz	;;
    .tar.xz)	unar=txz	;;
    .tar.7z)	unar=t7z	;;
    .cpio.Z)	unar=cpz	;;
    .cpio.gz)	unar=cpgz	;;
    .cpio.bz2)	unar=cpbz2	;;
    .cpio.lzma)	unar=cplz	;;
    .cpio.xz)	unar=cpxz	;;
    .pax.Z)	unar=paz	;;
    .pax.gz)	unar=pgz	;;
    .pax.bz2)	unar=pbz2	;;
    .pax.lzma)	unar=plz	;;
    .pax.xz)	unar=pxz	;;
    *)	unar="`echo \"$suf\" | sed -e 's|^\.||'`"	;;
    esac
  }
  art="art_${unar}"
  unar="unar_${unar}"
}
listing=false
fart="`basename \"$0\" .sh | sed -n 's/\(.*\)to\(.*\)/\1/p'`"
tart="`basename \"$0\" .sh | sed -n 's/\(.*\)to\(.*\)/\2/p'`"
while [ "$1" != "" ]; do
  case "$1" in
  --)	shift; break	;;
  -h|--help)	usage; exit 0	;;
  -f)	shift; fart="$1"	;;
  -t)	shift; tart="$1"	;;
  -l)	listing=:	;;
  --list)
    for unar in $unar_types; do
      for ar in $ar_types; do
	[ "$unar" != "$ar" ] || continue
	echo "${unar}to${ar}".sh
      done
    done
    exit 0
    ;;
  *)	break	;;
  esac
  shift
done

if $listing; then
  while [ "$1" != "" ]; do
    [ ! -d "$1" ] || { find "$1" -print; shift; continue; }
    setunar "$1"
    "$art" "$1"
    shift
  done
  exit 0
fi
case "$me" in
unar.sh)
  while [ "$1" != "" ]; do
    setunar "$1"
    b="`basename \"$pre\"`"
    mkdir -p "$b" && (
      cd "$b" && "$unar" ../"$1"
    ) &&
    echo unarchived from "$1" to "$b"/
    shift
  done
  ;;
ar.sh)
  check_ar_types "$tart" && ar="ar_${tart}" || { usage; exit 1; }
  x="`preferedsuffix \".$tart\"`"
  while [ "$1" != "" ]; do
    [ -d "$1" ] || continue
    b="`basename \"$1\"`"
    d="$b$x"
    "$ar" "$d" "$1" &&
    echo archived from "$1"/ to "$d"
    shift
  done
  ;;
rear.sh|*)
  check_ar_types "$tart" && ar="ar_${tart}" || { usage; exit 1; }
  x="`preferedsuffix \".$tart\"`"
  t="`mktemp -d tmp_\"$me\"-XXXXXX`"
  while [ "$1" != "" ]; do
    setunar "$1"
    b="`basename \"$pre\"`"
    d="$pre$x"
    mkdir -p "$t/$b" && (
      cd "$t/$b" && "$unar" ../../"$1" && "$ar" ../../"$d" .
    ) &&
    touch -r "$1" "$d"
    echo rearchived from "$1" to "$d"
    rm -rf "$t/$b"
    shift
  done
  rm -rf "$t"
  ;;
esac

各種書庫の変換「rear.sh

では使い方を見てみましょう。以下は .tar.pax へ変換した例です。

$ rear.sh -t pax filename.tar 
rearchived from filename.tar to filename.pax

$ file filename.pax 
filename.pax: POSIX tar archive

-l」オプションで内容物を確認することが出来ます。

$ rear.sh -l filename.pax 
	:

また、次は .pax.7z へ変換した例です。

$ rear.sh -t 7z filename.pax 
	:
rearchived from filename.pax to filename.7z

但し、7z は Unix のオーナー・グループ情報を保存しないので、以下のように .t7z へ変換した方が望ましいです。

$ rear.sh -t t7z filename.pax 
	:
rearchived from filename.pax to filename.tar.7z

各種書庫の展開「unar.sh

また、この rear.shln -s rear.sh unar.sh のように別名 unar.sh で動作させることにより、書庫の展開(伸長)に用いることが出来ます。

では使い方を見てみましょう。以下は、.tar.7z をディレクトリ filename/ へ展開した例です。

$ unar.sh filename.tar.7z 
	:
unarchived from filename.tar.7z to filename/

$ ls -F1
filename/
filename.7z
filename.pax
filename.tar
filename.tar.7z

-l」オプションで内容物を確認することが出来ます。

$ unar.sh -l filename.tar.7z
	:

各種書庫の格納「ar.sh

さらに、この rear.shln -s rear.sh ar.sh のように別名 ar.sh で動作させることにより、書庫の格納(圧縮)に用いることが出来ます。

では使い方を見てみましょう。以下は、.tar.xz にディレクトリ filename/ を格納した例です。

$ ar.sh -t txz filename
archived from filename/ to filename.tar.xz

$ file filename.tar.xz
filename.tar.xz: xz compressed data

-l」オプションで内容物を確認することが出来ます。

$ ar.sh -l filename
	:

このシェルスクリプトの要は、いたるところで直前のコマンドの返り値「$!」を非明示的に AND-OR リスト「&&, ||」で処理を制御していることにあります。

テキスト処理

シェルそのものは正規表現の機能がなく、sed 等を組み合わせることによって、perl 等を使わなくてもある程度のテキスト処理ができます。

sed とその正規表現について以下にまとめつつあるので、そちらも参考にして下さい。

sed で力不足なら perl の前に Awk という選択肢もあります。Awk とその正規表現についても以下にまとめつつあるので、そちらも参考にして下さい。

正規表現全般については、以下にまとめつつあります。

しかし、「Awk は方言が多い」、「正規表現内の後方参照ができない」、「正規表現による置換における後方参照が GNU Awk の gensub じゃないとできない」、「文字列の最初の文字の添字が 1 から始まる」、「特殊文字のエスケープがシェルのそれよりも怪奇である」などなど、Awk を使うなら perl を使った方がまだマシだと思いますが、「機能過多でない」、「消極的実装ならコードの長寿命が期待できる」などのメリットはあり得ると思われます。以下は、sed で可能な範囲ですので Awk は登場してませんが、上記に多数の実例を記してあります。

コードから HTML への変換「code2html.sh

簡単な例として、ソースコードを HTML にペーストできるように「<」から「&lt;」への変換等を行い「<pre></pre>」で括るようなシェルスクリプトは以下のようになります。

code2html.sh
#!/bin/sh
#
# code2html.sh
#
# Copyright (C) 2007,2014 Taiji Yamada <taiji@aihara.co.jp>
#
me="`basename \"$0\"`"
usage(){
  cat <<EOF
$me - converts plain text to HTML text, <pre>stdin</pre>, <fieldset><legend>filename</legend><pre>file</pre></fieldset> and so on
usage:
	$me -c string [...]
	$me - < pathname
	$me pathname [...]
author(s):
	Taiji Yamada <taiji@aihara.co.jp>
EOF
}
code2text(){
  LC_ALL=C sed 's|&|\&amp;|g;s|<|\&lt;|g;s|>|\&gt;|g'
}
code2pre(){
  cat <<EOF
<pre>`code2text`
</pre>
EOF
}
code2fieldset(){
  cat <<EOF
<fieldset>
<legend><code>$1</code></legend>
`cat "$1" | code2pre`
</fieldset>
EOF
}
while [ "$1" != "" ]; do
  case "$1" in
  -h|--help)	usage; exit 0		;;
  -)		code2pre; exit		;;
  -c)	shift;	echo "$@" | code2text; exit	;;
  *)		code2fieldset "$1"	;;
  esac
  shift
done

これは以下のように使うとコマンドライン引数を「<」から「&lt;」への変換等行ないます。

$ code2html.sh -c 'Taiji Yamada <taiji@aihara.co.jp>'
Taiji Yamada &lt;taiji@aihara.co.jp&gt;

これは以下のように使うと入力ファイルを「<pre></pre>」で括ります。

$ code2html.sh - < filename.c
<pre>
        :
</pre>

また、以下のように使うとさらに「<fieldset></fieldset>」で括ります。

$ code2html.sh filename.c
<fieldset>
<legend><code>filename.c</code></legend>
<pre>
	:
</pre>
</fieldset>

このスクリプトの要は、単純な sed のワンライナーとシェルスクリプトの関数の用途に応じた呼び出しにあります。

HTML(5) の見出しから目次の章を作成する「content-html.sh

HTML の「<h2><a name="…"></a>〜</h2>」のような見出しから目次の章を自動的に作成するシェルスクリプトは以下のようになります。
content-html.sh
#!/bin/ksh
#
# content-html.sh
#
# Copyright (C) 2007,2014 Taiji Yamada <taiji@aihara.co.jp>
#
printindent(){
  [ ! "$1" -gt 0 ] || printf '%+*c' `expr "$1" \* 2` ' '
}
n=0
LC_ALL=C sed -ne 's/^[	 ]*<h\([2-9]\)><a name="\([^"][^"]*\)"[	 ]*><\/a>\(.*\)<\/h\1>.*$/\1	\2	\3/p;s/^[	 ]*<h\([2-9]\)[	 ][	]*id="\([^"][^"]*\)"[	 ]*>\(.*\)<\/h\1>.*$/\1	\2	\3/p' "$1" |
while IFS='	' read level name title; do
  eval contents_${n}_level='${level}'
  eval contents_${n}_name='${name}'
  eval contents_${n}_title='${title}'
  n=`expr $n + 1`
done
echo "<h2>Contents</h2>"
l=1
i=0
while [ $i -lt $n ]; do
  eval level="\$contents_${i}_level"
  eval name="\$contents_${i}_name"
  eval title="\$contents_${i}_title"
  while [ $l -lt $level ]; do
    indent=`expr $l - 2 + 1`
    printindent $indent
    echo "<ul>"
    l=`expr $l + 1`
  done
  while [ $l -gt $level ]; do
    indent=`expr $l - 2`
    printindent $indent
    echo "</ul>"
    if [ $l -gt 2 ]; then
      printindent $indent
      echo "</li>"
    fi
    l=`expr $l - 1`
  done
  indent=`expr $l - 2 + 1`
  printindent $indent
  printf "<li><a href=\"#%s\"\t>%s</a>" "$name" "$title"
  i=`expr $i + 1`
  if [ $i -lt $n ] && eval next_level="\$contents_${i}_level" && [ $level -lt $next_level ]; then
    echo
  else
    echo "</li>"
  fi
done
while [ $l -gt 1 ]; do
  indent=`expr $l - 2`
  printindent $indent
  echo "</ul>"
  if [ $l -gt 2 ]; then
    printindent $indent
    echo "</li>"
  fi
  l=`expr $l - 1`
done

これは以下のように使います。

$ content-html.sh index.html

これは、HTML5 の「<h2 id="…">〜</h2>」のような見出しにも対応しています。

このページのソースもこのシェルスクリプトを利用しており、冒頭の「Contents」セクションを見れば具体例がわかるでしょう。

そして、このシェルスクリプトのコードの要は、eval で変数を動的に定義していることと expr で簡単な整数演算をしていることにあります。

目次の章から HTML(5) の見出しを作成する「html-content.sh

逆に先のシェルスクリプトで作成されたような「<ul><li><a href="#…">〜</a>」のような見出しへのリンクから見出しを作成するシェルスクリプトは以下のようになります。

html-content.sh
#!/bin/ksh
#
# html-content.sh
#
# Copyright (C) 2007,2014 Taiji Yamada <taiji@aihara.co.jp>
#
me="`basename \"$0\"`"
LC_ALL=C sed -ne '/^[	 ]*<h2>Contents<\/h2>/,/^[	 ]*<h\([2-9][^>]*\)>\(.*\)<\/h\1>/{
  /^[	 ]*<h\([2-9][^>]*\)>\(.*\)<\/h\1>/{
    /^[	 ]*<h2>Contents<\/h2>/!q
    d
  }
  /^[	 ]*<ul>/{
    g
    s/$/\./
    h
    d
  }
  /^[	 ]*<\/ul>/{
    g
    s/\.$//
    h
    d
  }
  /^[	 ]*<li><a href="#\([^"]*\)"[	 ]*>\([^<]*\).*$/{
    s/^[	 ]*<li><a href="#\([^"]*\)"[	 ]*>\([^<]*\).*$/\1	\2/
    G
    s/^\(.*\)\n\([^\n]*\)$/\2	\1/
    p
  }
}' "$1" | while IFS='	' read level name title; do
  level="$level "
  level=${#level}
  case "$me" in
  *html5-*)
    printf "<h%u id=\"%s\"\t>%s</h%u>\n\n" $level "$name" "$title" $level;
    ;;
  *)
    printf "<h%u><a name=\"%s\"\t></a>%s</h%u>\n\n" $level "$name" "$title" $level;
    ;;
  esac
done

これは以下のように使います。

$ html-content.sh index.html

また、ln -s html-content.sh html5-content.sh のような別名を作成しておけば、以下のように HTML5 の「<h2 id="…">〜</h2>」のような見出しを出力します。

$ html5-content.sh index.html

このシェルスクリプトのコードの要は、sed において、ホールドスペースをカウンタとして用い、つまり、ホールドスペースに詰め込んだ「.」の文字数をセクションのネストレベルに対応させていることにあります。

HTML の外部へのリンクから参考文献の章を作成する「biblio-html.sh

HTML の「<a href="http://...">---</a>」のような外部へのリンクから参考文献の章を自動的に作成するシェルスクリプトは以下のようになります。

biblio-html.sh
#!/bin/ksh
#
# biblio-html.sh
#
# Copyright (C) 2014 Taiji Yamada <taiji@aihara.co.jp>
#
me="`basename \"$0\"`"
i=0
n=0
LC_ALL=C sed -ne '/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>/{
  s/^/#/
  s/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>/\
\1	\2\
#/g
  p
}
' "$1" | LC_ALL=C sed -e '/^#/d' |
while IFS='	' read url title; do
  echo "$i	$url	$title"
  i=`expr $i + 1`
done | LC_ALL=C sort -k 2 | LC_ALL=C uniq -f 1 | LC_ALL=C sort -n | while IFS='	' read i url title; do
  eval biblios_${n}_url="\$url"
  eval biblios_${n}_title="\$title"
  n=`expr $n + 1`
done

[ "$n" != 0 ] || exit 0
case "$me" in
*-html5*)
  cat <<EOF
<h2 id="bibliography"	>Bibliography</h2>
EOF
  ;;
*)
  cat <<EOF
<h2><a name="bibliography"	></a>Bibliography</h2>
EOF
  ;;
esac
cat <<EOF
<ol>
EOF
i=0
while [ $i -lt $n ]; do
  eval url="\$biblios_${i}_url"
  eval title="\$biblios_${i}_title"
  echo "  <li><a href=\"$url\"	>$title</a></li>"
  i=`expr $i + 1`
done
cat <<EOF
</ol>
EOF

これは以下のように使います。

$ biblio-html.sh index.html

このページのソースもこのシェルスクリプトを利用しており、末尾の「Bibliography」セクションを見れば具体例がわかるでしょう。

また、ln -s biblio-html.sh biblio-html5.sh のような別名を作成しておけば、以下のように HTML5 の「<h2 id="…">〜</h2>」のような見出しを出力します。

$ biblio-html5.sh index.html

このシェルスクリプトのコードの要は、sed において、一行内で複数マッチする可能性のあるリンクを改行つきで出力、マッチしない部分文字列をコメント行「#〜」として出力、次のパイプラインの sed でコメント行を除去して、最終的に一行にひとつのリンク情報を出力している点、さらに、シェルスクリプトにおいて、「sort -k | uniq -f 1」で重複しないユニークなリンク情報のみを抽出し、「sort -n」で元の並びに戻していることにあります。

ここで、二つの sed をパイプラインで行なっているのは、マッチしない始めの部分を除去するのが正規表現では難しいからですが、ホールドスペースと 'D' コマンドを使えば、少々難しくなるものの、一つの sed で実現は可能です。以下に変更箇所のみ示します。

biblio-html.sh
	:
LC_ALL=C sed -ne '
/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/{
  /^<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/{
    h
    s/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/\1	\2/p
    x
    s/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/\3/
  }
  /^<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/!{
    s/<a[	 ][	 ]*href="\([-a-z][-a-z]*:\/\/[^"][^"]*\)"[	 ]*>\([^<]*\)<\/a>\(.*\)$/\
&/
  }
  D
}
' "$1" |
	:

プレーンテキストから HTML の生成「a2html.sh

次は、本格的な sed プログラミングの例を紹介します。

例題としては AsciiDoc のように、プレーンテキストから HTML を生成する sed を作成します。但し、AsciiDoc のようにキチンとしたものでなく、相当な制限があることを承知でこのテーマを取り上げます。

ところで、POSIX sed の正規表現の実装には主に2種類ありまして、基本正規表現(BRE: Basic Regular Expressions)拡張正規表現(ERE: Extended Regular Expressions)です。sed ではよく前者が使われますが、実装されていれば必要なら perl 等で馴染み深い現代的な後者が使えます。ここでは必要なので、後者を採用することにします。

しかし、sed の ERE を利用するには、GNU sed の実装では「-r」を必要としますが、POSIX の実装では「-E」が必要です。よって、シェルスクリプトでは以下のようにするとよいでしょう。

#!/bin/ksh
sed --posix < /dev/null > /dev/null 2>&1 && sed_flags='-r --posix' || sed_flags=-E
sed $sed_flags
	:

さて、プレーンテキストから HTML を生成する sed を使ったシェルスクリプトは、かなり長いですが、以下のとおりです。

a2html.sh

これは以下のように使います。

$ a2html.sh sed-howto-1.txt > sed-howto-1.html

入力プレーンテキスト:

出力 HTML:

少し異なりますが、以下のように使う例も見てみましょう。

$ a2html.sh sed-howto-2.txt > sed-howto-2.html

入力プレーンテキスト:

出力 HTML:

さらに異なりますが、以下のように使う例も紹介します。

$ a2html.sh sed-howto-0.txt > sed-howto-0.html

入力プレーンテキスト:

出力 HTML:

タグのネスト等は考慮していないので実用的ではないし、sed にここまで求めると相当苦労することがわかると思います。

このシェルスクリプトの要は、1段目の sed において先読みを使って AsciiDoc の複数行タイプのセクションを一行タイプのセクションに変換し、2段目の sed では HTML の開いたタグを閉じる為に、ホールドスペースをスタックのように使っていることにあります。

Bibliography

  1. sh
  2. find
  3. sort
  4. test
  5. read
  6. cmp
  7. cp
  8. rm
  9. mv
  10. シェルコマンド言語
  11. 配列変数
  12. プロセス置換
  13. eval
  14. sed
  15. expr
  16. uniq
  17. AsciiDoc
  18. 基本正規表現(BRE: Basic Regular Expressions)
  19. 拡張正規表現(ERE: Extended Regular Expressions)
  20. GNU sed
Written by Taiji Yamada