Swift Digest
SE-0355 | Swift Evolution

Regex Syntax and Run-time Construction

Proposal
SE-0355
Authors
Hamish Knight, Michael Ilseman
Review Manager
Ben Cohen
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0350 で導入された Regex 型は、コンパイル時にリテラルから組み立てる経路と、実行時に文字列からコンパイルする経路の両方を備えています。しかし「実行時に文字列から組み立てる」ためには、Swift としてどの正規表現構文を受け付けるのかを厳密に定義し、キャプチャの型が静的に決まらないケースをどう扱うかを決めておく必要があります。

正規表現はエンジンごとに微妙に構文が異なり、PCRE、Oniguruma、ICU、.NET、Java などで機能集合も挙動も揃っていません。特に NSRegularExpression が依存する ICU は Swift の String とは異なる文字モデルで動作し、NSRange との相互変換が必要になるなど、Swift から自然に扱うには摩擦がありました。

let pattern = #"(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)"#
let nsRegEx = try! NSRegularExpression(pattern: pattern)
// マッチ結果は NSRange を Range<String.Index> に変換してから使う必要がある

また、実行時コンパイルは SwiftPM の swift test --filter のような「ユーザが与えるパターン」を扱う場面や、エディタ・CLI ツールのように文字列を動的に受け取るケースで不可欠です。こうした用途に応えつつ、リテラル(SE-0354)や結果ビルダ DSL(SE-0351)と構文上も意味論上も一貫させるために、Swift 版の正規表現が受理する構文と、実行時コンストラクタ Regex(_: String)、そして静的に型が決まらないキャプチャを扱うための仕組みを定義する必要がありました。

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

Swift の正規表現が受理する構文を、既存の主要エンジンの「構文的スーパーセット」として定義します。あわせて、実行時にパターン文字列から Regex を組み立てるための初期化子と、キャプチャの型が静的に分からない場合のための型消去型 AnyRegexOutput を導入します。

受理する正規表現の方言

受理する構文は、次のエンジンのスーパーセットです。

  • PCRE 2: Perl や Python 系の事実上の業界標準。
  • Oniguruma: PCRE を拡張した現代的なエンジン(absent 関数や名前付きコールアウトなど)。
  • ICU: NSRegularExpression が利用する Unicode 指向のエンジン。
  • .NET: balancing group や条件パターンの細部に独自要素。

加えて、UTS#18 の文字クラス集合演算子(&&--~~)や、Java の \p{javaLowerCase} のようなプロパティ名もパースします。エンジン間で意味が食い違う点は、基本的に PCRE の挙動に寄せつつ、Swift にとってより直感的な方を採用しています(例: x{2, 4} のような空白混じりの範囲量指定子は、PCRE と違って正しい量指定子として解釈します)。

一方で、今回の Proposal は「構文としてパースできる範囲」を定めるものであって、すべての機能を初回リリースから実行時にサポートすると約束するものではありません。コールアウトや補間構文 <{...}> は、診断のために構文としてパースはするものの、実行時の挙動は将来の課題として据え置かれています。

実行時コンストラクタと AnyRegexOutput

文字列からの Regex のコンパイルは、次の 2 系統の初期化子で行います。

extension Regex {
  // キャプチャ型を明示して、強い型付きの Regex を得る
  public init(_ pattern: String, as: Output.Type = Output.self) throws
}
extension Regex where Output == AnyRegexOutput {
  // 型消去された Regex を得る
  public init(_ pattern: String) throws
}
let pattern = #"(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)"#

// 型消去版
let regex1 = try Regex(pattern)
// regex1: Regex<AnyRegexOutput>

// 強い型付け版(実行時に型整合性が検査される)
let regex2: Regex<(Substring, Substring, Substring, Substring, Substring)> =
  try Regex(pattern)

構文エラーや、明示した Output 型と実際のキャプチャが合わない場合は、初期化時に throw されます。エラーの詳細な API は今後の課題で、現時点では「コンパイラが出すのと同じ位置情報つきエラー」が投げられます。

AnyRegexOutput でのキャプチャへのアクセス

コンパイル時にキャプチャの型が分からない場合、結果は AnyRegexOutput にまとまります。これは RandomAccessCollection として振る舞い、各要素(Element)からキャプチャ範囲・部分文字列・値・名前を取り出せます。

public struct AnyRegexOutput: RandomAccessCollection {
  public struct Element {
    public var range: Range<String.Index>? { get }
    public var substring: Substring? { get }
    public var value: Any? { get }
    public var name: String? { get }
  }
}

extension Regex.Match where Output == AnyRegexOutput {
  // 名前によるアクセス
  public subscript(_ name: String) -> AnyRegexOutput.Element? { get }
}

extension Regex where Output == AnyRegexOutput {
  public func contains(captureNamed name: String) -> Bool
}

