Swift Regex のおさらい

はじめに

業務で扱う iOS アプリのデプロイメントターゲットも、iOS 16 以上を切れる現場が増えてきたかと思います。それまで NSRegularExpression で書いていた正規表現コードを、iOS 16 で入った Regex 型に置き換えられる場面が広がっています。

この記事では、Regex 型の基本 (生成方法、マッチ系メソッド、名前付きキャプチャ) を 1 通り整理したあと、ディープリンク用の URL ルーターを実装した際に Regex をどう活かしたかを紹介します。NSRegularExpression から Regex へ移行する際の取っかかりとして使ってもらえれば嬉しいです。

iOS 16 までと iOS 16 以降

iOS 16 までは正規表現といえば NSRegularExpression でした。マッチ対象の文字列を target とすると、こんなコードを書いていました。

let target: String = "/item/123"
let pattern = "^/item/([^/]+)/?$"
let regex = try NSRegularExpression(pattern: pattern)
let range = NSRange(target.startIndex..., in: target)
if let match = regex.firstMatch(in: target, range: range),
   let idRange = Range(match.range(at: 1), in: target) {
    let id = String(target[idRange])  // "123"
}

NSRangeRange<String.Index> の往復と、match.range(at: 1) のようなキャプチャ番号での取り出しが必要でした。

iOS 16 で入った Regex 型では同じ処理がこう書けます。

