sedコマンドの s/regexp/str/g と /regexp/d とでの正規表現の役割の違い

普段sedコマンドは置換のためにしか使わず、たまに「マッチした行の削除ってどうやるんだっけ？」となって調べて*また忘れる*ということを繰り返していました。

いつも理屈抜きで方法のみ調べていたため、置換 `s/regexp/str/g` と削除 `/regexp/d` とで**規則性がありそうな無さそうな**という曖昧な認識でした。

もう少しsed力を上げたいと思ってマニュアルを読んだら、そもそも**正規表現の使い方がひとつではない**ことを知りました。

## 要点

- sedで指定する操作は、「コマンド」の前に「アドレス」を付けられます
	- アドレスに合致する行がコマンド処理の対象です
	- アドレス無しなら全ての行が対象となります
- `s/regexp/str/g` の場合は**`s` がコマンド**（その後ろはオプション）です
	- 正規表現は置換する部分文字列を表します
- `/regexp/d` の場合は **`d` がコマンド**でその前の **`/regexp/` はアドレス**です
	- 正規表現はコマンドを実行する行を表します

→ 組み合わせると `/regexp1/s/regexp2/str/g` といったこともでき、行指定と置換とで正規表現を分けられます。

## マニュアルの内容
manだと情報が少なかったため、 https://www.gnu.org/software/sed/manual/html_node/index.html （あるいは `info sed` ）を見ていきます。複雑な処理も作れそうですが、今回は簡単なものに絞ります。

### 基本の構文

> (3.1 sed script overview)
>
> `sed` commands follow this syntax:
>
>     [addr]X[options]
>
> *X* is a single-letter `sed` command. `[addr]` is an optional line address. If `[addr]` is specified, the command *X* will be executed only on the matched lines. `[addr]` can be a single line number, a regular expression, or a range of lines (see sed addresses). Additional `[options]` are used for some sed commands.

例えば `s/hello/world/` は、 `s` がコマンド（*X*）を表し、後ろの `/hello/world/` はオプションということになるようです。

一方で `/^foo/d` だと、 `d` がコマンドなので、その前の `/^foo/` はアドレスです。アドレスが指定されるとコマンドは**該当行のみ**に対して実行します。（ちなみに `d` は削除なので、アドレスを指定しないと全ての行が消えます。）

### アドレスの詳細
0〜2つの引数を、カンマ区切りで指定します。最大個数はコマンドによって異なりますが、置換 `s` や削除 `d` なら2個まで可能です。最後（コマンドの直前）に `!` を付けると、処理対象の行を反転させられます。

    [addr1[,addr2]][!]

引数の形式は「行番号」と「正規表現」があります。正規表現は `/regexp/` とスラッシュで囲うほかに、 `\%regexp%` などと任意の文字で囲うこともできます。（目印として最初にバックスラッシュが必要です。）

1引数なら、行番号はその1行だけ、正規表現はgrepのように**マッチする行全て**を表します。

2引数では**行の範囲**（開始行と終了行）を表します。行番号はほぼそのままの意味なので簡単ですが、正規表現は「検索開始行以降で最初にマッチした行」となります。終了行は含むのかなど細かい振る舞いを覚えるには、第2引数が正規表現だと範囲が2行以上になるという性質が役立ちそうです。

> (4.4 Range Addresses)
>
> If the second address is a *regexp*, then checking for the ending match will start with the line *following* the line which matched the first address: a range will always span at least two lines (except of course if the input stream ends).

※ このマニュアルのページでは読み取れなかったのですが、第2引数のマッチが成功したらまた第1引数のマッチに戻るようです。（[フリップフロップ](https://en.wikipedia.org/wiki/Flip-flop_(programming))）

---

例えば以下のコマンドは、Rubyプログラムの [`__END__`](https://docs.ruby-lang.org/ja/latest/method/Object/c/DATA.html) の行から最終行（行番号 `$` ）までを削除します。

```bash
sed -e '/^__END__$/,$d' script.rb
```

### `s` コマンド

> (3.3 The s Command)
>
> The `s` command (as in substitute) is probably the most important in `sed` and has a lot of different options. The syntax of the `s` command is '`s/regexp/replacement/flags`'.

単独でページが用意されているほど主要なコマンドです。とはいえ多くのエディタやプログラミング言語でも同様のことができるので、よく使う機能に関しては難しくありません。メモ書き程度に触れておきます。

- 正規表現
	- 置換したい文字列にマッチするよう指定します
	- デフォルトは基本正規表現（BRE）を使うようになっていて、 `-E` オプションで現代的な拡張正規表現（ERE）に変えられます<br>https://www.gnu.org/software/sed/manual/html_node/BRE-vs-ERE.html
- 置換文字列
	- キャプチャした文字列（正規表現の `\(...\)` にマッチした文字列）は、 `\1`, `\2`, … のように指定して利用できます
	- 正規表現全体にマッチした文字列は、 `&` で利用できます（ `\0` ではありません）
		- 単なる文字 "&" を使いたいときはエスケープが必要です
- フラグ（任意）
	- `g` は行内でマッチした文字列を全て置換します
	- `2` などと数字を指定すると、行内でn番目にマッチした文字列だけを置換します（デフォルトは `1` ということになります）
- その他
	- 区切り文字は `/` 以外でも大丈夫です<br>アドレスと異なり `s` が目印になるので、先頭にバックスラッシュは付けません

### （おまけ） `y` コマンド
マニュアルを見ていて知ったのですが、 `y/src/dst/` というのもあります。スラッシュで区切っているので `s` と瓜二つですが、これは文字を置換するだけのもので正規表現ではありません。[trコマンド](https://ja.wikipedia.org/wiki/Tr_(UNIX))と同じような働きです。

## 利用例
Capybara-RSpec のスクリプトで「一部のマッチャについて `wait` オプションを追加したい」ということがありました。

```ruby
# 元のスクリプト
expect(page).not_to have_css "#foobar"
expect(page).not_to have_selector "#hogehoge"

# ↓
# have_selector だけオプション追加したい
expect(page).not_to have_css "#foobar"
expect(page).not_to have_selector "#hogehoge", wait: 15
```

（幸い引数を括弧で囲っていなかったので）行末に文字列を追加するだけなのですが、**置換したい部分を表す正規表現が他の行でもマッチする**と、期待しない行まで変更してしまいます。

```bash
sed -e 's/$/, wait: 15/' sample.rb
# have_css の行にも追加されてしまう
```

---

今までは、置換したい行全体を一旦マッチさせ、キャプチャした部分文字列（ `&` や `\1` など）を使いながら置換後の行を作っていました。

```bash
sed -e 's/.*have_selector.*/&, wait: 15/' sample.rb
```

アドレスを使うと、対象行の指定と置換とで**正規表現を分けられます**。アドレスはgrepの要領で行い（ `-v` 相当も `!` で可能）、置換は対象外の行を気にしなくていいので単純化できます。

```bash
# grep 'have_selector' が抽出する行のみ置換
sed -e '/have_selector/s/$/, wait: 15/' sample.rb
```