Swift Digest
SE-0351 | Swift Evolution

Regex builder DSL

Proposal
SE-0351
Authors
Richard Wei, Michael Ilseman, Nate Cook, Alejandro Alonso
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0350 で導入された Regex<Output> 型によって、正規表現は Swift の型システムに統合され、リテラルや実行時コンパイルで生成できるようになりました。しかし、テキストとしての正規表現には、現実のパターンを書こうとすると避けがたい問題が残ります。

例えば、次のような銀行明細を 1 行ずつパースしたいとします。

CREDIT    04062020    PayPal transfer    $4.99
CREDIT    04032020    Payroll            $69.73
DEBIT     04022020    ACH transfer       $38.25
DEBIT     03242020    IRS tax payment    $52249.98

これをテキストの正規表現で表現すると次のようになります。

(CREDIT|DEBIT)\s+(\d{2}\d{2}\d{4})\s+([\w\s]+\w)\s+(\$\d+\.\d{2})

この書き方は確かに簡潔ですが、読み書き・保守の観点では多くの欠点を抱えています。

  • \, (, [, { のような記号が密集して可読性が低くなります。
  • サブパターンが階層構造を持っているのに、すべて 1 行のテキストに畳み込まれています。
  • 入力中にコード補完が効きません。
  • キャプチャグループはあくまで生の Substring(や範囲)を返すだけで、マッチ後に改めて他のデータ型に変換する必要があります。
  • インラインコメント (?#...) はあるものの、それを書くとさらに読みにくくなります。

また、日付や金額のように「本来はきちんとしたパーサを通すべき値」まで、正規表現のキャプチャと後段の文字列変換で済ませてしまいがちです。パース失敗を正規表現側で扱えないため、「緩い正規表現でだいたい切り出し、後から検証する」という壊れやすい書き方に引きずられてしまいます。

必要だったのは、正規表現の表現力を保ちながら、Swift のコードとしてサブパターンを組み立て、型付きのキャプチャや本格派パーサとの連携を自然に書ける仕組みです。

02 どのように解決されるのか

この提案は、Regex を result builder で組み立てるための DSL(regex builder)を導入します。これは Swift 標準ライブラリに同梱されますが、独立したモジュール RegexBuilder として提供されるため、使うときは import RegexBuilder が必要です。

import RegexBuilder

let emailPattern = Regex {
  let word = OneOrMore(.word)
  Capture {
    ZeroOrMore {
      word
      "."
    }
    word
  }
  "@"
  Capture {
    word
    OneOrMore {
      "."
      word
    }
  }
} // => Regex<(Substring, Substring, Substring)>

ここでは、サブパターンに名前を付けて再利用したり、インデントで階層を表したり、普通のコードコメントを挟んだりできます。

RegexComponent プロトコル

DSL の中心には RegexComponent プロトコルがあります。これは「正規表現の部品として振る舞える型」を表すもので、Regex 自身も RegexComponent に適合します。

public protocol RegexComponent<RegexOutput> {
  associatedtype RegexOutput
  var regex: Regex<RegexOutput> { get }
}

StringSubstringCharacterUnicodeScalarCharacterClass といった標準ライブラリの型も RegexBuilder モジュールで RegexComponent に適合させられています。そのため、DSL の中に文字列リテラルや文字クラスをそのまま書くと、それらがパターンとして振る舞います。

Regex {
  "hello"            // 文字列リテラルもコンポーネント
  OneOrMore(.whitespace)
  "world"
}

連結

Regex { ... } のクロージャは @RegexComponentBuilder でマークされており、内部に書かれたコンポーネントを 上から順に連結 します。連結結果の Output は、先頭に全体マッチの Substring、そのあとに各コンポーネントのキャプチャ型が順に並ぶタプルになります。

Regex {
  regex0 // Regex<Substring>
  regex1 // Regex<(Substring, Int)>
  regex2 // Regex<(Substring, Float)>
  regex3 // Regex<(Substring, Substring)>
} // Regex<(Substring, Int, Float, Substring)>

CaptureTryCapture

キャプチャは Capture { ... } で書きます。これはマッチ全体の部分文字列を Output に追加する、テキスト正規表現でいう (...) に相当します。

// '(CREDIT|DEBIT)' と等価
Capture {
  ChoiceOf {
    "CREDIT"
    "DEBIT"
  }
} // Output は (Substring, Substring)

transform: クロージャを付けると、キャプチャ時点でその部分文字列を別の値に変換できます。transform:throws でき、投げられた場合はマッチングは中断されてエラーが呼び出し側に伝播します。

Capture {
  ChoiceOf { "CREDIT"; "DEBIT" }
} transform: {
  "Transaction Kind: \($0)"
} // Output は (Substring, String)

一方 TryCapture は、変換の結果が nil になり得る場合のためのものです。nil が返ると regex エンジンはマッチを失敗とみなし、他の選択肢にバックトラックします。failable initializer の呼び出しを直接差し込むのに向いています。

enum TransactionKind: String {
  case credit = "CREDIT"
  case debit = "DEBIT"
}

TryCapture {
  ChoiceOf { "CREDIT"; "DEBIT" }
} transform: {
  TransactionKind(rawValue: String($0)) // nil を返し得る
}

これにより、「緩くキャプチャしてから後で検証」ではなく、パース失敗をその場で regex のバックトラックとして扱える ようになります。

mapOutput による結果全体の整形

個々のキャプチャだけでなく、regex 全体の Output を別の型にまとめ直すこともできます。mapOutput(_:) は、キャプチャの並べ替えやネストした Optional の平坦化、カスタム型への組み立てに便利です。

struct SemanticVersion: Hashable {
  var major, minor, patch: Int
}

let semverRegex = Regex {
  TryCapture(OneOrMore(.digit)) { Int($0) }
  "."
  TryCapture(OneOrMore(.digit)) { Int($0) }
  Optionally {
    "."
    TryCapture(OneOrMore(.digit)) { Int($0) }
  }
}.mapOutput { _, c1, c2, c3 in
  SemanticVersion(major: c1, minor: c2, patch: c3 ?? 0)
}

Reference による名前付きキャプチャと後方参照

Reference は、テキスト正規表現の名前付きキャプチャや後方参照に相当する機構です。保持する型を与えて宣言し、Capture(as:) / TryCapture(as:)as: 引数として渡すと、マッチ結果から match[reference] の形で取り出せます。

let kind = Reference(Substring.self)

let regex = Capture(as: kind) {
  ChoiceOf { "CREDIT"; "DEBIT" }
}

if let result = try regex.firstMatch(in: "CREDIT") {
  print(result[kind]) // Optional("CREDIT")
}

Reference 自身を DSL の中に書くと、そこまでにキャプチャされた内容に再度マッチさせる後方参照になります。

let a = Reference(Substring.self)
let regex = Regex {
  Capture("abc", as: a)
  "def"
  a           // ここで a に一致する内容をもう一度要求
}

regex の中で一度も as: reference としてキャプチャに紐付けられていない Reference を使うと、マッチ実行時に実行時エラーになります。

選択肢 ChoiceOf

複数のパターンのいずれかにマッチさせたい場合は ChoiceOf { ... } を使います。テキスト正規表現の | に相当し、各枝のキャプチャ型はそれぞれ Optional にくるまれてから連結 されます。

let choice = ChoiceOf {
  regex0 // Regex<Substring>
  regex1 // Regex<(Substring, Int)>
  regex2 // Regex<(Substring, Float)>
  regex3 // Regex<(Substring, Substring)>
} // => Regex<(Substring, Int?, Float?, Substring?)>

繰り返し

量化子は 5 つのコンポーネントで表されます。

regex builder テキスト正規表現
One(...) ...
OneOrMore(...) ...+
ZeroOrMore(...) ...*
Optionally(...) ...?
Repeat(..., count: n) ...{n}
Repeat(..., n...) ...{n,}
Repeat(..., n...m) ...{n,m}

One / OneOrMore / 回数指定の Repeat は内側のキャプチャ型をそのまま保持しますが、ZeroOrMore / Optionally / 範囲指定の Repeat は各キャプチャ型を Optional でくるみます。

One がわざわざ用意されているのは、ビルダー内で .digit などの leading-dot 記法を書いても直前のコンポーネントのメンバとして解釈されてしまう問題を避けるためです。例えば次のように書きます。

Regex {
  OneOrMore(.whitespace)
  One(.digit)
}

繰り返しの貪欲度

各繰り返しには RegexRepetitionBehavior を指定でき、既定は .eager です。

指定 テキスト正規表現
.eager 指定なし(既定)
.reluctant 末尾 ?
.possessive 末尾 +

典型例として、HTML タグを拾おうとして次のように書くと意図通りになりません。

let tag = Reference(Substring.self)
let htmlRegex = Regex {
  "<"
  Capture(as: tag) {
    OneOrMore(.any)          // 既定で eager
  }
  ">"
}
// "<code>print(\"hello world!\")</code>" に対して
// tag は "code>print(\"hello world!\")</code" になってしまう

.eager は可能な限り長く食うため、最後の > までまとめて取り込んでからバックトラックで調整しようとします。こうしたケースでは .reluctant を指定して「できるだけ短く」一致させます。

Capture(as: tag) {
  OneOrMore(.any, .reluctant) // tag は "code" になる
}

.possessive はバックトラックを行わず、食い過ぎて失敗してもそれ以上戻らないため、不要な探索を避けたいときに使います。

アンカーと先読み

特定の位置でのみマッチを許したいときは Anchor を使います。.startOfLine / .endOfLine / .wordBoundary / .startOfSubject / .endOfSubject / .textSegmentBoundary などが用意されており、.inverted で反転版も得られます。

長さゼロのアサーションとしての先読みは LookaheadNegativeLookahead で表現できます。これらは位置を進めずに、そこから含まれるパターンが一致するか/しないかだけを確認します。

サブパターンの再利用

テキスト正規表現では (?1)(?2) で他のグループを参照してサブパターンを使い回しますが、DSL ではそのような専用構文は不要です。単に let で束縛して再利用すれば十分です。

Regex {
  let subject = ChoiceOf { "I"; "you" }
  let object = ChoiceOf { "goodbye"; "hello" }
  subject; "say"; object
  ";"
  subject; "say"; object
}

Local によるバックトラックのスコープ

Local は、テキスト正規表現の atomic group (?>...) に相当するコンポーネントです。中で一度マッチが決まると、以降バックトラックで戻ってこないようにできます。

Regex {
  "a"
  Local {
    ChoiceOf { "bc"; "b" }
  }
  "c"
}

この regex は "abcc" にはマッチしますが "abc" にはマッチしません。Local の中で先に "bc" が選ばれると、そこから戻って "b" を試し直すことはせず、続く "c" に進んでしまうためです。無駄なバックトラックを抑えてパフォーマンスを確保したい箇所で使います。

組み合わせ例

ここまでの要素を組み合わせると、冒頭の銀行明細は次のように書けます。

enum TransactionKind: String {
  case credit = "CREDIT"
  case debit = "DEBIT"
}

struct Date {
  var month, day, year: Int
  init?(mmddyyyy: String) { ... }
}

let statementRegex = Regex {
  TryCapture {
    ChoiceOf { "CREDIT"; "DEBIT" }
  } transform: {
    TransactionKind(rawValue: String($0))
  }

  OneOrMore(.whitespace)

  TryCapture {
    Repeat(.digit, count: 2)
    Repeat(.digit, count: 2)
    Repeat(.digit, count: 4)
  } transform: {
    Date(mmddyyyy: String($0))
  }

  OneOrMore(.whitespace)

  Capture {
    OneOrMore(.any, .reluctant)
  }

  OneOrMore(.whitespace)

  "$"

  TryCapture {
    OneOrMore(.digit)
    "."
    Repeat(.digit, count: 2)
  } transform: {
    Double($0)
  }
}

for match in statement.matches(of: statementRegex) {
  let (line, kind, date, description, amount) = match.output
  // kind: TransactionKind, date: Date, description: Substring, amount: Double
}

同じパターンを「リテラル」「DSL」「実行時コンパイル」で使い分けられ、サブパターンを let で切り出せるため、差分のレビューやリファクタリングがテキスト正規表現よりずっと素直に行えます。

今後の展望

将来的には、regex builder で書いた Regex をテキスト形式の正規表現に書き戻す API や、(?R) のような再帰的サブパターンに相当する「自分自身を引数で受け取って組み立てる」初期化子の導入が検討されています。ただし、再帰初期化子はラベルの扱いやオーバーロード解決の観点で未解決の懸念もあり、いずれも speculative な見通しであって、このまま採用が約束されるものではありません。