使うときは、番号(Int による subscript)か、名前付きキャプチャなら名前で取り出します。

let pattern = #"(?<kind>\w+)\s\s+(?<amount>[\d,]+\.\d{2})"#
let regex = try Regex(pattern)
// regex: Regex<AnyRegexOutput>

if let match = "DEBIT  1,000.00".firstMatch(of: regex) {
  let kind = match["kind"]?.substring       // Optional("DEBIT")
  let amount = match["amount"]?.substring   // Optional("1,000.00")
  let whole = match[0].substring            // マッチ全体
}

型消去版と強い型付き版の相互変換

強い型付けの Regex を型消去版に詰めたり、逆に型消去版から型付き版を取り出したりできます。後者は実行時の型チェックがあり、不一致なら nil を返します。

extension Regex where Output == AnyRegexOutput {
  public init<Output>(_ regex: Regex<Output>)
}

extension Regex {
  // 型が一致しなければ nil
  public init?(_ erased: Regex<AnyRegexOutput>, as: Output.Type = Output.self)
}

// マッチ単体の変換も用意される(ARC のコストを抑えるため)
extension Regex.Match where Output == AnyRegexOutput {
  public init<Output>(_ match: Regex<Output>.Match)
}

リテラル文字列からの Regex

正規表現としての解釈ではなく、文字列そのものを逐語的にマッチさせたい場合のために、verbatim: ラベル付き初期化子があります。これは DSL に文字列リテラルを埋め込んだのと同じ挙動の Regex を作ります。

let regex = Regex(verbatim: "a.b")
// "a.b" という 3 文字にのみマッチ。`.` はメタ文字扱いされない

代表的な構文機能

Proposal の大半は構文仕様の網羅的な記述で占められていますが、実利用の観点では次のような機能が一通り揃っていることを押さえておけば十分です。

  • グループ: キャプチャ (...)、非キャプチャ (?:...)、名前付き (?<name>...)(?P<name>...)(?'name'...) も可)、分岐リセット (?|...)、アトミックグループ (?>...)、先読み/後読み (?=...)/(?!...)/(?<=...)/(?<!...)、非アトミック版 (?*...)/(?<*...)、script run (*script_run:...) など。
  • 量指定子: ? * + {n,m} に加え、貪欲・怠惰 (?)・所有的 (+) の区別。
  • アンカー: ^ $ \A \Z \z \b \B \G \y \Y
  • 文字クラス: . \d \D \s \S \w \W \h \H \v \V \R \N \X など。カスタム文字クラス [...] は入れ子とと集合演算(&&--~~)に対応。
  • Unicode スカラ: \u{...}\x{...}\U........\o{...}\N{...}(名前または U+...)。\u{A B C} のように空白区切りで複数スカラを一気に書けます。
  • 文字プロパティ: \p{...} と POSIX 風の [:...:] を等価に扱い、双方をカスタム文字クラスの外でも使えます。プロパティ名は「fuzzy マッチ」で、whitespaceisWhitespaceis-White_Space などはすべて同じと見なされます。\p{Latin} のような略記は Script_Extensions=Latin として解釈されます。
  • 後方参照とサブパターン: \1\9\g{name}\k<name>(?P=name)、再帰 (?R)\g<0> など。
  • 条件: (?(cond)yes|no)。条件にはグループ参照、再帰チェック、任意の正規表現などを書けます。
  • マッチングオプション: (?i) (?m) (?s) (?x)/(?xx) (?U) (?J) (?n) などをグループ単位や孤立形で切り替え。孤立形の (?i) は PCRE の挙動に合わせ、a(?i)b|c|da(?i:b)|(?i:c)|(?i:d) と解釈されます。
  • PCRE バックトラッキング指示: (*ACCEPT)(*FAIL)(*COMMIT)(*PRUNE)(*SKIP)(*THEN)(*MARK:tag) など。
  • グローバルマッチングオプション: 正規表現の先頭に (*UTF)(*LIMIT_MATCH=100) のように指定するオプション。

キャプチャの番号付けと重複名

キャプチャはカッコの開き位置の順に番号が振られ、非キャプチャグループはカウントされません。(?|...) による分岐リセットを使うと、各分岐でキャプチャ番号を揃えられます。同じ名前のキャプチャは、番号が同じ(分岐リセット内、または (?J) が有効)である場合に限って許され、それ以外は構文エラーになります。

Future Directions

構文仕様は Swift のソース互換性・バイナリ互換性にかかわるため、本 Proposal では「何を受理するか」までを確定させ、エラー報告 API のリッチ化、コールアウトの実行時サポート、PCRE や ICU といった別エンジン向けの正規表現への出し直し、Swift 独自の拡張構文などは将来の課題として据え置かれています。いずれも本 Proposal のスコープ外で、実現を約束するものではありません。