let target: String = "/item/123"
let regex = try Regex(#"^/item/(?<id>[^/]+)/?$"#)
if let match = target.wholeMatch(of: regex),
   let id = match["id"]?.substring {
    print(String(id))  // "123"
}

NSRange がなくなり、キャプチャは名前 ((?<id>...)) で引けます。String 自身に wholeMatch(of:) / firstMatch(of:) / contains(_:) が生えているので、変換のための型を経由せず直接マッチを取れます。

Regex の作り方

Regex リテラルと文字列からの実行時生成、2 つの作り方があります。

Regex リテラル

Swift 5.7 / iOS 16 では、Regex 型と同時に / で囲む Regex リテラル記法も導入されました。コンパイル時にパターンが検証されるため、書き間違えればビルドエラーで気付けます。

let regex = /^\/item\/(?<id>[^\/]+)\/?$/

URL を扱う場合、/ のエスケープが頻発するので少しつらいです。

文字列から生成

Regex(_:) initializer に文字列を渡すと、実行時に Regex を生成します。raw string literal #"..."# と組み合わせると、エスケープなしで書けます。

let regex = try Regex(#"^/item/(?<id>[^/]+)/?$"#)

URL パターンを扱うなら後者の方が読みやすいです。

マッチ系メソッド

String から直接呼べるマッチ系メソッドは次の通りです。

メソッド何をするか
wholeMatch(of:)文字列全体がパターンに一致するか
firstMatch(of:)文字列のどこかにパターンが現れる最初の位置
contains(_:)マッチが存在するかの真偽値だけ
matches(of:)すべてのマッチを配列で返す
replacing(_:with:)マッチした部分を別文字列に置換

URL 解決のように「文字列全体がパターンと一致するか」を判定する場面では wholeMatch(of:) が素直です。

let regex = try Regex(#"^/item/(?<id>[^/]+)/?$"#)
if "/item/123".wholeMatch(of: regex) != nil {
    print("matched")
}

名前付きキャプチャ

Regex.Match には添字 (match["id"]) が生えていて、名前付きキャプチャをそのまま引けます。

let regex = try Regex(#"^/item/(?<id>[^/]+)/?$"#)
if let match = "/item/123".wholeMatch(of: regex),
   let raw = match["id"]?.substring {
    let id = String(raw)  // "123"
}

複数キャプチャも同じ要領です。

let regex = try Regex(#"^/item/(?<id>[^/]+)/sub/(?<sub>[^/]+)/?$"#)
if let m = "/item/123/sub/456".wholeMatch(of: regex) {
    let id = String(m["id"]!.substring!)
    let sub = String(m["sub"]!.substring!)
}

match["id"]?.substring の戻り値は Substring? なので、String(_:) に渡して値型に変換してから使うのが扱いやすいです。

RegexBuilder という選択肢

Swift 5.7 では RegexBuilder という DSL もあります。キャプチャの型がコンパイル時に決まるので、文字列で書くより型安全に扱えます。

import RegexBuilder

let id = Reference(Substring.self)
let regex = Regex {
    "/item/"
    Capture(as: id) { OneOrMore(.any) }
}

仕様書のテキスト記法をそのまま埋め込みたい場合は文字列の方が、キャプチャ結果を強い型で扱いたい場合は RegexBuilder の方が向いています。

ディープリンクの URL 解決に使ってみる

ここからは実際の応用例として、ディープリンクの URL を enum で受け取る処理に Regex を組み込みます。

最終的な利用者コード

仕様書のパス記法 (/item/:id?ref=:source のような形) を、そのままケースに付ける @Route 属性に書くだけで動かせるようにしました。

@DeeplinkRouter
enum DeeplinkPath: DeeplinkRoutable, Hashable, Sendable {
    @Route("/home")
    case home

    @Route("/item/:id?ref=:source")
    case itemDetail(id: Int, source: String?)

    @Route("/user/:id")
    case userProfile(id: String)

    @Route("/search?q=:query")
    case search(query: String?)

    @Route("/settings")
    case settings

    @Route("/settings/notifications")
    case notificationSettings
}

利用者側のコードに RegexURLComponents も登場しません。Hashable / Sendable のような標準的なプロトコルもそのまま並べて適合できます。

利用イメージ

@DeeplinkRouter がコンパイル時に static func resolve(url:) -> Self? を生やしているので、URL を渡すだけで型安全な enum が返ります。

DeeplinkPath.resolve(url: URL(string: "app://home")!)
// → .home

DeeplinkPath.resolve(url: URL(string: "app://item/123?ref=push")!)
// → .itemDetail(id: 123, source: Optional("push"))

DeeplinkPath.resolve(url: URL(string: "app://user/alice")!)
// → .userProfile(id: "alice")

DeeplinkPath.resolve(url: URL(string: "https://example.com/item/777")!)
// → .itemDetail(id: 777, source: nil)

DeeplinkPath.resolve(url: URL(string: "app://unknown/path")!)
// → nil

戻り値は Int / String / String? などケースの associated value 型にきれいに収まった状態なので、呼び出し側で switch して画面遷移に流すだけで使えます。

仕様書記法 → Regex への変換

仕組みとしては、:id のようなパスパラメータを (?<id>[^/]+) という名前付きキャプチャに機械的に変換し、Regex のマッチ結果をそのまま associated value に流しています。

"/item/:id"     → #"^/item/(?<id>[^/]+)/?$"#
"/user/:id"     → #"^/user/(?<id>[^/]+)/?$"#
"/home"         → #"^/home/?$"#

末尾の /? で trailing slash の有無を吸収しています。

マクロ展開で見える 1 ケース分の中身

実際の挙動を掴むため、1 ケースだけの最小例を見ます。利用者が書くのはこれだけです。

@DeeplinkRouter
enum DeeplinkPath {
    @Route("/item/:id?ref=:source")
    case itemDetail(id: Int, source: String?)
}

@DeeplinkRouter がこのケースを解析して、次のような resolve(url:) を生成します。

enum DeeplinkPath {
    case itemDetail(id: Int, source: String?)

    static func resolve(url: URL) -> Self? {
        let _comp = URLComponents(url: url, resolvingAgainstBaseURL: true)
        let _targets = ["/" + (url.host ?? "") + url.path, url.path]
        for target in _targets {
            if let _m = target.wholeMatch(of: try! Regex(#"^/item/(?<id>[^/]+)/?$"#)) {
                guard let _raw_id = _m["id"]?.substring else { return nil }
                guard let id = Int(String(_raw_id)) else { return nil }
                let _q_source = _comp?.queryItems?.first(where: { $0.name == "ref" })?.value
                let source = _q_source
                return .itemDetail(id: id, source: source)
            }
        }
        return nil
    }
}

Int(_:) は failable initializer なので、"abc" のような不正な値は nil で弾けます。:id 部分が数値かどうかを呼び出し側で気にせず書ける形です。

利用者側は @Route("/item/:id?ref=:source") を書くだけで、Regex の構文や URLComponents の扱いはすべてマクロが生成したコードに閉じ込められます。Regex を利用者から隠す、というのが今回の設計の主眼でした。

host + path と path だけの 2 段で見る

カスタムスキームと Universal Link で、同じ「item の 123」を指している URL でも hostpath の振り分けが違います。

let a = URL(string: "app://item/123")!
a.host  // "item"
a.path  // "/123"          ← "item" が path に入っていない

let b = URL(string: "https://example.com/item/123")!
b.host  // "example.com"
b.path  // "/item/123"     ← こちらは "item" も含まれている

app://item/123 という URL は app://(host)/path の構造なので、item が host 扱いになり、/123 だけが path に残ります。このまま url.path だけを正規表現に当てても /item/:id パターンに一致しません。

これを吸収するために _targets に 2 種類の文字列を詰めて、順に正規表現を当てます。

let _targets = [
    "/" + (url.host ?? "") + url.path,  // host を頭に付けた形
    url.path                             // path だけの形
]

URL ごとに _targets の中身と、/item/:id の正規表現がどちらにヒットするかを並べるとこうなります。

入力 URL_targets[0] (/ + host + path)_targets[1] (path だけ)マッチする要素
app://item/123/item/123/123[0]
https://example.com/item/123/example.com/item/123/item/123[1]

カスタムスキームは [0] で、Universal Link は [1] で hit します。@Route("/item/:id") を 1 行書くだけで、どちらの URL 形式から流れてきたディープリンクも同じ enum case に解決されます。

try! の判断

Regex(_:)throws ですが、ここでは try! を使っています。パターン文字列は静的に決まる内容で、実行時の入力には依存しません。生成側の処理が正しい限り構築は失敗しないため、try! で潰しています。

生成コード自体の妥当性は別途スナップショットテストで担保しています。

まとめ

iOS 16 以降のアプリなら、正規表現周りはほぼ Regex 型に寄せられます。

  • Regex リテラル /.../ か、文字列なら Regex(_:) + raw string #"..."#
  • 全体一致は wholeMatch(of:)、部分は firstMatch(of:) / contains(_:)
  • 名前付きキャプチャは match["name"]?.substringSubstring? として取れる
  • NSRange 往復は不要

ディープリンクのような「決まった形の URL を enum に詰め替える」用途では、Regex + named capture + failable initializer を組み合わせるだけで型安全な変換コードが書けます。さらにそれをマクロでラップしておけば、利用者からは Regex を隠した宣言的な API になります。NSRegularExpression で書いた既存処理が手元にある方は、書き換えてみると 1 段すっきりするはずです。

参考リンク