Open9

SE0354: Regex Literals

UeeekUeeek

Introduction

Swiftに"Regex Literal"を導入する。
それによって、コンパイル時のチェックと、typed-capture推論が可能になる。
"Regex Literal"は、"SE350: Regex Type and Overview"で語られた話を完全なものに近づける。

UeeekUeeek

Motivation

"SE350: Regex Type and Overview"では、"Regex" Typeを導入した。それによって、正規表現を 動的にコンパイルすることが可能になった。

let pattern = #"(\w+)\s\s+(\S+)\s\s+((?:(?!\s\s).)*)\s\s+(.*)"#
let regex = try! Regex(pattern)
// regex: Regex<AnyRegexOutput>

正規表現を実行時にコンパイルできることには、いくつかの場合で便利である。
例えば、user_inputを使用する場合。
しかし、正規表現が静的な場合は、いくつかの点で最適ではない。
+正規表現の文法のエラーを 実行するまで見つけられれない。いくつかのエラーを対処するために、明示的なエラーハンドリング(e.g. try!)が必要になる。

  • syntax-highlightや、コード補完やリファクタリングの助けとなる特別なツールのサポートが利用できない。
  • 実行するまで、captured-typeを知ることができないため、動的なAnyRegexOutput capture-typeを使用する必要がある。
  • 文法が全体的に冗長である、特に、mtaching-functionの引数など。
    Capture types aren't known until run time, and as such a dynamic AnyRegexOutput capture type must be used.
    The syntax is overly verbose, especially for e.g an argument to a matching function.
UeeekUeeek

Proposed solution

Regex-literalは、"/hoge/"のように書くことができる。
A regex literal may be written using /.../ delimiters:

// Matches "<identifier> = <hexadecimal value>", extracting the identifier and hex number
let regex = /(?<identifier>[[:alpha:]]\w*) = (?<hex>[0-9A-F]+)/
// regex: Regex<(Substring, identifier: Substring, hex: Substring)>

"/"(Forward slash)は正規表現の専門用語である。
forwardSlashと正規表現の関係は1969年頃に始まり、そしてそれはlessやvimなどの部分文字列を扱うtext toolに継承された。
その文法はsedにも使用されて、そこからPerl Ruby JSなどにも使用された。
ForwardSlashはすぐに正規表現だと認識することができる。
唯一の一般的な代替手段は、ライブラリAPIに渡される一般的な文字列リテラルである。それらは一般的に余計なオーバーヘッドがあり、escape処理を必要とし、実行するまでエラーがわからない。
提案するRegex-Literalはそのような制限を持たないため、forwardSlashは開発者に適切な挙動の手がかりを提供する。
forwardSlashに関する前例は50年以上あり、その他のものについてはほとんで前例がない。

