シェルスクリプト用の国際化ライブラリの決定版！ sh-i18n を作りました ～ gettext.sh 代替・すべてのPOSIXシェルと環境に対応

## はじめに

POSIX 準拠でどの環境でも動くシェルスクリプト用の国際化ライブラリ [sh-i18n](https://github.com/ko1nksm/sh-i18n) を作りました。同様のライブラリとしては GNU gettext に含まれている [gettext.sh](https://www.gnu.org/software/gettext/manual/html_node/sh.html) が有名です。すでにライブラリがあるのになぜ作ったのかと言えば、gettext.sh は基本的に GNU gettext 専用で、書きづらく単一の書き方でどのシェルどの環境でも動くわけではなかったからです。一言で言えば**すべての環境で動く完璧なシェルスクリプト用の国際化ライブラリを作りたかった**のです。

ちなみにすべての環境で動くというのはおそらく嘘です。動かない環境は今のところ認知していませんが、限られた環境で手動テストしかしてないので、古い環境とかきっと動かない環境があるはずです。もし動かなかったら教えてください。おそらく対応は可能でしょう。その気になったらちゃんとテストします（だってシェルスクリプトを国際化しようとは思わないし、このライブラリを自分で使う予定ないし・・・）。**シェルスクリプトで国際化対応したい！って人がいましたらぜひ使ってみてください。**

**追記** ksh88 では動きませんでした。が、理由は ksh88 が POSIX に完全に準拠してないからなので・・・。対応は可能だと思いますが古すぎなシェルなので今の所切り捨てます。**さらに追記** ksh88 は軽微な問題を修正し一応動くようになりました。ksh88 と ksh93 (93t+ 2009-05-01) 以前は `set -u` した状態だと「@: parameter not set」というエラーになりますが、POSIX に準拠してないバグなので対応しません。`set -u` しなければ動きます。pdksh (v5.2.14 99/07/13.2)、posh (0.14.1) はシェルのバグにより動きません。メンテナンスされてないシェルなので対応しません。bash 2.04 は Segmentation fault になりますが古すぎるシェルなのでこれ以上の調査も対応もしません。

:::note
国際化で考えなければいけない点、開発中に発生した環境依存の問題、ソースコードの解説、移植性・可搬性を実現したシェルスクリプトのテクニックなどについて、技術的な記事を現在書いています。近く公開できると思いますのでおたのしみに。
:::

:::note warn
公開時の名前は sh-gettext でしたが sh-i18n に変更しました。仕様もいくつか変更しています。
:::

## 特徴

- OS 標準の `gettext` と `ngettext` コマンドで動作します
  - `gettext` と `ngettext` は Issue 8 で標準化されます
  - GNU gettext のインストールは不要です
- どの POSIX 準拠シェルでもどの環境でも動きます
  - POSIX Issue 8 準拠の必要はありません
- `gettext` と `ngettext` コマンドがなくてもフォールバック機能で動作します
  - もちろんその場合は翻訳機能は動作しません
- `gettext.sh` よりも優れた便利で簡単な API を提供しています
  - `eval_gettext` に代わる `_`、`eval_ngettext` に代わる `n_`
  - すべてのシェルで `printf` で引数の位置を指定する書式 `<数値>$` に対応
  - カンマ区切りの書式 `%'d` の対応（非対応シェルでもエラーにならない）
  - ロケール依存の小数点記号に対応（小数点記号にカンマを使う国がある）
- CC0 ライセンスです
   - あなたのシェルスクリプトに含めて配布することができます

## なぜ作ったのか？

gettext は現在様々なプログラミング言語で使われている Unix 系 OS が持っている国際化の仕組みの一つです。誕生したのは 1990 年頃、Uniform の提案した仕様に基づいてサン・マイクロシステムズによって初めて実装され、SunOS 4.x という古い UNIX で使われていました。しかし商用 UNIX ではさまざまな事情で X/Open が提案した catgets が使われるようになっていきました。その一方で 1995 年頃、GNU が gettext を再実装し、主に Linux 界隈で広く使われるようになりました。今では Linux の普及により gettext が広く使われています。そして 2023 年予定の POSIX Issue 8 にて gettext（API と コマンド）は標準化されます。もっと詳しい話に興味がある方は「[POSIX 準拠の二つの国際化機能 gettext と catgets について調べてみた](https://qiita.com/ko1nksm/items/9c06b732e519529991f1)」を参照してください。

POSIX Issue 8 ではシェルスクリプトに関連する国際化関係の要素として以下のコマンドや機能が標準化されます。今はまだ一部の環境でしか使えない機能もありますが、将来的にはどの環境でも使えるようになるはずです。

- `gettext` コマンド、`ngettext` コマンド
- `printf` の `<数値>$` 書式（例 `printf '%2$s %1$s' arg1 arg2` => arg2 arg1）
- `xgettext` コマンド（C 言語開発オプション）、`msgfmt` コマンド
- エスケープシーケンスが使える文字列 `$'...'`（例 `echo $'foo\tbar\n'`）

これらのコマンドや機能を使うと POSIX で標準化された範囲の機能を使うだけでシェルスクリプトを国際化できるようになります。しかし世の中のシェルやコマンドは、まだ POSIX Issue 8 に完全対応しているわけではありません。gettext.sh を使えば POSIX Issue 8 に対応していない環境でも国際化対応ができますが、GNU gettext （または互換性のある実装）を使うことが前提となっています。言い換えると **gettext.sh は Solaris 版の `gettext` では正常に動作しません**。せっかく POSIX Issue 8 で gettext が標準化されるというのに残念な話です。その他 gettext.sh にはいくつかの問題点や不満点があります。

そこで私が作ったのが sh-i18n というシェルスクリプト製のライブラリです。このライブラリは POSIX Issue 8 の標準規格に記載された移植性に関する問題点（下記参照）を解決しています。`gettext`、`ngettext` コマンドがインストールされたどの環境でも動くように開発されており、もしコマンドがインストールされていない場合でもフォールバック機能により翻訳は行えませんが動作はします。また POSIX Issue 8 準拠環境専用ではないので、現在の全ての POSIX シェル（sh、dash、bash、ksh、zsh など）で全く同じ書き方で動作します。さらに gettext.sh よりも使いやすい簡潔なインターフェースを提供しています。

:::note warn
POSIX Issue 8 の標準規格に記載された移植性に関する問題点とは

POSIX に関して多くの人が勘違いしているのは、POSIX が各実装の違いをなくして統一した動作を実現するものだと考えていることです。POSIX はすでにある実装の仕様の変更を要求することは（基本的に）ありません。そのようなこと要求してしまうと互換性が維持できなくなるからです。現在の動作を変えることなく POSIX は動作の違いを文書化しています。それを読むことで私達は動作の違いを知ることができます。POSIX は移植性の問題点を知るために、私達が読むべきガイドラインです。

今回の国際化ライブラリの開発では `gettext` のデフォルトの動作が「`-e`: エスケープシーケンスを解釈する」「`-E`: エスケープシーケンスを解釈しない」という違いがあることが POSIX 標準規格を読むことで知ることができました。調べてみると GNU gettext が `-E` 相当、Solaris gettext が `-e`  相当がデフォルトの動作でした。（更に補足すると Solaris 11 の gettext は `-E` オプションが実装されていません）

POSIX はこのような動作の違いを統一することはせず、どちらがデフォルトであるかは「未指定 (unspecified)」と標準化しました。これは仕様を決めていないのではなく、既存の実装に違いがあり、後方互換性の観点からどちらかに統一（仕様変更）することが不可能だからです。意図的に「未指定という仕様」に決めているのです。

この移植性の問題を解決するのは私達アプリケーション開発者の仕事です。POSIX で標準化されたコマンドであっても動作が統一されるわけではないため、POSIX コマンドを直接使う場合はこのような実装の違いに対処しなければならないのがシェルスクリプトで高い可搬性を実現するのが困難な理由です。だから私はこのような面倒な事柄に対応しなくていいようにライブラリを開発したのです。
:::

## 使い方

### 基本的な書き方

```sh
#!/bin/sh

. "${0%/*}/lib/i18n.sh"

export TEXTDOMAIN="sh-i18n"
export TEXTDOMAINDIR="${0%/*}/locale"

# 一部の環境では TEXTDOMAINDIR で指定しても動かない？
# 例 OpenIndiana Hipster 2022.10 
export NLSPATH="${0%/*}/locale/%l/LC_MESSAGES/%N.mo"

_ $'Hello World'                         # 翻訳して出力する（$'...' 対応）
_ 'Hello World' -n                       # 末尾の改行なし
_ 'Hello %2$s %1$s' -- Koichi Nakashima  # 書式が使える（<数値>$ 対応）
n_ 'an apple' $'%d apples' -- 1          # 複数形の対応
_ "%'d" 1000000000                       # カンマ区切り（シェルが対応している場合）
```

しつこいようですが上記のコードは「最小限の機能しか持たない現在の POSIX シェル」の全て動作します。つまり `/bin/sh` （Ubuntu の dash、macOS の bash 3.2.57、FreeBSD sh など）でも動作するということです。**`$'...'` や `<数値>$` の対応は sh-i18n 内部でシェル言語で実装したコードで処理している**から使えます。

:::note warn
**注意** gettext.sh は おそらく Bourne シェルに対応していますが sh-i18n は非対応です。これは `sh` で動かないという意味ではありません。現在の `sh` は「POSIX 非準拠の Bourne `sh`」から「POSIX に準拠した `sh` （dash や bash や FreeBSD sh など）」に変わっています。つまり sh-i18n が動かないのは古い `sh` であって現在の `sh` ではすべてで動きます。

Bourne シェルは 1992 年に最終バージョンがリリースされたとても古いシェルです。「sh は Bourne シェル」という間違った（昔の）説明を今もしている人がいるため勘違いされがちですが、BSD 系 Unix（最初期を除く）や Linux や macOS で Bourne シェルが使われたことはありません。商用 UNIX でも 2000 年代中までに `sh` の実体は ksh に置き換わっています。今は Bourne シェルへの対応は切り捨てて問題ありません。

ちなみに Bourne シェルを切り捨てた理由は、POSIX で標準化されている変数展開を多用しているからです。変数展開を利用しない場合、同等の処理を外部コマンドを使って実装せねばならず大幅にパフォーマンスが低下してしまいます。
:::

### MSGID

MSGID とは翻訳の際に使用するキーのことです。`_ "Hello World"`、`_ 'Hello World'`、`_ $'Hello World'` であれば `Hello World` というメッセージが MSGID となります。gettext の仕組みは（catgets などのように番号を使用するのではなく）、ソースコードに書いたメッセージを翻訳するという形で動作します。もし翻訳文が見つからなければ、ソースコードのメッセージがそのまま出力されます。**MSGID の中に変数やコマンド置換を含めることはできない**ので注意してください。正確に言えばシェルスクリプト自体は動作するのですがメッセージカタログを生成するための `xgettext` が該当箇所を翻訳対象の文字列として認識しません。

```sh
# このような方法でタブをメッセージに入れることはできない
_ "Hello${TAB}World"
_ "Hello$(printf '\t')World"
```

`$'...'` という書き方は POSIX Issue 8 で標準化される「Dollar-Single-Quotes」と呼ばれるシェルの機能です。改行やタブが含まれる場合に `$'FOO\tBAR\n'` のように書くことができます。bash などすでに多くのシェルで使える機能ですが dash ではまだ使えません。しかし **sh-i18n では MSGID に限り Dollar-Single-Quotes に対応していないシェルでも使える**ようにワークアラウンドが実装されています（注意 シェルスクリプト全体で Dollar-Single-Quotes が使えるようになるわけではありません）。Dollar-Single-Quotes を使わない場合、以下のように書くこともできるのですが読みづらくなってしまいます。

```sh
_ 'Hello	World' # 見えないがタブが含まれている
_ 'Hello
World' # 改行が含まれている

_ $'Hello\tWorld\n' # 読みやすい
```

#### Dollar-Single-Quotes 非対応シェルに関する制限

sh-i18n にはタブや改行をメッセージに含めるのに便利な Dollar-Single-Quotes を MSGID で使えるという特徴があります。しかし、Dollar-Single-Quotes 対応シェルと非対応シェルの両方で同じ書き方ができるようにするための制限があります。

**MSGID の先頭の文字が `$` の場合は `'MSGID'` や `"MSGID"` を使って書くことはできません**。先頭の文字が `$` の場合は `$'$ is dollar'` と書く必要があります。これは Dollar-Single-Quotes に非対応のシェルでは先頭の `$` でバックスラッシュエスケープシーケンスを解釈するかどうかを判断しているからです。

```sh
# $'...' に非対応のシェルでは以下の二つを区別できない
_ '$ is dollar'
_ $'is dollar'

# このように書く必要がある
_ $'$ is dollar'
```

**文字列を複数のクォートに分けて書くことはできません**。メッセージ全体を一つの Dollar-Single-Quotes で書く必要があります。バックスラッシュエスケープシーケンスを解釈するかどうかを判断は文字列の先頭でしか行われないからです。

```sh
# メッセージ全体を一つの $'...' で書く必要がある
_ $'Hello '$'world\n' # 二つの $'...' をつなげることはできない
_ $'Hello $world\n'   # Dollar-Single-Quotes 非対応シェルではこのように解釈される
_ $'Hello ''world\n'  # Dollar-Single-Quotes 対応シェルでは末尾の \n が改行と認識されない
```

**シングルクォーテーションを文字列の中に含める場合は `\'` は使えません**。代わりに `\47` または `\047` を使う必要があります。

```sh
# 以下のように書くことはできない
_ $'It\'s a small world\n'

# 以下のような書き方をする
_ $'It\47s a small world\n'
_ $'It\047s a small world\n'

# \n を含まないならこれで良い
_ "It's a small world"
```

**8 進数での指定は `\177` までで、16 進数での指定と Unicode のコードポイントでの指定（`\uHHHH`)には対応していません**。8 進数の制限は yash が `\200` 以上の（ASCII 文字ではない）文字を扱えないためです。16 進数の対応はやろうと思えばできるのですが 8 進数に対応できていれば十分でコード量を増やす価値がなく、Unicode のコードポイントでの指定は文字数が膨大であるためメモリ使用量とパフォーマンス上の理由で現実的ではありません。ASCII 文字ではない文字をメッセージとして使うことは普通はありませんし Unicode はコードポイントではなく文字そのものを書けば十分でしょう。（NBSP のような特殊な文字で扱えたほうが良いものありますかね？あれば例外的に対応するかもしれません）

このような制限がありますが、しばしばメッセージにタブや改行を入れたいことがあるようなので `\t` や `\n` を使えたほうが便利であると判断し、このような仕様にしています。

### i18n_print ( _ )

GNU gettext を使う場合、メッセージを国際化対応するには `gettext MSGID` または `eval_gettext MSGID` を使用します。しかしこの書き方は長いため、sh-i18n では簡潔に `_` と書けるようにしています。これは C 言語などの他の言語でもよく使われている省略された書き方です。

他の言語では `_` は `gettext` 関数の別名ですが sh-i18n では `i18n_print` シェル関数の別名になっています。`i18n_print` シェル関数は gettext.sh の `eval_gettext` シェル関数と同じく変数展開を行う機能が追加されています。

```txt
_ MSGID [-n | --] [ARGUMENT]...
i18n_print MSGID [-n | --] [ARGUMENT]...
```

`i18n_print` は厳密にはオプションを持ちません。二番目の引数はフラグで `-n` または `--` を指定します。`-n` を指定した場合、末尾の改行の出力を抑制します。`--` を指定した場合は改行が出力されます。`--` は一応省略可能にしていますが ARGUMENT の値が `--` になる可能性を考慮した場合、おそらく省略しないほうが良いでしょう。MSGID に `%` で始まる書式が含まれている場合、引数の展開が行われ ARGUMENT で渡された値が代入されます。使用可能な書式に関しては `i18n_printf` を参照してください。

ちなみに二番目の引数にオプションのように見えるフラグを指定するという変則的な形になっているのは `xgettext` で翻訳対象の文字列として指定できる位置が「n 番目の引数を翻訳対象の文字列とする」のような指定しかできないからです。`_ MSGID` と `_ -- MSGID` とでは引数の位置が異なります。どちらかしか選べないのであれば短い前者の方を選びます。改行の有無で別のシェル関数を作るという手もあるのですが、そうすると関数の数が増えてしまいます。（将来 `eval_pgettext` と `eval_npgettext` 相当の関数を追加したとき、4 つの関数×改行の有無で 8 つの関数を作りたくなかった）

### i18n_nprint ( n_ )

GNU gettext を使う場合、複数形に対応したメッセージの国際化を行うには `ngettext MSGID MSGID-PLURAL N` または `eval_gettext MSGID MSGID-PLURAL N` を使用します。`_` と同様に、`n_` は `ngettext` のための省略された書き方です。こちらは `i18n_nprint` シェル関数の別名になっており、`eval_ngettext` シェル関数と同じく変数展開を行う機能が追加されています。

```txt
n_ MSGID MSGID-PLURAL [-n | --] N [ARGUMENT]...
i18n_nprint MSGID MSGID-PLURAL [-n | --] N [ARGUMENT]...
```

`i18n_nprint` は厳密にはオプションを持ちません。三番目の引数はフラグで `-n` または `--` を指定します。`-n` を指定した場合、末尾の改行の出力を抑制します。`--` を指定した場合は改行が出力されます。次の引数 N は数値であり  `--` になることはないため `--` を省略しても構いません。四番目の引数は数値で 1（単数）の場合は MSGID が、1 以外（複数）であれば MSGID-PLURAL がメッセージとして使用されます。MSGID に `%` で始まる書式が含まれている場合、引数の展開が行われ ARGUMENT で渡された値が代入されます。展開される引数の番号は N が 1 であり、ARGUMENT は 2 から始まるので注意してください。使用可能な書式に関しては `i18n_printf` を参照してください。

### i18n_gettext

メッセージを取得し VARNAME で指定された変数に代入します。オプションはなくエスケープシーケンスを解釈しません（`gettext -E` 相当）。

```sh
i18n_gettext VARNAME MSGID
```

### i18n_ngettext

メッセージを取得し VARNAME で指定された変数に代入します。オプションはなくエスケープシーケンスを解釈しません（`ngettext -E` 相当）。

```txt
i18n_ngettext VARNAME MSGID MSGID-PLURAL N
```

### i18n_printf

FORMAT で指定された書式に従い、引数の展開が行われ ARGUMENT で渡した値が代入されます。

```txt
i18n_printf FORMAT [ARGUMENT]...
```

内部的に `printf` コマンドを呼び出しており、基本的に `printf` コマンドと同様の書式を解釈しますが、以下の点で仕様が異なります。

- エスケープシーケンスを解釈しない（解釈するのは `%` を使った書式のみ）
  - `printf '\t'` はタブが出力されます。
  - `i18n_printf '\t'` の出力は `\t` です。
- 書式 `<数値>$` による位置を指定した変数の参照に対応
  - `printf '%2$s %1$s\n' FOO BAR` に対応していないシェルがあります。
  - `i18n_printf '%2$s %1$s\n' FOO BAR` は全てのシェルで動作します。
- 書式 `'` フラグによる桁区切り表示に対応（例 `%'d`）
  - ロケールに応じた記号で区切って出力します。（日本語ではカンマ区切り）
  - `printf` の実装が `'` フラグに対応されていない場合は無視されます。
- FORMAT で引数を消費した後に残った引数は無視される
  - `printf '%s ' FOO BAR` の出力は `FOO BAR ` です。
  - `i18n_printf '%s ' FOO BAR` の出力は `FOO ` です。
- FORMAT で参照する引数が存在しない場合は、その場所には書式がそのまま残る
  - `printf '%s %s' FOO` の出力は `FOO ` です。
  - `i18n_printf '%s %s' FOO` の出力は `FOO %s` です。

個人的にこれはこれで便利だと思うので `printf` コマンドの強化（？）版として独立させてもいいかなーなんて思っています。ソースコードのライセンスは CC0 なので必要な方は適当に切り取って使ってください。

### i18n_printfln

最後に改行を付け加える点を除いて、`i18n_printf` と同じです。

```txt
i18n_printfln FORMAT [ARGUMENT]...
```

**補足** この関数の追加に伴い、`i18n_replace_all` は非公開関数に戻しました。

### i18n_echo

```txt
i18n_echo ARGUMENT
```

gettext.sh からの移行のために用意した関数です。必要のない人は使う必要はありません。gettext.sh の [$echo](https://www.gnu.org/software/gettext/manual/html_node/gettext_002esh.html) 変数にセットされる関数と同様の機能を持っており、最初の引数と改行を出力しバックスラッシュエスケープシーケンスを解釈しません。

もし必要な場合、以下のようにすることで `$echo` と同等の処理を行わせることができます。

```sh
echo='i18n_echo'
$echo foo
```

gettext.sh の `$echo` とは異なり、二つ目以降の引数は必ず無視される、`-n` はそのまま出力される、zsh でも問題なく動作する、といった修正が行われており、どのシェルでも同じ動作を行うようになっています。

### i18n_detect_decimal_point

```txt
i18n_detect_decimal_point
```

小数点記号の再検出を行います。通常は必要ありませんが `i18n.sn` を読み込んだ後でロケールを変更した場合に呼び出す必要があります。

## 環境変数

### I18N_GETTEXT

sh-i18n では使用するコマンド名にデフォルトで `gettext` を使いますが、環境変数 `I18N_GETTEXT` で別の名前を使用することができます。例えば Solaris 11 では Solaris 版の `gettext` コマンドがインストールされていますが、パッケージから gnu-gettext をインストールすることで GNU gettext を使うことができます。この時 GNU 版の `gettext` コマンドは `ggettext` という名前でインストールされます。

例えば以下のようにすることで `ggettext` コマンドがインストールされている場合にそれを使うことができます。環境変数は `i18n.sh` の読み込み前に設定してください。

```sh
if type ggettext >/dev/null 2>&1; then
  I18N_GETTEXT=ggettext
fi

. i18n.sh
```

他にも別のパスに最新版の GNU gettext をインストールしているような環境では絶対パスで指定することもできます。

### I18N_NGETTEXT

`I18N_NGETTEXT` は 上記と同じく `ngettext` コマンドに何を使うかをしている環境変数です。`ngettext` も POSIX で標準化されているのですが、GNU 版の `ngettext` 以外の実装はないのではないかと思っています。デフォルトでインストールされていないかインストールされていても GNU 版ぐらいしか見かけない気がします。

Solaris 11 版でも `ngettext` コマンドは存在しないようで、GNU gettext をインストールすることで `ngettext` コマンドがインストールされます。`gngettext` コマンドが存在する環境があるのかは知りません。

### I18N_PRINTF

sh-i18n はデフォルトでシェルビルトインの `printf` コマンドを使用しますが、環境変数 `I18N_PRINTF` を指定することで、外部コマンド版の `printf` コマンドを使用することができます。

外部コマンド版や多くのシェルの `printf` コマンドは数値を出力する時にロケールに適した出力を行える `'` フラグ（例 `%'d`）が実装されていますが、一部のシェルは対応していません。環境変数 `I18N_PRINTF` に外部コマンドのパスを設定することにより、（外部コマンド版が対応していれば）ロケールに適した数値出力を行えるようになります。環境変数は `i18n.sh` の読み込み前に設定しておく必要があります。

```sh
I18N_PRINTF=/usr/bin/printf
. i18n.sh
```

なお。この `'` フラグ自体は POSIX で標準化されておらず、Issue 8 でも標準化されませんが、国際化対応には必要な機能の一つだと私は考えています。そのため sh-i18n は正式に `'` フラグに対応しており、もし `printf` コマンドの実装が対応していない場合でもエラーにならないようになっています。

## xgettext コマンド使用時の注意点

GNU xgettext はシェルスクリプトの中に含まれている `gettext` や `eval_gettext` をキーワードに翻訳対象とする文字列を抽出します。sh-i18n では、異なるキーワード `_` / `n_` や `i18n_gettext` / `i18n_ngettext` などを使用するためにキーワードを追加しなければいけません。キーワードの追加には `-k` オプション（POSIX では `-K` オプション）を使用します。

```sh
# _ と n_ だけに対応する場合
xgettext -k_:1 -kn_:1,2 example.sh

# すべてに対応する場合
xgettext -k_:1 -kn_:1,2 -ki18n_gettext:1 -ki18n_ngettext:1,2 example.sh

# POSIX 準拠の書き方の例（今はまだ使える実装は存在していないはず）
xgettext -K _:1 -K n_:1,2 example.sh
```

POSIX ではキーワードの追加に `-K`（大文字 K）オプションを使うことになっていますが、実際の GNU xgettext は `-k`（小文字 k）オプションです。その気になれば `xgettext` の移植性問題を解決するラッパー関数を作ることもできますが、いらないですよね？ どうせシェルスクリプトの国際化に対応している `xgettext` の実装は GNU 製（とそのフォーク？）しかないはずです。ちなみにですが POSIX で標準化されている `xgettext` は C 言語にしか対応していません。

:::note info
なぜ POSIX では `-k` オプションの代りに `-K` オプションを標準化したのか？

POSIX では原則として今までに実装が一つもない新しい機能（発明）を作ることは基本的にありません。`-K` オプションはおそらく現時点で実装している `xgettext` コマンドはありません。一見新しい機能に思えるかもしれませんが、機能自体は `-k` オプションで実装されたものでありオプションの指定の方法を変更しているだけです。その理由は `-k` オプションの指定の仕方が [POSIX で標準化されているオプションのガイドライン](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html)の「Guideline 7: Option-arguments should not be optional.」に準拠していないからです。

POSIX ではオプションというものはオプション引数（`-K この部分`）は「持たない」か「持つ」かどちらかしかありません。GNU の `-k` オプションは「省略可能なオプション引数」が実装されており、`-k` または `-kWORD` という書き方ができてしまいます。POSIX では `-k ARG` と書いた場合に「`-k` のオプション引数は ARG」なのか「`-k` のオプション引数は空文字で ARG は次の引数」なのかが曖昧であるため、これを許可していません。

POSIX では既存の小文字のオプションが小さな問題がある場合、それを解決したオプションを大文字で定義するという（非公式の？）慣習があるようです（例 `xargs` の `-L` オプションなど）。POSIX 標準規格の開発者は GNU xgettext と Solaris xgettext の両方で実装されている `-c`, `-m`, `-M` オプションの標準化も検討したようですが、これらもオプションのガイドラインに準拠していないためそのままでは追加することができず、それを解決したオプションを定義しようにも `-C` は GNU xgettext で別の意味で使われており、`-m` と `-M` は明らかな理由で不可能であったため断念したことが POSIX の xgettext の RATIONALE に書かれています。
:::

## 問題点

想定はしていましたがやはり遅いです。単純に `echo` で出力するのに比べて Linux 環境で 2650 倍ぐらい遅くなっています。私の環境だと 1 万回程度の出力で 20 秒かかっているので 1 回あたり 2 ミリ秒ですね。ユーザーが読むためのメッセージなのである程度遅くても問題ないと考えていますが、データとして出力する部分には使用しないほうが良いでしょう。

遅い理由はシェル言語で実装した部分よりも `gettext` コマンドの呼び出しの方が大きいです。全体の 66% が `gettext` コマンドの呼び出しで時間を消費しています。シェル言語での実装はそれなりの処理を行っていますが、それよりもコマンド呼び出しの方が遅いのです。ちなみに `gettext.sh` は更に遅く実時間 (real) で 1.5 倍ぐらい遅いです。さらに gettext.sh はコアを複数使った状態（パイプを使うとコマンドが並列で動作するので実時間以上に CPU を消費する）なので user + sys で考えると 2.35 倍ぐらい遅いです。gettext.sh では一回の `gettext` コマンドに加えて `envsubst` コマンドの呼び出しが二回行われるのでこの分の遅さでしょう。

`gettext.sh` は `gettext` コマンドと `envsubst` コマンドを組み合わせるのではなく、一つの専用コマンドを用意していたらもっと速くなったと思います。sh-i18n ではそのようなネイティブコマンドを作るのも有りかもしれません。専用コマンドがインストールされていたら速くなり、インストールされていなくてもシェルスクリプトだけでほぼ同等の処理が実行できるという仕組みが良いかもしれません。ただし、今より 1.5 倍近く速度になると思いますが 1 万回の呼び出しで 20 秒が 15 秒になる程度のものです。

これが翻訳メッセージを変数に入れて使う方式だと 1000 倍以上になるはずなので、シェルスクリプトではメリットが大きいんですよね。理想的には国際化機能はシェルネイティブで実装されているべきです。ksh や bash の `$"..."` はそれを実現したものでした。廃れた or 非推奨なのが残念です。`gettext` コマンドを使わない独自実装、うーん、PO ファイルを直接読み込む？ 可能なら sh-i18n と互換性を持たせたいですがシェルに連想配列がないのがきついんですよね。シェルスクリプトをトランスパイルするぐらいしか思いつかないです。

## さいごに

シェルスクリプト用の国際化ライブラリを作ることになったのは POSIX Issue 8 で gettext が標準化するため、それについて色々と調べていたのがきっかけです。シェルスクリプトの国際化の手法には gettext.sh を使った方法のほか、bash 専用の方法、ksh 専用の方法などがあり、昔シェルに詳しくない時にこれを読んで混乱したので、この際だから全てまとめてしまおうと考えました。

そして「[シェルスクリプトの国際化・多言語化の完全解説（POSIX準拠 / gettext.sh / bash / ksh）](https://qiita.com/ko1nksm/items/fbef0ad261f67760dff0)」の記事を書きつつ gettext.sh 機能を検証してまとめていたのですが、その出来があまり良くないことに気づきました。gettext.sh は GNU gettext と共に使うために作られているから仕方ないとも言えるのですが、POSIX Issue 8 で gettext が標準化されます。それならば環境依存しないシェルスクリプトライブラリがあればいいなと思いつつ記事を書いていたら不満点とアイデアがでてきて、気づいたら sh-i18n を作っていました。POSIX で標準化されるとは言え、どの環境でも動きが統一するわけではなく、どの環境でも動くシェルスクリプト（ライブラリ）を書くのは簡単な作業ではないことがよくわかります。

この記事は使い方ををメインに説明しているので、内部の実装がどうなっているかは書いていませんが、移植性に関して思った以上にいろいろな問題が有りました。その話については別に記事を書こうと思っています。結構行き当たりばったりでライブラリを書き始めたのですが、シンプルでなかなか良い形に仕上がったのではないかと思います。現在のシェルでも動き、将来 POSIX Issue 8 準拠が普通になったときでも書き換えなくてよいのが気に入っています。Dollar-Single-Quotes へ自力で対応するというアイデアは今回の開発の中でもっとも気に入っています。ShellCheck で警告が出るのが難点ですが、それは無効にすれば良く、POSIX Issue 8 以降とは言え完全に POSIX に準拠した書き方なので近いうちにおそらく ShellCheck 側も対応してくるでしょう。`<数値>$` への対応は少々頭を使いました。最初は既存の `printf` と同じ仕様にしようと思ったのですが、やはりシェルごとに細かい挙動（`%` での指定した引数がなかったり引数が多すぎたりしたときなどの挙動）が異なりました。しかし途中で全く同じだと逆に翻訳ライブラリとしては適していないことに気づき、異なる仕様で機能を縮小したら簡単な実装で実現することができました。

個人的には自分でこのライブラリを使う時が来ることはあるだろうか？と思っていたりしますが、使う人は使うでしょう。これでこの世界からまた一つシェルスクリプトのくだらないバッドノウハウを消し去ることができました。