仕様に割りと忠実に URL をパースする正規表現を書いてみた

実用的なものであれば URL をパースする正規表現なんてすぐ書けるんですけど、仕様に忠実に書こうと思うとどうなるのかなぁと思って書いてみました。

URI の定義

まず、RFC 3986 には URI が次のように ABNF で定義されています。

URI         = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

hier-part   = "//" authority path-abempty
             / path-absolute
             / path-rootless
             / path-empty

query         = *( pchar / "/" / "?" )
fragment      = *( pchar / "/" / "?" )

pchar         = unreserved / pct-encoded / sub-delims / ":" / "@"
unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
pct-encoded = "%" HEXDIG HEXDIG
sub-delims  = "!" / "$" / "&" / "'" / "(" / ")"
                  / "*" / "+" / "," / ";" / "="

今回対象にするのは http, https, ftp scheme に限定することにするので、hier-part (hierarchical part) は “//” authority path-abempty になります(たぶん)。
次に authority ですが、次のように定義されています。1

authority   = [ userinfo "@" ] host [ ":" port ]
userinfo    = *( unreserved / pct-encoded / sub-delims / ":" )
host        = IP-literal / IPv4address / reg-name
port        = *DIGIT

host はちゃんと見てないんですが、RFC 1035RFC 1123 に書かれている有効なホスト名だけを対象とすることにします。IP アドレスは考慮しません。
有効なホスト名は、各ラベル(ドットで区切られる文字列)がアルファベットか数字で始まり、アルファベット、数字、ハイフンで構成され、ハイフンで終わらなく、63文字以内であり、ホスト名全体で255文字以内のものです。ホスト名が全体で255文字以内という条件は正規表現で表現できそうにないので無視します。あと、現在 TLD に数字やハイフンを含むものがないのでそのようなホスト名は除外します。

残りの path-abempty, query, fragment ですが、

path                = path-abempty    ; begins with "/" or is empty
                    / path-absolute   ; begins with "/" but not "//"
                    / path-noscheme   ; begins with a non-colon segment
                    / path-rootless   ; begins with a segment
                    / path-empty      ; zero characters

path-abempty  = *( "/" segment )

segment       = *pchar

となっています。今回対象にしているのは path-abempty ですが、これはスラッシュで始まるか空文字列と定義されています。

パースしてみる

文字列処理のための言語といえば、そう、R ですよね!というわけで R でパースしてみました。2

library(gsubfn)

regex <- "(?ix)
    # scheme
    ( https? | ftp )
    ://
    # authority
    (
        (?:
            # userinfo
            (
                (?: [-.~\\w!$&'()*+,;=] | %[0-9a-f]{2} )+
                :
                (?: [-.~\\w!$&'()*+,;=] | %[0-9a-f]{2} )+
            )
            @
        )?
        # host (the last label is one of TLDs and currently consists of alphabets and dot)
        ( (?: [0-9a-z][-0-9a-z]{0,62}(?<!-)\\. )+ [a-z]+ )
        # port (0-65535)
        (?: :(\\d{1,5}) )?
    )
    # path-abempty
    ( (?: / (?: [-.~\\w!$&'()*+,;=:@] | %[0-9a-f]{2} )* )* )
    (?:
        \\?
        # query
        ( (?: [-.~\\w!$&'()*+,;=:@/] | %[0-9a-f]{2} )* )
    )?
    (?:
        \\#
        # fragment
        ( (?: [-.~\\w!$&'()*+,;=:@/] | %[0-9a-f]{2} )* )
    )?"

url <- "http://user:password@example.com:8080/path/to/file?date=1342460570#fragment"
print(strapply(url, regex, c, perl = TRUE, backref = 8, simplify = c))

これを実行すると次のような結果になります。

[1] "http://user:password@example.com:8080/path/to/file?date=1342460570#fragment"
[2] "http"                                                                       
[3] "user:password@example.com:8080"                                             
[4] "user:password"                                                              
[5] "example.com"                                                                
[6] "8080"                                                                       
[7] "/path/to/file"                                                              
[8] "date=1342460570"                                                            
[9] "fragment"                                                                   

上から順に、マッチした文字列、scheme、authority、userinfo、host、port、path-abempty、query、fragment になってますよね!

ネタなのでテストとか書いてませんし、パフォーマンスも考慮してないですし、誤りもあるかもしれませんがご了承ください・・・。
誤りは指摘していただけると嬉しいです!

  1. 余談ですが、file scheme は authority が空なので、file:///path となります 

  2. Perl だと文字列内で変数展開もできるのでもっと楽に書けますが・・・