PerlとRubyは追加で、"user-selected delimiters"が可能になっている。それは、正規表現の中でslashをえscapeする必要がないようにする。この特的のために、ここでは`#/.../#'という拡張リテラルを提供する。

拡張リテラル(#/.../#)で、正規表現の中でForwardSlashをescapeする必要がなくなる。
リテラルとエスケープの周囲の任意の数の#文字をバランスよく配置できるようになる。。
NewLineに続いて"#/"を開始すると、空白文字を無視して、行の終わりのコメントを無視する、複数行のリテラルを作成することができる。

コンパイラは正規表現の内容を"SE-0355: Regex Construction"にまとめられた文法を使用して解析し、コンパイル時にエラーを診断する。
CaptureTypeとラベルは正規表現内で表現されているcaptureGroupsに応じて、自動で推論される。
RegexLiteralはエディタやツールが literal内部の文法の色付けや、正規表現のsub-structureのハイライトや、リテラルを同等なResult_Builder_DSLに変換することを可能にする。

RegexLiteralはまた、RegexDSLとのシームレスな合成を可能にし、正規表現の文法とビルダーの他の要素との簡単な混合が可能になる。(SE0351: Regex Builder DSL)

// A regex for extracting a currency (dollars or pounds) and amount from input 
// with precisely the form /[$£]\d+\.\d{2}/
let regex = Regex {
  Capture { /[]/ }
  TryCapture {
    /\d+/
    "."
    /\d{2}/
  } transform: {
    Amount(twoDecimalPlaces: $0)
  }
}

この柔軟性により、適切な場合には簡潔な mtaching-syntaxを使用することができる。
さらに、明確で強力な型が必要な場合には より明示的な構文を使用できる。

コメントや演算子の文法としての 既存の"forwardSlash"の仕様があるため、文法的な曖昧性がいくつか考えられる。
それらはとても少ないケースだが、それらのケースへの影響がこの文法を却下するには値しないと考える。
これらのいくつかの曖昧性が 言語への破壊的変更を必要とする。
新しい"/../"の文法を使用するためには、NewLanguageModewを有効にする必要がある。

UeeekUeeek

Detailed design

Typed captures

RegexLiteralのCaptureTypeは、CaptureGroupの存在によって静的に決定される。
これは、DSLの推論の挙動と似た動作をする。詳細は(StronglyTypedCaptures)を参照。

私たちは、RegexLiteralで、以下の推論の挙動を提案する。

  • "Substring"は常に全体のマッチに現れる。
  • Captureが一つでも存在するなら、"Subscring"を含むタプルが形成され、後続の要素はCaptureTypeを表す。

Cpatureのタイプは、デフォルトで"Substring"であるが、マッチに成功した時の値の存在を保証できない時は、optionalになる。
これは"?" "*"や 下限が0の範囲数量子(e.g. {0,n})を含む、ゼロになる可能性のある数量子内にネストされている場合に発生する。
branch of alternationに現れる場合にも発生する。
例えば、

let regex1 = /([ab])?/ <- "?"なので、Captureがなくてもマッチする。
// regex1: Regex<(Substring, Substring?)>

let regex2 = /([ab])|\d+/ <- Capture or Not-Captureの形
// regex2: Regex<(Substring, Substring?)>

capture自身が0数量子やalternationの内部にいない限りは、captureにネストした0数量子やalrenationは optionalCaptureを生成しない。

let regex = /([ab]*)cd/ 
// regex: Regex<(Substring, Substring)> 

この場合では、もし"*"が0回でマッチすると、マッチの結果はEmptyStringになる。

OptionalWrappingはネストにならない、最大でも一層のOptionalとなる。

let regex = /(.)*|\d/
// regex: Regex<(Substring, Substring?)>

この挙動は、そのようなケースでResultBuilderの現状の制限によって、複数のOptionalがネストしてしまうDSLの挙動と異なる。

UeeekUeeek

Named captures

現状RegexLiteralだけが持っている TypedCaptureの機能は、NamedCaptureGroupのラベルがついたタプルの要素を推論する能力である。

func matchHexAssignment(_ input: String) -> (String, Int)? {
  let regex = /(?<identifier>[[:alpha:]]\w*) = (?<hex>[0-9A-F]+)/
  // regex: Regex<(Substring, identifier: Substring, hex: Substring)>
  
  guard let match = input.wholeMatch(of: regex), 
        let hex = Int(match.hex, radix: 16) 
  else { return nil }
  
  return (String(match.identifier), hex)
}

match.1, match.2のように数字でアクセスするだけでなく、captureを"match.identifier"と"match.hex"として参照できる。
このラベル推論の挙動は、DSLでは利用できないが、"bind captures to named variable"(SE-0531)を代わりに使用することができる。

Extended delimiters #/.../#, ##/.../##

"Backslash"は正規表現内でfowardSlashを書くために使われるかもしれない。(e.g. /foo/bar/)
しかし、それは文法的にnoisyで紛らわしい。
それを避けるために、任意の数のbalannced Number signsで囲まれたRegexLiteralを使用する。(Balancedは、前にn個ついてたら、後ろにもn個ついてるということ?)
これは、リテラルのdelitmiterを変える、その結果 forward slashをescapeなしで使用することができる。

let regex = #/usr/lib/modules/([^/]+)/vmlinuz/#
// regex: Regex<(Substring, Substring)>

"#"の数は"/#"のような文字をリテラル内で使用するために、増やすことができる。
これは(SE-0200)で導入されたRawStringLiteralの文法をにているが、いくつかの重要な違いがある。
BackSlashesはLiteralの文字にはならない。また、開始のdelimiterの後が改行なら、空白と行の終わりのコメントが無視される multi-line literalが使える。

let regex = #/ <-開始のdelimiterの後が改行
  usr/lib/modules/ # Prefix <-コメントが書ける。
  (?<subpath> [^/]+)
  /vmlinuz          # The kernel <-空白が無視される。
/#
// regex: Regex<(Substring, subpath: Substring)>
Escaping of backslashes

この文法は、RawStringLiteral#"..."# とは、正規表現内でbackslashをリテラルとして扱わない点で、異なっている。
StringLiteralの #"\n"#は改行文字を表すが、RegexLiteralの#/\n#はnewline escape sequenceのままである。(Backslashがescapeとして機能しないことを言いたい?)

RawString LiteralにおけるescapingBehaviorの挙動の 主なモチベーションは、contensが escapeが不要な外部のファイルへの(to/from)持ち出しが簡単なことである。
StringLiteralにとって、これはbackslashがデフォルトでliteralとして扱われることを示唆する。
しかし、RegexLiteralでは、backslashがその意味のままで使用されることを示唆する。
これは、コードの外から持ってきた正規表現との相互運用を、delimiterの使用方法を合わせるためのescape seuqenceの調整なしで、可能にする。

StringLiteralでは、backslashがconsumer(e.g. NSRegularExpression)にとって意味があり、コンパイラーにとっては意味がないものになるため、raw syntaxを使用しない場合はtrickyである。

// Matches '\' <word char> <whitespace>* '=' <whitespace>* <digit>+
let regex = try NSRegularExpression(pattern: "\\\\\\w\\s*=\\s*\\d+", options: []) <-Stringでのtrickyな例

この場合、コンパイラがこれらのシーケンスをStringLiteralEscapeとして認識するのが目的ではなく、NS RegularExpressionがそれらを正規表現Escapeシーケンスとして解釈することが目的である。
そのため、RawStringを使用してbackslashを文字通りに扱うことができ、NSRegularExpressoinがescapeを直接処理できるようになる。(#"\\w\s*=\s*\d+"# <-上の表現のescapeを処理した結果)

正規表現パーサーはそのようなescape シーケンスの唯一の可能なconsumerであるため、これはRegexLiteralにとっては問題にならない。
このような正規表現は直接下のように表現できる。

let regex = /\\\w\s*=\s*\d+/
// regex: Regex<Substring>

Backslashは以前として、Literalとして扱われるためにはescapeする必要がある。
しかし、拡張delimiters(#/.../#)を含むRegexLiteralの中で、\s,\wや\p{..}のようなRegex Escape Sequenceを書く必要は頻繁に起こるとは考えられない。

Multi-line literals

Extended regex delimiters additionally support a multi-line literal when the opening delimiter is followed by a new line. For example:
Extended Regex Delimiterは、opening delimiterの次の文字が改行の時に、multi-line literalが可能になる。

let regex = #/
  # Match a line of the format e.g "DEBIT  03/03/2022  Totally Legit Shell Corp  $2,000,000.00"
  (?<kind>    \w+)                \s\s+
  (?<date>    \S+)                \s\s+
  (?<account> (?: (?!\s\s) . )+)  \s\s+ # Note that account names may contain spaces.
  (?<amount>  .*)
/#

このようなliteralでは、extended regex syntax(?x)が可能になる。
これは、whitespaceが意味を持たなくなり、また行末のコメントも可能になる。

このモードは delimiterの中に一つ以上の#文字が存在するときに可能になる。
SE-0168で導入されたmulti-line stringと同様に、closing delimiterは新しい行に記入される必要がある。
解析時のコンラインを避けるために、そのようなliteralはclosing deliterが存在しないなら解析されない。
これは冒頭部分だけが入力された時に、残りのファイルが正規表現として解析されてしまうことを防ぎます。

ようなリテラルの拡張構文は (?-x) で無効にすることはできませんが、グループ (?-x:...) または引用符で囲まれたシーケンス \Q...\E の内容に対しては,複数行にまたがらない限り、無効にすることができます
複数行に跨った場合で、whitespaceを意味があるものとして解釈されるには、改行を逐語的に維持しながら、先頭と末尾のスペースを取り除く必要がある。
これはサポートすることが可能だが、その挙動は混乱を生むと考えられる。
望むなら、改行は\nを使って書くことがでる。そこでは、backslashはnewlie characterをescapeするために使用される。

let regex = #/
  a\
  b\
  c
/#
// regex = /a\nb\nc/
UeeekUeeek

Ambiguities of /.../ with comment syntax

コメントの//と、block commentの /**/は引き続きコメントとして解釈される。
からのregex literalは表現としては特に意味のないものである、しかし #//#として書くことが可能である。
"*"は正規表現の開始文字としては無効なものなので、問題にはならない。

ただし、"*"で終わるregex-literalに囲まれたblock commentは解析時に混乱を生む。

/*
let regex = /[0-9]*/ <- どこでblock comment を閉じればいいか難しくなる?
*/

この場合では、block commentが予定より早いところで閉じられる。
この問題はString-literalですでに知られている問題である。
正し、"*"の記号を使う正規表現では、より発生しやすいと考えられる。
この問題は block commentの代わりにline Commentを使用することで回避することができる。

Ambiguity of /.../ with infix operators

infix operatorが正規表現とともに使用された時に、少しの曖昧性が生じる。(+/ という演算子があった時に、紛らわしくなるという話)
whitespaceなしで使用した時(e.g. x+/y/), 表現は infix operator (+/)を使用したとして解釈される。
そのためWhitespaceが 正規表現の解釈のために必要になる(e.g. x + /y/)
代わりに、extended literalsを使用するといいかもしれない (e.g. x+#/y/#)

Regex syntax limitations in /.../

解析の曖昧性を避けるために、/.../ regex literalは spaceやtabで始まったり終わったりしている場合には、解析されない。
この制限は、extended #/.../# literalを使用することで回避できる。

Rationale 根拠

終わりの文字に対する制限は、特定のケースでprefixとinfix operatorの コード互換性の保つために役立つ。どのようなケースかは次の章で説明する。
開始文字に対する制限は "/.../" regex literalが新しい行から始まった時に生じる曖昧性のために必要である。
これはresultBuilderにとって問題になる、特に、RegexBuilderのなかで頻繁に使用される場合。)

let digit = Regex {
  TryCapture(OneOrMore(.digit)) { Int($0) }
}
// Matches against <digit>+ (' + ' | ' - ') <digit>+
let regex = Regex {
   digit
   / [+-] /
   digit
}

3つの要素として解釈される代わりに(二行めは正規表現リテラル),
これは operands digit, [+-], digitの sinlge operator chainとして解釈される。(a op b op c ってことかな?)
よって、これは意味的に無効であると診断される。

この問題を避けるために、regexLiteralはスペースやタブで開始されてはならない。
spaceやtabが一文字めとして必要な場合は、escapeされう必要がある。

let regex = Regex {
   digit
   /\ [+-] /
   digit
}

もしくは、extendedLiteralを使用する必要がある。

let regex = Regex {
   digit
   #/ [+-] /#
   digit
}

この制限は、infix operatorが両側にスペースが必要になるという性質を利用している。
これは 改行に加え spaceも含む

let a = 0 + 1 // Valid
let b = 0+1   // Also valid
let c = 0
+ 1 // Valid operator chain because the newline before '+' is whitespace.

let d = 0 +1 // Not valid, '+' is treated as prefix, which cannot then appear next to '0'.
let e = 0+ 1 // Same but postfix
let f = 0
+1 // Not a valid operator chain, same as 'd', except '+1' is no longer sequenced with '0'.

"f'と同様に、operator chainとして解釈されないようにするために、RegexLiteralの最初の文字は、spaceやtabであってはならない。

let g = 0
/1 + 2/ // Must be a regex
UeeekUeeek

How /.../ is parsed

RegexLiteral (/.../)は 開始の"/"がexpression positionにあり、close "/"も存在する時に、解析される。
下の例は今まで通り解釈される。

// Infix '/' is never in an expression position in valid code (unless unapplied).
let a = 1 / 2 / 3

// None of these '/^/' cases are in expression position.
infix operator /^/
func /^/ (lhs: Int, rhs: Int) -> Int { 0 }
let b = 0 /^/ 1

// Also fine.
prefix operator /
prefix func / (_ x: Int) -> Int { x }
let c = /0 // No closing '/', so not a regex literal. The '//' of this comment doesn't count either.

しかし "r = /^/"はRegexとして解析される。

RegexLiteralは prefix operatorと一緒に使用される場合がある。("let r = ^^/x/"は "let r = ^^(/x/)"として解析される).
この場合は、"/"を含むoperatorCharacterが expressionPositionにでてきたら、初めの"/"までの文字列はprefix characterとして分離され、そのあとは通常通り解析される。
A regex literal may be used with a prefix operator, e.g let r = ^^/x/ is parsed as let r = ^^(/x/).

すでに議論したように、RegexLiteralは スペースやtabで始まら・終わらない
これは、下のケースは、通常通り解析され続けることを意味する。

// Unapplied '/' in a call to 'reduce':
let x = array.reduce(1, /) / 5
let y = array.reduce(1, /) + otherArray.reduce(1, /)

// Prefix '/' with another '/' on the same line:
foo(/a, /b)
bar(/x) / 2

// Unapplied operators:
baz(!/, 1) / 2
qux(/, /)
qux(/^, /)
qux(!/, /)

let d = hasSubscript[/] / 2 // Unapplied infix '/' and infix '/'

let e = !/y / .foo() // Prefix '!/' with infix '/' and operand '.foo()'

しかし、下のような例の曖昧性をなくすには十分でない。

// Prefix '/' used multiple times on the same line without trailing whitespace:
(/x).foo(/y)
bar(/x) + bar(/y)

// Cases where the closing '/' is not used with whitespace:
bar(/x)/2
baz(!/, 1)/2

// Prefix '/^' with postfix '/':
let f = (/^x)/

それらのすべでの場合は、開始の/はexpression positionに現れている。そして、スペースなしで使用されている終わりの/として解釈さレそうな/が存在する。
そのような互換性を壊すものを避けるために、一つのヒューリスティックを導入した。
RegexLiteralは かっこ列の生合成が取れていないなら解析されない。
これはエスケープとカスタム文字クラスの両方を考慮するため、正規表現としてすでに無効になっている構文にのみ適用されます。そのため、上記のケースはすべて通常通り解析され続けます。

この追加のヒューリストックは、正規表現が有効で破壊的変更を生じるケースの曖昧性を排除する単純な方法として機能する。
例えば、下のケースはRegexLiteralとなる。

foo(/a, b/) // Will become regex literal '/a, b/'
qux(/, !/)  // Will become regex literal '/, !/'
qux(/,/)    // Will become regex literal '/,/'

let g = hasSubscript[/]/2 // Will become regex literal '/]/'

let h = /0; let f = 1/ // Will become the regex literal '/0; let y = 1/'
let i = /^x/           // Will become the regex literal '/^x/'

しかし、それらは括弧を挿入することで、簡単に曖昧さをなくすことができる。

// Now a prefix and postfix '/':
foo((/a), b/)

// Now unapplied operators:
qux((/), !/)
qux((/),/)
let g = hasSubscript[(/)]/2

let h = (/0); let f = 1/ // Now prefix '/' and postfix '/'
let i = (/^x)/           // Now prefix '/^' and postfix '/'

または、いくつかのケースではwhitespaceを入れることで、それが可能になる。

qux(/, /)
let g = hasSubscript[/] / 2

しかし、これらのケースはとても稀な例だと考えられる。
似たケースで、二つの/と一緒に使われる、ペアされてない infix operatorの使用が考えられる

baz(/^/) // Will become the regex literal '/^/' rather than an unapplied operator

これは、カッコやスペースで曖昧性をなくすことができない、しかし、closureを使用することで曖昧性を排除できる。

baz({ $0 /^/ $1 }) // Is now infix '/^/'

これは、Infix operatorの位置にある正規表現は解析されないという性質を利用している。

UeeekUeeek

Source Compatibility

/.../を解析することは、下のケースを満たす時に ソースコード互換性を破壊する可能性がある

  • "/"がexpression positionに現れる。
  • 閉じる/が同じ行に存在する。
  • 最初と最後の文字がスペースでもtabでもない。
  • Literalの中で括弧の生合成が取れている。
    しかし、そのケースは稀であると考えられる。また、それらのケースでも、カッコやclosureを使用することで曖昧性を排除できる。

ソースコードの互換性が破壊されるかもしれないケースに対処するために、/.../ regex literalはSwift6で導入される。
しかし、-enable-bare-slash-regexフラグか、BareSlashRegexLiterals feature flagで有効にすることができる。
注意として、これは #/.../# extended delimiter syntaxは対象でなく、それはすぐに使用できる。