diff --git a/src/PlaywrightNative.Tests/Assets/urlpattern/LICENSE.md b/src/PlaywrightNative.Tests/Assets/urlpattern/LICENSE.md new file mode 100644 index 00000000..39c46d03 --- /dev/null +++ b/src/PlaywrightNative.Tests/Assets/urlpattern/LICENSE.md @@ -0,0 +1,11 @@ +# The 3-Clause BSD License + +Copyright © web-platform-tests contributors + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/PlaywrightNative.Tests/Assets/urlpattern/urlpatterntestdata.json b/src/PlaywrightNative.Tests/Assets/urlpattern/urlpatterntestdata.json new file mode 100644 index 00000000..272b51ef --- /dev/null +++ b/src/PlaywrightNative.Tests/Assets/urlpattern/urlpatterntestdata.json @@ -0,0 +1,3181 @@ +[ + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/ba" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/bar/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [ "https://example.com/foo/bar" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": { "0": "example.com" } }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": { "0": "https" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [ "https://example.com/foo/bar/baz" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "hostname": "example.com", "pathname": "/foo/bar" }], + "expected_match": { + "hostname": { "input": "example.com", "groups": { "0": "example.com" } }, + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "hostname": "example.com", "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/bar", "baseURL": "https://example.com" }], + "expected_match": { + "hostname": { "input": "example.com", "groups": { "0": "example.com" } }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": { "0": "https" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/bar/baz", + "baseURL": "https://example.com" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "hostname": "example.com", "pathname": "/foo/bar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar", "search": "otherquery", + "hash": "otherhash" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hash": { "input": "otherhash", "groups": { "0": "otherhash" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "otherquery", "groups": { "0": "otherquery" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar", "search": "otherquery", + "hash": "otherhash" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hash": { "input": "otherhash", "groups": { "0": "otherhash" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "otherquery", "groups": { "0": "otherquery" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?otherquery#otherhash" }], + "inputs": [{ "protocol": "https", "hostname": "example.com", + "pathname": "/foo/bar", "search": "otherquery", + "hash": "otherhash" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hash": { "input": "otherhash", "groups": { "0": "otherhash" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "otherquery", "groups": { "0": "otherquery" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "https://example.com/foo/bar" ], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "https://example.com/foo/bar?otherquery#otherhash" ], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hash": { "input": "otherhash", "groups": { "0": "otherhash" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "otherquery", "groups": { "0": "otherquery" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "https://example.com/foo/bar?query#hash" ], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hash": { "input": "hash", "groups": { "0": "hash" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "query", "groups": { "0": "query" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "https://example.com/foo/bar/baz" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "https://other.com/foo/bar" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [ "http://other.com/foo/bar" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar", "baseURL": "https://example.com" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar/baz", + "baseURL": "https://example.com" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar", "baseURL": "https://other.com" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar", + "baseURL": "https://example.com?query#hash" }], + "inputs": [{ "pathname": "/foo/bar", "baseURL": "http://example.com" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "bar": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/([^\\/]+?)" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar" }], + "inputs": [{ "pathname": "/foo/index.html" }], + "expected_match": { + "pathname": { "input": "/foo/index.html", "groups": { "bar": "index.html" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar" }], + "inputs": [{ "pathname": "/foo/bar/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_obj": { + "pathname": "/foo/*" + }, + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_obj": { + "pathname": "/foo/*" + }, + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_obj": { + "pathname": "/foo/*" + }, + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)" }], + "inputs": [{ "pathname": "/foo" }], + "expected_obj": { + "pathname": "/foo/*" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar(.*)" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "bar": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar(.*)" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "bar": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar(.*)" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "bar": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar(.*)" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar?" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "bar": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar?" }], + "inputs": [{ "pathname": "/foo" }], + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "bar": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar?" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar?" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar?" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar+" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "bar": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar+" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "bar": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar+" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar+" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar+" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar*" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "bar": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar*" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "bar": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar*" }], + "inputs": [{ "pathname": "/foo" }], + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "bar": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/:bar*" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/:bar*" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/foo" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "0": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/foo" }], + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "0": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)?" }], + "inputs": [{ "pathname": "/fo" }], + "expected_obj": { + "pathname": "/foo/*?" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*?" }], + "inputs": [{ "pathname": "/fo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/foo" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)+" }], + "inputs": [{ "pathname": "/fo" }], + "expected_obj": { + "pathname": "/foo/*+" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/*+" }], + "inputs": [{ "pathname": "/fo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": { "0": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": { + "pathname": { "input": "/foo/bar/baz", "groups": { "0": "bar/baz" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/foo" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "0": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/foo" }], + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "/foo", "groups": { "0": null } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": { + "pathname": { "input": "/foo/", "groups": { "0": "" } } + } + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/foobar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/(.*)*" }], + "inputs": [{ "pathname": "/fo" }], + "expected_obj": { + "pathname": "/foo/**" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/**" }], + "inputs": [{ "pathname": "/fo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}" }], + "inputs": [{ "pathname": "/foo" }], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}?" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}?" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}?" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}?" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}+" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}+" }], + "inputs": [{ "pathname": "/foo/bar/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}+" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}+" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}+" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}*" }], + "inputs": [{ "pathname": "/foo/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}*" }], + "inputs": [{ "pathname": "/foo/bar/bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar/bar", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}*" }], + "inputs": [{ "pathname": "/foo/bar/baz" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo{/bar}*" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/foo{/bar}*" }], + "inputs": [{ "pathname": "/foo/" }], + "expected_match": null + }, + { + "pattern": [{ "protocol": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "username": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "password": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "pathname": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "search": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hash": "(café)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "protocol": ":café" }], + "inputs": [{ "protocol": "foo" }], + "expected_match": { + "protocol": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "username": ":café" }], + "inputs": [{ "username": "foo" }], + "expected_match": { + "username": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "password": ":café" }], + "inputs": [{ "password": "foo" }], + "expected_match": { + "password": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "hostname": ":café" }], + "inputs": [{ "hostname": "foo" }], + "expected_match": { + "hostname": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "pathname": "/:café" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "search": ":café" }], + "inputs": [{ "search": "foo" }], + "expected_match": { + "search": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "hash": ":café" }], + "inputs": [{ "hash": "foo" }], + "expected_match": { + "hash": { "input": "foo", "groups": { "café": "foo" } } + } + }, + { + "pattern": [{ "protocol": ":\u2118" }], + "inputs": [{ "protocol": "foo" }], + "expected_match": { + "protocol": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "username": ":\u2118" }], + "inputs": [{ "username": "foo" }], + "expected_match": { + "username": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "password": ":\u2118" }], + "inputs": [{ "password": "foo" }], + "expected_match": { + "password": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "hostname": ":\u2118" }], + "inputs": [{ "hostname": "foo" }], + "expected_match": { + "hostname": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "pathname": "/:\u2118" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "search": ":\u2118" }], + "inputs": [{ "search": "foo" }], + "expected_match": { + "search": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "hash": ":\u2118" }], + "inputs": [{ "hash": "foo" }], + "expected_match": { + "hash": { "input": "foo", "groups": { "\u2118": "foo" } } + } + }, + { + "pattern": [{ "protocol": ":\u3400" }], + "inputs": [{ "protocol": "foo" }], + "expected_match": { + "protocol": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "username": ":\u3400" }], + "inputs": [{ "username": "foo" }], + "expected_match": { + "username": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "password": ":\u3400" }], + "inputs": [{ "password": "foo" }], + "expected_match": { + "password": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "hostname": ":\u3400" }], + "inputs": [{ "hostname": "foo" }], + "expected_match": { + "hostname": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "pathname": "/:\u3400" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "search": ":\u3400" }], + "inputs": [{ "search": "foo" }], + "expected_match": { + "search": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "hash": ":\u3400" }], + "inputs": [{ "hash": "foo" }], + "expected_match": { + "hash": { "input": "foo", "groups": { "\u3400": "foo" } } + } + }, + { + "pattern": [{ "protocol": "(.*)" }], + "inputs": [{ "protocol" : "café" }], + "expected_obj": { + "protocol": "*" + }, + "expected_match": null + }, + { + "pattern": [{ "protocol": "(.*)" }], + "inputs": [{ "protocol": "cafe" }], + "expected_obj": { + "protocol": "*" + }, + "expected_match": { + "protocol": { "input": "cafe", "groups": { "0": "cafe" }} + } + }, + { + "pattern": [{ "protocol": "foo-bar" }], + "inputs": [{ "protocol": "foo-bar" }], + "expected_match": { + "protocol": { "input": "foo-bar", "groups": {} } + } + }, + { + "pattern": [{ "username": "caf%C3%A9" }], + "inputs": [{ "username" : "café" }], + "expected_match": { + "username": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "username": "café" }], + "inputs": [{ "username" : "café" }], + "expected_obj": { + "username": "caf%C3%A9" + }, + "expected_match": { + "username": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "username": "caf%c3%a9" }], + "inputs": [{ "username" : "café" }], + "expected_match": null + }, + { + "pattern": [{ "password": "caf%C3%A9" }], + "inputs": [{ "password" : "café" }], + "expected_match": { + "password": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "password": "café" }], + "inputs": [{ "password" : "café" }], + "expected_obj": { + "password": "caf%C3%A9" + }, + "expected_match": { + "password": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "password": "caf%c3%a9" }], + "inputs": [{ "password" : "café" }], + "expected_match": null + }, + { + "pattern": [{ "hostname": "xn--caf-dma.com" }], + "inputs": [{ "hostname" : "café.com" }], + "expected_match": { + "hostname": { "input": "xn--caf-dma.com", "groups": {}} + } + }, + { + "pattern": [{ "hostname": "café.com" }], + "inputs": [{ "hostname" : "café.com" }], + "expected_obj": { + "hostname": "xn--caf-dma.com" + }, + "expected_match": { + "hostname": { "input": "xn--caf-dma.com", "groups": {}} + } + }, + { + "pattern": ["http://\uD83D\uDEB2.com/"], + "inputs": ["http://\uD83D\uDEB2.com/"], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http", + "hostname": "xn--h78h.com", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {}}, + "hostname": { "input": "xn--h78h.com", "groups": {}}, + "pathname": { "input": "/", "groups": {}} + } + }, + { + "pattern": ["http://\uD83D \uDEB2"], + "expected_obj": "error" + }, + { + "pattern": [{"hostname":"\uD83D \uDEB2"}], + "expected_obj": "error" + }, + { + "pattern": [{"pathname":"\uD83D \uDEB2"}], + "inputs": [], + "expected_obj": { + "pathname": "%EF%BF%BD%20%EF%BF%BD" + }, + "expected_match": null + }, + { + "pattern": [{"pathname":":\uD83D \uDEB2"}], + "expected_obj": "error" + }, + { + "pattern": [{"pathname":":a\uDB40\uDD00b"}], + "inputs": [], + "expected_obj": { + "pathname": ":a\uDB40\uDD00b" + }, + "expected_match": null + }, + { + "pattern": [{"pathname":"test/:a\uD801\uDC50b"}], + "inputs": [{"pathname":"test/foo"}], + "expected_obj": { + "pathname": "test/:a\uD801\uDC50b" + }, + "expected_match": { + "pathname": { "input": "test/foo", "groups": { "a\uD801\uDC50b": "foo" }} + } + }, + { + "pattern": [{"pathname":":\uD83D\uDEB2"}], + "expected_obj": "error" + }, + { + "pattern": [{ "port": "" }], + "inputs": [{ "protocol": "http", "port": "80" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "protocol": { "input": "http", "groups": { "0": "http" }} + } + }, + { + "pattern": [{ "protocol": "http", "port": "80" }], + "inputs": [{ "protocol": "http", "port": "80" }], + "exactly_empty_components": [ "port" ], + "expected_match": { + "protocol": { "input": "http", "groups": {}} + } + }, + { + "pattern": [{ "protocol": "http", "port": "80{20}?" }], + "inputs": [{ "protocol": "http", "port": "80" }], + "expected_match": null + }, + { + "pattern": [{ "protocol": "http", "port": "80 " }], + "inputs": [{ "protocol": "http", "port": "80" }], + "expected_obj": { + "protocol": "http", + "port": "80" + }, + "expected_match": null + }, + { + "pattern": [{ "protocol": "http", "port": "100000" }], + "inputs": [{ "protocol": "http", "port": "100000" }], + "expected_obj": "error" + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "protocol": "http", "port": "80" }], + "expected_match": null + }, + { + "pattern": [{ "protocol": "http{s}?", "port": "80" }], + "inputs": [{ "protocol": "http", "port": "80" }], + "expected_match": null + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "port": "80" }], + "expected_match": { + "port": { "input": "80", "groups": {}} + } + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "port": "8\t0" }], + "expected_match": { + "port": { "input": "80", "groups": {}} + } + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "port": "80x" }], + "expected_match": { + "port": { "input": "80", "groups": {}} + } + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "port": "80?x" }], + "expected_match": { + "port": { "input": "80", "groups": {}} + } + }, + { + "pattern": [{ "port": "80" }], + "inputs": [{ "port": "80\\x" }], + "expected_match": { + "port": { "input": "80", "groups": {}} + } + }, + { + "pattern": [{ "port": "(.*)" }], + "inputs": [{ "port": "invalid80" }], + "expected_obj": { + "port": "*" + }, + "expected_match": null + }, + { + "pattern": [{ "protocol": "https", "port": "443*" }], + "inputs": [{ "protocol": "https", "port": "4430" }], + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "port": { "input": "4430", "groups": { "0": "0" }} + } + }, + { + "pattern": [{ "protocol": "https", "port": "*443" }], + "inputs": [{ "protocol": "https", "port": "1443" }], + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "port": { "input": "1443", "groups": { "0": "1" }} + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "/foo/./bar" }], + "expected_match": { + "pathname": { "input": "/foo/bar", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo/baz" }], + "inputs": [{ "pathname": "/foo/bar/../baz" }], + "expected_match": { + "pathname": { "input": "/foo/baz", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/caf%C3%A9" }], + "inputs": [{ "pathname": "/café" }], + "expected_match": { + "pathname": { "input": "/caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/café" }], + "inputs": [{ "pathname": "/café" }], + "expected_obj": { + "pathname": "/caf%C3%A9" + }, + "expected_match": { + "pathname": { "input": "/caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/caf%c3%a9" }], + "inputs": [{ "pathname": "/café" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "foo/bar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [{ "pathname": "foo/bar", "baseURL": "https://example.com" }], + "expected_match": { + "protocol": { "input": "https", "groups": { "0": "https" }}, + "hostname": { "input": "example.com", "groups": { "0": "example.com" }}, + "pathname": { "input": "/foo/bar", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo/../bar" }], + "inputs": [{ "pathname": "/bar" }], + "expected_obj": { + "pathname": "/bar" + }, + "expected_match": { + "pathname": { "input": "/bar", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "./foo/bar", "baseURL": "https://example.com" }], + "inputs": [{ "pathname": "foo/bar", "baseURL": "https://example.com" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {}}, + "hostname": { "input": "example.com", "groups": {}}, + "pathname": { "input": "/foo/bar", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "", "baseURL": "https://example.com" }], + "inputs": [{ "pathname": "/", "baseURL": "https://example.com" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {}}, + "hostname": { "input": "example.com", "groups": {}}, + "pathname": { "input": "/", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "{/bar}", "baseURL": "https://example.com/foo/" }], + "inputs": [{ "pathname": "./bar", "baseURL": "https://example.com/foo/" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/bar" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "\\/bar", "baseURL": "https://example.com/foo/" }], + "inputs": [{ "pathname": "./bar", "baseURL": "https://example.com/foo/" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/bar" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "b", "baseURL": "https://example.com/foo/" }], + "inputs": [{ "pathname": "./b", "baseURL": "https://example.com/foo/" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/foo/b" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {}}, + "hostname": { "input": "example.com", "groups": {}}, + "pathname": { "input": "/foo/b", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "foo/bar" }], + "inputs": [ "https://example.com/foo/bar" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "foo/bar", "baseURL": "https://example.com" }], + "inputs": [ "https://example.com/foo/bar" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/foo/bar" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {}}, + "hostname": { "input": "example.com", "groups": {}}, + "pathname": { "input": "/foo/bar", "groups": {}} + } + }, + { + "pattern": [{ "pathname": ":name.html", "baseURL": "https://example.com" }], + "inputs": [ "https://example.com/foo.html"] , + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/:name.html" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {}}, + "hostname": { "input": "example.com", "groups": {}}, + "pathname": { "input": "/foo.html", "groups": { "name": "foo" }} + } + }, + { + "pattern": [{ "search": "q=caf%C3%A9" }], + "inputs": [{ "search": "q=café" }], + "expected_match": { + "search": { "input": "q=caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "search": "q=café" }], + "inputs": [{ "search": "q=café" }], + "expected_obj": { + "search": "q=caf%C3%A9" + }, + "expected_match": { + "search": { "input": "q=caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "search": "q=caf%c3%a9" }], + "inputs": [{ "search": "q=café" }], + "expected_match": null + }, + { + "pattern": [{ "hash": "caf%C3%A9" }], + "inputs": [{ "hash": "café" }], + "expected_match": { + "hash": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "hash": "café" }], + "inputs": [{ "hash": "café" }], + "expected_obj": { + "hash": "caf%C3%A9" + }, + "expected_match": { + "hash": { "input": "caf%C3%A9", "groups": {}} + } + }, + { + "pattern": [{ "hash": "caf%c3%a9" }], + "inputs": [{ "hash": "café" }], + "expected_match": null + }, + { + "pattern": [{ "protocol": "about", "pathname": "(blank|sourcedoc)" }], + "inputs": [ "about:blank" ], + "expected_match": { + "protocol": { "input": "about", "groups": {}}, + "pathname": { "input": "blank", "groups": { "0": "blank" }} + } + }, + { + "pattern": [{ "protocol": "data", "pathname": ":number([0-9]+)" }], + "inputs": [ "data:8675309" ], + "expected_match": { + "protocol": { "input": "data", "groups": {}}, + "pathname": { "input": "8675309", "groups": { "number": "8675309" }} + } + }, + { + "pattern": [{ "pathname": "/(\\m)" }], + "expected_obj": "error" + }, + { + "pattern": [{ "pathname": "/foo!" }], + "inputs": [{ "pathname": "/foo!" }], + "expected_match": { + "pathname": { "input": "/foo!", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo\\:" }], + "inputs": [{ "pathname": "/foo:" }], + "expected_match": { + "pathname": { "input": "/foo:", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo\\{" }], + "inputs": [{ "pathname": "/foo{" }], + "expected_obj": { + "pathname": "/foo%7B" + }, + "expected_match": { + "pathname": { "input": "/foo%7B", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo\\(" }], + "inputs": [{ "pathname": "/foo(" }], + "expected_match": { + "pathname": { "input": "/foo(", "groups": {}} + } + }, + { + "pattern": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "inputs": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "expected_match": { + "protocol": { "input": "javascript", "groups": {}}, + "pathname": { "input": "var x = 1;", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "var x = 1;" }], + "inputs": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "expected_obj": { + "pathname": "var%20x%20=%201;" + }, + "expected_match": null + }, + { + "pattern": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "inputs": [{ "baseURL": "javascript:var x = 1;" }], + "expected_match": { + "protocol": { "input": "javascript", "groups": {}}, + "pathname": { "input": "var x = 1;", "groups": {}} + } + }, + { + "pattern": [{ "protocol": "(data|javascript)", "pathname": "var x = 1;" }], + "inputs": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "expected_match": { + "protocol": { "input": "javascript", "groups": {"0": "javascript"}}, + "pathname": { "input": "var x = 1;", "groups": {}} + } + }, + { + "pattern": [{ "protocol": "(https|javascript)", "pathname": "var x = 1;" }], + "inputs": [{ "protocol": "javascript", "pathname": "var x = 1;" }], + "expected_obj": { + "pathname": "var%20x%20=%201;" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "var x = 1;" }], + "inputs": [{ "pathname": "var x = 1;" }], + "expected_obj": { + "pathname": "var%20x%20=%201;" + }, + "expected_match": { + "pathname": { "input": "var%20x%20=%201;", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [ "./foo/bar", "https://example.com" ], + "expected_match": { + "hostname": { "input": "example.com", "groups": { "0": "example.com" } }, + "pathname": { "input": "/foo/bar", "groups": {} }, + "protocol": { "input": "https", "groups": { "0": "https" } } + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }], + "inputs": [ { "pathname": "/foo/bar" }, "https://example.com" ], + "expected_match": "error" + }, + { + "pattern": [ "https://example.com:8080/foo?bar#baz" ], + "inputs": [{ "pathname": "/foo", "search": "bar", "hash": "baz", + "baseURL": "https://example.com:8080" }], + "expected_obj": { + "protocol": "https", + "username": "*", + "password": "*", + "hostname": "example.com", + "port": "8080", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "search": { "input": "bar", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [ "/foo?bar#baz", "https://example.com:8080" ], + "inputs": [{ "pathname": "/foo", "search": "bar", "hash": "baz", + "baseURL": "https://example.com:8080" }], + "expected_obj": { + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "search": { "input": "bar", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [ "/foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "example.com/foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "http{s}?://{*.}?example.com/:product/:endpoint" ], + "inputs": [ "https://sub.example.com/foo/bar" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http{s}?", + "hostname": "{*.}?example.com", + "pathname": "/:product/:endpoint" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "sub.example.com", "groups": { "0": "sub" } }, + "pathname": { "input": "/foo/bar", "groups": { "product": "foo", + "endpoint": "bar" } } + } + }, + { + "pattern": [ "https://example.com?foo" ], + "inputs": [ "https://example.com/?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com#foo" ], + "inputs": [ "https://example.com/#foo" ], + "exactly_empty_components": [ "port", "search" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/", + "hash": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "hash": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com:8080?foo" ], + "inputs": [ "https://example.com:8080/?foo" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "port": "8080", + "pathname": "/", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com:8080#foo" ], + "inputs": [ "https://example.com:8080/#foo" ], + "exactly_empty_components": [ "search" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "port": "8080", + "pathname": "/", + "hash": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "hash": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/?foo" ], + "inputs": [ "https://example.com/?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/#foo" ], + "inputs": [ "https://example.com/#foo" ], + "exactly_empty_components": [ "port", "search" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/", + "hash": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "hash": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/*?foo" ], + "inputs": [ "https://example.com/?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/*?foo" + }, + "expected_match": null + }, + { + "pattern": [ "https://example.com/*\\?foo" ], + "inputs": [ "https://example.com/?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/*", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "" } }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/:name?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/:name?foo" + }, + "expected_match": null + }, + { + "pattern": [ "https://example.com/:name\\?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/:name", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/bar", "groups": { "name": "bar" } }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/(bar)?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/(bar)?foo" + }, + "expected_match": null + }, + { + "pattern": [ "https://example.com/(bar)\\?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/(bar)", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/bar", "groups": { "0": "bar" } }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/{bar}?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/{bar}?foo" + }, + "expected_match": null + }, + { + "pattern": [ "https://example.com/{bar}\\?foo" ], + "inputs": [ "https://example.com/bar?foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/bar", + "search": "foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/bar", "groups": {} }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "https://example.com/" ], + "inputs": [ "https://example.com:8080/" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "port": "", + "pathname": "/" + }, + "expected_match": null + }, + { + "pattern": [ "data:foobar" ], + "inputs": [ "data:foobar" ], + "expected_obj": "error" + }, + { + "pattern": [ "data\\:foobar" ], + "inputs": [ "data:foobar" ], + "exactly_empty_components": [ "hostname", "port" ], + "expected_obj": { + "protocol": "data", + "pathname": "foobar" + }, + "expected_match": { + "protocol": { "input": "data", "groups": {} }, + "pathname": { "input": "foobar", "groups": {} } + } + }, + { + "pattern": [ "https://{sub.}?example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "{sub.}?example.com", + "pathname": "/foo" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} } + } + }, + { + "pattern": [ "https://{sub.}?example{.com/}foo" ], + "inputs": [ "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "{sub.}?example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": { "0": "/foo" } } + } + }, + { + "pattern": [ "{https://}example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "https://(sub.)?example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "(sub.)?example.com", + "pathname": "/foo" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": { "0": null } }, + "pathname": { "input": "/foo", "groups": {} } + } + }, + { + "pattern": [ "https://(sub.)?example(.com/)foo" ], + "inputs": [ "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "(sub.)?example(.com/)foo", + "pathname": "*" + }, + "expected_match": null + }, + { + "pattern": [ "(https://)example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "https://{sub{.}}example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "https://(sub(?:.))?example.com/foo" ], + "inputs": [ "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "(sub(?:.))?example.com", + "pathname": "/foo" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": { "0": null } }, + "pathname": { "input": "/foo", "groups": {} } + } + }, + { + "pattern": [ "file:///foo/bar" ], + "inputs": [ "file:///foo/bar" ], + "exactly_empty_components": [ "hostname", "port" ], + "expected_obj": { + "protocol": "file", + "pathname": "/foo/bar" + }, + "expected_match": { + "protocol": { "input": "file", "groups": {} }, + "pathname": { "input": "/foo/bar", "groups": {} } + } + }, + { + "pattern": [ "data:" ], + "inputs": [ "data:" ], + "exactly_empty_components": [ "hostname", "port", "pathname" ], + "expected_obj": { + "protocol": "data" + }, + "expected_match": { + "protocol": { "input": "data", "groups": {} } + } + }, + { + "pattern": [ "foo://bar" ], + "inputs": [ "foo://bad_url_browser_interop" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "foo", + "hostname": "bar" + }, + "expected_match": null + }, + { + "pattern": [ "(café)://foo" ], + "expected_obj": "error" + }, + { + "pattern": [ "https://example.com/foo?bar#baz" ], + "inputs": [{ "protocol": "https:", + "search": "?bar", + "hash": "#baz", + "baseURL": "http://example.com/foo" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": null + }, + { + "pattern": [{ "protocol": "http{s}?:", + "search": "?bar", + "hash": "#baz" }], + "inputs": [ "http://example.com/foo?bar#baz" ], + "expected_obj": { + "protocol": "http{s}?", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "example.com", "groups": { "0": "example.com" }}, + "pathname": { "input": "/foo", "groups": { "0": "/foo" }}, + "search": { "input": "bar", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [ "?bar#baz", "https://example.com/foo" ], + "inputs": [ "?bar#baz", "https://example.com/foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "search": { "input": "bar", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [ "?bar", "https://example.com/foo#baz" ], + "inputs": [ "?bar", "https://example.com/foo#snafu" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/foo", + "search": "bar", + "hash": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "search": { "input": "bar", "groups": {} } + } + }, + { + "pattern": [ "#baz", "https://example.com/foo?bar" ], + "inputs": [ "#baz", "https://example.com/foo?bar" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "search": { "input": "bar", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [ "#baz", "https://example.com/foo" ], + "inputs": [ "#baz", "https://example.com/foo" ], + "exactly_empty_components": [ "port", "search" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/foo", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/foo", "groups": {} }, + "hash": { "input": "baz", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "*" }], + "inputs": [ "foo", "data:data-urls-cannot-be-base-urls" ], + "expected_match": null + }, + { + "pattern": [{ "pathname": "*" }], + "inputs": [ "foo", "not|a|valid|url" ], + "expected_match": null + }, + { + "pattern": [ "https://foo\\:bar@example.com" ], + "inputs": [ "https://foo:bar@example.com" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "username": "foo", + "password": "bar", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "username": { "input": "foo", "groups": {} }, + "password": { "input": "bar", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [ "https://foo@example.com" ], + "inputs": [ "https://foo@example.com" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "username": "foo", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "username": { "input": "foo", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [ "https://\\:bar@example.com" ], + "inputs": [ "https://:bar@example.com" ], + "exactly_empty_components": [ "username", "port" ], + "expected_obj": { + "protocol": "https", + "password": "bar", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "password": { "input": "bar", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [ "https://:user::pass@example.com" ], + "inputs": [ "https://foo:bar@example.com" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "username": ":user", + "password": ":pass", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "username": { "input": "foo", "groups": { "user": "foo" } }, + "password": { "input": "bar", "groups": { "pass": "bar" } }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [ "https\\:foo\\:bar@example.com" ], + "inputs": [ "https:foo:bar@example.com" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "username": "foo", + "password": "bar", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "username": { "input": "foo", "groups": {} }, + "password": { "input": "bar", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [ "data\\:foo\\:bar@example.com" ], + "inputs": [ "data:foo:bar@example.com" ], + "exactly_empty_components": [ "hostname", "port" ], + "expected_obj": { + "protocol": "data", + "pathname": "foo\\:bar@example.com" + }, + "expected_match": { + "protocol": { "input": "data", "groups": {} }, + "pathname": { "input": "foo:bar@example.com", "groups": {} } + } + }, + { + "pattern": [ "https://foo{\\:}bar@example.com" ], + "inputs": [ "https://foo:bar@example.com" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "username": "foo%3Abar", + "hostname": "example.com" + }, + "expected_match": null + }, + { + "pattern": [ "data{\\:}channel.html", "https://example.com" ], + "inputs": [ "https://example.com/data:channel.html" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "pathname": "/data\\:channel.html", + "search": "*", + "hash": "*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/data:channel.html", "groups": {} } + } + }, + { + "pattern": [ "http://[\\:\\:1]/" ], + "inputs": [ "http://[::1]/" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http", + "hostname": "[\\:\\:1]", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "[::1]", "groups": {} }, + "pathname": { "input": "/", "groups": {} } + } + }, + { + "pattern": [ "http://[\\:\\:1]:8080/" ], + "inputs": [ "http://[::1]:8080/" ], + "expected_obj": { + "protocol": "http", + "hostname": "[\\:\\:1]", + "port": "8080", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "[::1]", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/", "groups": {} } + } + }, + { + "pattern": [ "http://[\\:\\:a]/" ], + "inputs": [ "http://[::a]/" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http", + "hostname": "[\\:\\:a]", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "[::a]", "groups": {} }, + "pathname": { "input": "/", "groups": {} } + } + }, + { + "pattern": [ "http://[:address]/" ], + "inputs": [ "http://[::1]/" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http", + "hostname": "[:address]", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "[::1]", "groups": { "address": "::1" }}, + "pathname": { "input": "/", "groups": {} } + } + }, + { + "pattern": [ "http://[\\:\\:AB\\::num]/" ], + "inputs": [ "http://[::ab:1]/" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "protocol": "http", + "hostname": "[\\:\\:ab\\::num]", + "pathname": "/" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "[::ab:1]", "groups": { "num": "1" }}, + "pathname": { "input": "/", "groups": {} } + } + }, + { + "pattern": [{ "hostname": "[\\:\\:AB\\::num]" }], + "inputs": [{ "hostname": "[::ab:1]" }], + "expected_obj": { + "hostname": "[\\:\\:ab\\::num]" + }, + "expected_match": { + "hostname": { "input": "[::ab:1]", "groups": { "num": "1" }} + } + }, + { + "pattern": [{ "hostname": "[\\:\\:xY\\::num]" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "{[\\:\\:ab\\::num]}" }], + "inputs": [{ "hostname": "[::ab:1]" }], + "expected_match": { + "hostname": { "input": "[::ab:1]", "groups": { "num": "1" }} + } + }, + { + "pattern": [{ "hostname": "{[\\:\\:fé\\::num]}" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "{[\\:\\::num\\:1]}" }], + "inputs": [{ "hostname": "[::ab:1]" }], + "expected_match": { + "hostname": { "input": "[::ab:1]", "groups": { "num": "ab" }} + } + }, + { + "pattern": [{ "hostname": "{[\\:\\::num\\:fé]}" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "[*\\:1]" }], + "inputs": [{ "hostname": "[::ab:1]" }], + "expected_match": { + "hostname": { "input": "[::ab:1]", "groups": { "0": "::ab" }} + } + }, + { + "pattern": [{ "hostname": "*\\:1]" }], + "expected_obj": "error" + }, + { + "pattern": [ "https://foo{{@}}example.com" ], + "inputs": [ "https://foo@example.com" ], + "expected_obj": "error" + }, + { + "pattern": [ "https://foo{@example.com" ], + "inputs": [ "https://foo@example.com" ], + "expected_obj": "error" + }, + { + "pattern": [ "data\\:text/javascript,let x = 100/:tens?5;" ], + "inputs": [ "data:text/javascript,let x = 100/5;" ], + "exactly_empty_components": [ "hostname", "port" ], + "expected_obj": { + "protocol": "data", + "pathname": "text/javascript,let x = 100/:tens?5;" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "protocol": { "input": "data", "groups": {} }, + "pathname": { "input": "text/javascript,let x = 100/5;", "groups": { "tens": null } } + } + }, + { + "pattern": [{ "pathname": "/:id/:id" }], + "expected_obj": "error" + }, + { + "pattern": [{ "pathname": "/foo", "baseURL": "" }], + "expected_obj": "error" + }, + { + "pattern": [ "/foo", "" ], + "expected_obj": "error" + }, + { + "pattern": [{ "pathname": "/foo" }, "https://example.com" ], + "expected_obj": "error" + }, + { + "pattern": [{ "pathname": ":name*" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "pathname": ":name+" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "pathname": ":name" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "protocol": ":name*" }], + "inputs": [{ "protocol": "foobar" }], + "expected_match": { + "protocol": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "protocol": ":name+" }], + "inputs": [{ "protocol": "foobar" }], + "expected_match": { + "protocol": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "protocol": ":name" }], + "inputs": [{ "protocol": "foobar" }], + "expected_match": { + "protocol": { "input": "foobar", "groups": { "name": "foobar" }} + } + }, + { + "pattern": [{ "hostname": "bad hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad#hostname" }], + "inputs": [{ "hostname": "bad" }], + "expected_obj": { + "hostname": "bad" + }, + "expected_match": { + "hostname": { "input": "bad", "groups": {} } + } + }, + { + "pattern": [{ "hostname": "bad%hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad/hostname" }], + "inputs": [{ "hostname": "bad" }], + "expected_obj": { + "hostname": "bad" + }, + "expected_match": { + "hostname": { "input": "bad", "groups": {} } + } + }, + { + "pattern": [{ "hostname": "bad\\:hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "badhostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad?hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad@hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad[hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad]hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad\\\\hostname" }], + "inputs": [{ "hostname": "badhostname" }], + "expected_obj": { + "hostname": "bad" + }, + "expected_match": null + }, + { + "pattern": [{ "hostname": "bad^hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad|hostname" }], + "expected_obj": "error" + }, + { + "pattern": [{ "hostname": "bad\nhostname" }], + "inputs": [{ "hostname": "badhostname" }], + "expected_obj": { + "hostname": "badhostname" + }, + "expected_match": { + "hostname": { "input": "badhostname", "groups": {} } + } + }, + { + "pattern": [{ "hostname": "bad\rhostname" }], + "inputs": [{ "hostname": "badhostname" }], + "expected_obj": { + "hostname": "badhostname" + }, + "expected_match": { + "hostname": { "input": "badhostname", "groups": {} } + } + }, + { + "pattern": [{ "hostname": "bad\thostname" }], + "inputs": [{ "hostname": "badhostname" }], + "expected_obj": { + "hostname": "badhostname" + }, + "expected_match": { + "hostname": { "input": "badhostname", "groups": {} } + } + }, + { + "pattern": [{}], + "inputs": ["https://example.com/"], + "expected_match": { + "protocol": { "input": "https", "groups": { "0": "https" }}, + "hostname": { "input": "example.com", "groups": { "0": "example.com" }}, + "pathname": { "input": "/", "groups": { "0": "/" }} + } + }, + { + "pattern": [], + "inputs": ["https://example.com/"], + "expected_match": { + "protocol": { "input": "https", "groups": { "0": "https" }}, + "hostname": { "input": "example.com", "groups": { "0": "example.com" }}, + "pathname": { "input": "/", "groups": { "0": "/" }} + } + }, + { + "pattern": [], + "inputs": [{}], + "expected_match": {} + }, + { + "pattern": [], + "inputs": [], + "expected_match": { "inputs": [{}] } + }, + { + "pattern": [{ "pathname": "(foo)(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "0": "foo", "1": "barbaz" }} + } + }, + { + "pattern": [{ "pathname": "{(foo)bar}(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "0": "foo", "1": "baz" }} + } + }, + { + "pattern": [{ "pathname": "(foo)?(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": "(foo)?*" + }, + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "0": "foo", "1": "barbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "f", "0": "oobarbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}(barbaz)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "foo", "0": "barbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}{(.*)}" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": "{:foo}(.*)" + }, + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "f", "0": "oobarbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}{(.*)bar}" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": ":foo{*bar}" + }, + "expected_match": null + }, + { + "pattern": [{ "pathname": "{:foo}{bar(.*)}" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": ":foo{bar*}" + }, + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "foo", "0": "baz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}:bar(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": ":foo:bar(.*)" + }, + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "f", "bar": "oobarbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}?(.*)" }], + "inputs": [{ "pathname": "foobarbaz" }], + "expected_obj": { + "pathname": ":foo?*" + }, + "expected_match": { + "pathname": { "input": "foobarbaz", "groups": { "foo": "f", "0": "oobarbaz" }} + } + }, + { + "pattern": [{ "pathname": "{:foo\\bar}" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": "{:foo\\.bar}" }], + "inputs": [{ "pathname": "foo.bar" }], + "expected_obj": { + "pathname": "{:foo.bar}" + }, + "expected_match": { + "pathname": { "input": "foo.bar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": "{:foo(foo)bar}" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": "{:foo}bar" }], + "inputs": [{ "pathname": "foobar" }], + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": ":foo\\bar" }], + "inputs": [{ "pathname": "foobar" }], + "expected_obj": { + "pathname": "{:foo}bar" + }, + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": ":foo{}(.*)" }], + "inputs": [{ "pathname": "foobar" }], + "expected_obj": { + "pathname": "{:foo}(.*)" + }, + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "f", "0": "oobar" }} + } + }, + { + "pattern": [{ "pathname": ":foo{}bar" }], + "inputs": [{ "pathname": "foobar" }], + "expected_obj": { + "pathname": "{:foo}bar" + }, + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": ":foo{}?bar" }], + "inputs": [{ "pathname": "foobar" }], + "expected_obj": { + "pathname": "{:foo}bar" + }, + "expected_match": { + "pathname": { "input": "foobar", "groups": { "foo": "foo" }} + } + }, + { + "pattern": [{ "pathname": "*{}**?" }], + "inputs": [{ "pathname": "foobar" }], + "expected_obj": { + "pathname": "*(.*)?" + }, + "//": "The `null` below is translated to undefined in the test harness.", + "expected_match": { + "pathname": { "input": "foobar", "groups": { "0": "foobar", "1": null }} + } + }, + { + "pattern": [{ "pathname": ":foo(baz)(.*)" }], + "inputs": [{ "pathname": "bazbar" }], + "expected_match": { + "pathname": { "input": "bazbar", "groups": { "foo": "baz", "0": "bar" }} + } + }, + { + "pattern": [{ "pathname": ":foo(baz)bar" }], + "inputs": [{ "pathname": "bazbar" }], + "expected_match": { + "pathname": { "input": "bazbar", "groups": { "foo": "baz" }} + } + }, + { + "pattern": [{ "pathname": "*/*" }], + "inputs": [{ "pathname": "foo/bar" }], + "expected_match": { + "pathname": { "input": "foo/bar", "groups": { "0": "foo", "1": "bar" }} + } + }, + { + "pattern": [{ "pathname": "*\\/*" }], + "inputs": [{ "pathname": "foo/bar" }], + "expected_obj": { + "pathname": "*/{*}" + }, + "expected_match": { + "pathname": { "input": "foo/bar", "groups": { "0": "foo", "1": "bar" }} + } + }, + { + "pattern": [{ "pathname": "*/{*}" }], + "inputs": [{ "pathname": "foo/bar" }], + "expected_match": { + "pathname": { "input": "foo/bar", "groups": { "0": "foo", "1": "bar" }} + } + }, + { + "pattern": [{ "pathname": "*//*" }], + "inputs": [{ "pathname": "foo/bar" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/:foo." }], + "inputs": [{ "pathname": "/bar." }], + "expected_match": { + "pathname": { "input": "/bar.", "groups": { "foo": "bar" } } + } + }, + { + "pattern": [{ "pathname": "/:foo.." }], + "inputs": [{ "pathname": "/bar.." }], + "expected_match": { + "pathname": { "input": "/bar..", "groups": { "foo": "bar" } } + } + }, + { + "pattern": [{ "pathname": "./foo" }], + "inputs": [{ "pathname": "./foo" }], + "expected_match": { + "pathname": { "input": "./foo", "groups": {}} + } + }, + { + "pattern": [{ "pathname": "../foo" }], + "inputs": [{ "pathname": "../foo" }], + "expected_match": { + "pathname": { "input": "../foo", "groups": {}} + } + }, + { + "pattern": [{ "pathname": ":foo./" }], + "inputs": [{ "pathname": "bar./" }], + "expected_match": { + "pathname": { "input": "bar./", "groups": { "foo": "bar" }} + } + }, + { + "pattern": [{ "pathname": ":foo../" }], + "inputs": [{ "pathname": "bar../" }], + "expected_match": { + "pathname": { "input": "bar../", "groups": { "foo": "bar" }} + } + }, + { + "pattern": [{ "pathname": "/:foo\\bar" }], + "inputs": [{ "pathname": "/bazbar" }], + "expected_obj": { + "pathname": "{/:foo}bar" + }, + "expected_match": { + "pathname": { "input": "/bazbar", "groups": { "foo": "baz" }} + } + }, + { + "pattern": [{ "pathname": "/foo/bar" }, { "ignoreCase": true }], + "inputs": [{ "pathname": "/FOO/BAR" }], + "expected_match": { + "pathname": { "input": "/FOO/BAR", "groups": {} } + } + }, + { + "pattern": [{ "ignoreCase": true }], + "inputs": [{ "pathname": "/FOO/BAR" }], + "expected_match": { + "pathname": { "input": "/FOO/BAR", "groups": { "0": "/FOO/BAR" } } + } + }, + { + "pattern": [ "https://example.com:8080/foo?bar#baz", + { "ignoreCase": true }], + "inputs": [{ "pathname": "/FOO", "search": "BAR", "hash": "BAZ", + "baseURL": "https://example.com:8080" }], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "port": "8080", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/FOO", "groups": {} }, + "search": { "input": "BAR", "groups": {} }, + "hash": { "input": "BAZ", "groups": {} } + } + }, + { + "pattern": [ "/foo?bar#baz", "https://example.com:8080", + { "ignoreCase": true }], + "inputs": [{ "pathname": "/FOO", "search": "BAR", "hash": "BAZ", + "baseURL": "https://example.com:8080" }], + "expected_obj": { + "protocol": "https", + "hostname": "example.com", + "port": "8080", + "pathname": "/foo", + "search": "bar", + "hash": "baz" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "port": { "input": "8080", "groups": {} }, + "pathname": { "input": "/FOO", "groups": {} }, + "search": { "input": "BAR", "groups": {} }, + "hash": { "input": "BAZ", "groups": {} } + } + }, + { + "pattern": [ "/foo?bar#baz", { "ignoreCase": true }, + "https://example.com:8080" ], + "inputs": [{ "pathname": "/FOO", "search": "BAR", "hash": "BAZ", + "baseURL": "https://example.com:8080" }], + "expected_obj": "error" + }, + { + "pattern": [{ "search": "foo", "baseURL": "https://example.com/a/+/b" }], + "inputs": [{ "search": "foo", "baseURL": "https://example.com/a/+/b" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "pathname": "/a/\\+/b" + }, + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/a/+/b", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [{ "hash": "foo", "baseURL": "https://example.com/?q=*&v=?&hmm={}&umm=()" }], + "inputs": [{ "hash": "foo", "baseURL": "https://example.com/?q=*&v=?&hmm={}&umm=()" }], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "search": "q=\\*&v=\\?&hmm=\\{\\}&umm=\\(\\)" + }, + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "q=*&v=?&hmm={}&umm=()", "groups": {} }, + "hash": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [ "#foo", "https://example.com/?q=*&v=?&hmm={}&umm=()" ], + "inputs": [ "https://example.com/?q=*&v=?&hmm={}&umm=()#foo" ], + "exactly_empty_components": [ "port" ], + "expected_obj": { + "search": "q=\\*&v=\\?&hmm=\\{\\}&umm=\\(\\)", + "hash": "foo" + }, + "expected_match": { + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": {} }, + "protocol": { "input": "https", "groups": {} }, + "search": { "input": "q=*&v=?&hmm={}&umm=()", "groups": {} }, + "hash": { "input": "foo", "groups": {} } + } + }, + { + "pattern": [{ "pathname": "/([[a-z]--a])" }], + "inputs": [{ "pathname": "/a" }], + "expected_match": null + }, + { + "pattern": [{ "pathname": "/([[a-z]--a])" }], + "inputs": [{ "pathname": "/z" }], + "expected_match": { + "pathname": { "input": "/z", "groups": { "0": "z" } } + } + }, + { + "pattern": [{ "pathname": "/([\\d&&[0-1]])" }], + "inputs": [{ "pathname": "/0" }], + "expected_match": { + "pathname": { "input": "/0", "groups": { "0": "0" } } + } + }, + { + "pattern": [{ "pathname": "/([\\d&&[0-1]])" }], + "inputs": [{ "pathname": "/3" }], + "expected_match": null + }, + { + "pattern": [{ "protocol": "http", "hostname": "example.com/ignoredpath" }], + "inputs": ["http://example.com/"], + "expected_obj": { + "protocol": "http", + "hostname": "example.com", + "pathname": "*" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [{ "protocol": "http", "hostname": "example.com\\?ignoredsearch" }], + "inputs": ["http://example.com/"], + "expected_obj": { + "protocol": "http", + "hostname": "example.com", + "search": "*" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": [{ "protocol": "http", "hostname": "example.com#ignoredhash" }], + "inputs": ["http://example.com/"], + "expected_obj": { + "protocol": "http", + "hostname": "example.com", + "hash": "*" + }, + "expected_match": { + "protocol": { "input": "http", "groups": {} }, + "hostname": { "input": "example.com", "groups": {} }, + "pathname": { "input": "/", "groups": { "0": "/" } } + } + }, + { + "pattern": ["https://www.example.com/*"], + "inputs": ["https://www.example.com/x"], + "exactly_empty_components": ["port"], + "expected_obj": { + "protocol": "https", + "hostname": "www.example.com", + "pathname": "/*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "www.example.com", "groups": {} }, + "pathname": { "input": "/x", "groups": { "0": "x" } } + } + }, + { + "pattern": ["https://www.example.com/*"], + "inputs": ["https://www.example.com/xyz"], + "exactly_empty_components": ["port"], + "expected_obj": { + "protocol": "https", + "hostname": "www.example.com", + "pathname": "/*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "www.example.com", "groups": {} }, + "pathname": { "input": "/xyz", "groups": { "0": "xyz" } } + } + }, + { + "pattern": ["https://www.example.com/*"], + "inputs": ["https://www.example.com/example"], + "exactly_empty_components": ["port"], + "expected_obj": { + "protocol": "https", + "hostname": "www.example.com", + "pathname": "/*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "www.example.com", "groups": {} }, + "pathname": { "input": "/example", "groups": { "0": "example" } } + } + }, + { + "pattern": ["https://www.example.com/*"], + "inputs": ["https://www.example.com/text"], + "exactly_empty_components": ["port"], + "expected_obj": { + "protocol": "https", + "hostname": "www.example.com", + "pathname": "/*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "www.example.com", "groups": {} }, + "pathname": { "input": "/text", "groups": { "0": "text" } } + } + }, + { + "pattern": ["https://www.example.com/*"], + "inputs": ["https://www.example.com/path/with/x"], + "exactly_empty_components": ["port"], + "expected_obj": { + "protocol": "https", + "hostname": "www.example.com", + "pathname": "/*" + }, + "expected_match": { + "protocol": { "input": "https", "groups": {} }, + "hostname": { "input": "www.example.com", "groups": {} }, + "pathname": { "input": "/path/with/x", "groups": { "0": "path/with/x" } } + } + }, + { + "pattern": [{ "hostname": ":domain(.*)" }], + "inputs": [{ "hostname": "localhost" }], + "expected_obj": { + "hostname": ":domain(.*)" + }, + "expected_match": { + "hostname": { "input": "localhost", "groups": { "domain" : "localhost"} } + } + }, + { + "pattern": ["((?R)):"], + "expected_obj": "error" + }, + { + "pattern": ["(\\H):"], + "expected_obj": "error" + }, + { + "pattern": [ + {"pathname": "/:foo((?a))"} + ], + "inputs": [ + {"pathname": "/a"} + ], + "expected_match": { + "pathname": { + "input": "/a", + "groups": {"foo": "a"} + } + } + }, + { + "pattern": [ + {"pathname": "/foo/(bar(?baz))"} + ], + "inputs": [ + {"pathname": "/foo/barbaz"} + ], + "expected_match": { + "pathname": { + "input": "/foo/barbaz", + "groups": {"0": "barbaz"} + } + } + }, + { + "pattern": [{ "pathname": "/:𠀀" }], + "inputs": [{ "pathname": "/foo" }], + "expected_match": { + "pathname": { "input": "/foo", "groups": { "𠀀": "foo" } } + } + } +] diff --git a/src/PlaywrightNative.Tests/URLPatternTests.cs b/src/PlaywrightNative.Tests/URLPatternTests.cs new file mode 100644 index 00000000..588526bc --- /dev/null +++ b/src/PlaywrightNative.Tests/URLPatternTests.cs @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using NUnit.Framework; + +namespace PlaywrightNative.Tests +{ + /// + /// Runs the web-platform-tests URL Pattern data (urlpattern/resources/urlpatterntestdata.json, BSD-3-Clause, + /// see Assets/urlpattern/LICENSE.md) against . It follows the logic of + /// urlpattern/resources/urlpatterntests.js. These are not upstream Playwright tests, so they use + /// and carry no PlaywrightTest attribute. No browser is needed. + /// + public class URLPatternTests + { + private static readonly string[] _components = ["protocol", "username", "password", "hostname", "port", "pathname", "search", "hash"]; + + /// + /// WPT entries that this port does not pass, by index in the data file, with the reason. + /// + private static readonly Dictionary _knownFailures = new() + { + // {"pathname": "*{}**?"}: test() matches, but exec() reports group "1" as "" instead of undefined. + // JavaScript rejects an optional group iteration that matches the empty string; .NET Regex keeps it. + [329] = "an empty optional group is captured as \"\" instead of undefined (.NET Regex semantics).", + }; + + public static IEnumerable WptEntries() + { + string path = Path.Combine(AppContext.BaseDirectory, "Assets", "urlpattern", "urlpatterntestdata.json"); + using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path)); + int index = 0; + foreach (JsonElement entry in document.RootElement.EnumerateArray()) + { + string json = entry.GetRawText(); + var data = new TestCaseData(index, json).SetName($"Wpt{index:D3}").SetDescription(json); + if (_knownFailures.TryGetValue(index, out string reason)) + { + data.Ignore("Known failure: " + reason); + } + + yield return data; + index++; + } + } + + [TestCaseSource(nameof(WptEntries))] + public void ShouldMatchWebPlatformTestsData(int index, string json) + { + using JsonDocument document = JsonDocument.Parse(json); + JsonElement entry = document.RootElement; + JsonElement[] pattern = entry.GetProperty("pattern").EnumerateArray().ToArray(); + JsonElement expectedObj = entry.TryGetProperty("expected_obj", out JsonElement obj) ? obj : default; + + Func create = CreatePattern(pattern); + if (create == null) + { + Assert.Ignore("The .NET overloads cannot express this call: " + json); + } + + if (expectedObj.ValueKind == JsonValueKind.String && Text(expectedObj) == "error") + { + Assert.Throws(() => create()); + return; + } + + URLPattern urlPattern = create(); + string[] exactlyEmpty = entry.TryGetProperty("exactly_empty_components", out JsonElement empty) + ? empty.EnumerateArray().Select(e => Text(e)).ToArray() + : []; + + foreach (string component in _components) + { + string expected = ExpectedPatternComponent(pattern, expectedObj, exactlyEmpty, component); + Assert.That(GetComponent(urlPattern, component), Is.EqualTo(expected), $"compiled pattern property '{component}'"); + } + + if (!entry.TryGetProperty("inputs", out JsonElement inputsElement)) + { + return; + } + + JsonElement[] inputs = inputsElement.EnumerateArray().ToArray(); + JsonElement expectedMatch = entry.GetProperty("expected_match"); + Func exec = CreateExec(urlPattern, inputs); + Func test = CreateTest(urlPattern, inputs); + if (exec == null) + { + Assert.Ignore("The .NET overloads cannot express this call: " + json); + } + + bool shouldMatch = expectedMatch.ValueKind == JsonValueKind.Object; + Assert.That(test(), Is.EqualTo(shouldMatch), "test() result"); + URLPatternResult result = exec(); + if (!shouldMatch) + { + Assert.That(result, Is.Null, "exec() failed match result"); + return; + } + + Assert.That(result, Is.Not.Null, "exec() result"); + foreach (string component in _components) + { + URLPatternComponentResult actual = GetComponentResult(result, component); + string expectedInput = string.Empty; + var expectedGroups = new Dictionary(); + if (expectedMatch.TryGetProperty(component, out JsonElement expectedComponent)) + { + expectedInput = Text(expectedComponent.GetProperty("input")); + foreach (JsonProperty group in expectedComponent.GetProperty("groups").EnumerateObject()) + { + expectedGroups[group.Name] = group.Value.ValueKind == JsonValueKind.Null ? null : Text(group.Value); + } + } + else if (!exactlyEmpty.Contains(component)) + { + expectedGroups["0"] = string.Empty; + } + + Assert.That(actual.Input, Is.EqualTo(expectedInput), $"exec() result for {component} input"); + Assert.That(actual.Groups, Is.EquivalentTo(expectedGroups), $"exec() result for {component} groups"); + } + } + + [TestCase("https://example.com/books/1", true)] + [TestCase("https://example.com/films/1", false)] + [TestCase("not a url", false)] + public void PredicateShouldBeStableAndMatchLikeTest(string url, bool expected) + { + var pattern = new URLPattern("https://example.com/books/:id"); + Func predicate = pattern.Predicate; + Assert.That(pattern.Predicate, Is.SameAs(predicate)); + Assert.That(predicate(url), Is.EqualTo(expected)); + } + + private static Func CreatePattern(JsonElement[] args) + { + if (args.Length == 0) + { + return () => new URLPattern(new URLPatternInit()); + } + + if (args[0].ValueKind == JsonValueKind.String) + { + string input = Text(args[0]); + return args.Length switch + { + 1 => () => new URLPattern(input), + 2 when args[1].ValueKind == JsonValueKind.String => () => new URLPattern(input, Text(args[1])), + 2 when args[1].ValueKind == JsonValueKind.Object => () => new URLPattern(input, ToOptions(args[1])), + 3 when args[1].ValueKind == JsonValueKind.String => () => new URLPattern(input, Text(args[1]), ToOptions(args[2])), + _ => null, + }; + } + + URLPatternInit init = ToInit(args[0]); + return args.Length switch + { + 1 => () => new URLPattern(init), + 2 when args[1].ValueKind == JsonValueKind.Object => () => new URLPattern(init, ToOptions(args[1])), + _ => null, + }; + } + + private static Func CreateExec(URLPattern pattern, JsonElement[] inputs) + { + if (inputs.Length == 0) + { + return () => pattern.Exec(new URLPatternInit()); + } + + if (inputs[0].ValueKind == JsonValueKind.String) + { + string input = Text(inputs[0]); + string baseURL = inputs.Length > 1 ? Text(inputs[1]) : null; + return () => pattern.Exec(input, baseURL); + } + + return inputs.Length == 1 ? () => pattern.Exec(ToInit(inputs[0])) : null; + } + + private static Func CreateTest(URLPattern pattern, JsonElement[] inputs) + { + if (inputs.Length == 0) + { + return () => pattern.Test(new URLPatternInit()); + } + + if (inputs[0].ValueKind == JsonValueKind.String) + { + string input = Text(inputs[0]); + string baseURL = inputs.Length > 1 ? Text(inputs[1]) : null; + return () => pattern.Test(input, baseURL); + } + + return inputs.Length == 1 ? () => pattern.Test(ToInit(inputs[0])) : null; + } + + private static URLPatternInit ToInit(JsonElement element) => new() + { + Protocol = Get(element, "protocol"), + Username = Get(element, "username"), + Password = Get(element, "password"), + Hostname = Get(element, "hostname"), + Port = Get(element, "port"), + Pathname = Get(element, "pathname"), + Search = Get(element, "search"), + Hash = Get(element, "hash"), + BaseURL = Get(element, "baseURL"), + }; + + /// + /// Reads a JSON string. The data has lone surrogates, which rejects, + /// so this decodes the raw literal by hand in that case. + /// + private static string Text(JsonElement element) + { + try + { + return element.GetString(); + } + catch (InvalidOperationException) + { + string raw = element.GetRawText(); + var result = new System.Text.StringBuilder(); + for (int i = 1; i < raw.Length - 1; i++) + { + if (raw[i] != '\\') + { + result.Append(raw[i]); + continue; + } + + char escape = raw[++i]; + switch (escape) + { + case 'u': + result.Append((char)Convert.ToInt32(raw.Substring(i + 1, 4), 16)); + i += 4; + break; + case 'b': + result.Append('\b'); + break; + case 'f': + result.Append('\f'); + break; + case 'n': + result.Append('\n'); + break; + case 'r': + result.Append('\r'); + break; + case 't': + result.Append('\t'); + break; + default: + result.Append(escape); + break; + } + } + + return result.ToString(); + } + } + + private static URLPatternOptions ToOptions(JsonElement element) + => new() { IgnoreCase = element.TryGetProperty("ignoreCase", out JsonElement value) && value.GetBoolean() }; + + private static string Get(JsonElement element, string name) + => element.TryGetProperty(name, out JsonElement value) ? Text(value) : null; + + /// + /// Computes the expected pattern string the way urlpatterntests.js does. + /// + private static string ExpectedPatternComponent(JsonElement[] pattern, JsonElement expectedObj, string[] exactlyEmpty, string component) + { + if (expectedObj.ValueKind == JsonValueKind.Object && expectedObj.TryGetProperty(component, out JsonElement explicitValue)) + { + return Text(explicitValue); + } + + if (exactlyEmpty.Contains(component)) + { + return string.Empty; + } + + bool initPattern = pattern.Length > 0 && pattern[0].ValueKind == JsonValueKind.Object; + if (initPattern && pattern[0].TryGetProperty(component, out JsonElement given) && Text(given).Length > 0) + { + return Text(given); + } + + string[] earlier = component switch + { + "hostname" => ["protocol"], + "port" => ["protocol", "hostname"], + "pathname" => ["protocol", "hostname", "port"], + "search" => ["protocol", "hostname", "port", "pathname"], + "hash" => ["protocol", "hostname", "port", "pathname", "search"], + _ => [], + }; + if (initPattern && earlier.Any(c => pattern[0].TryGetProperty(c, out _))) + { + return "*"; + } + + string baseURL = null; + if (initPattern && pattern[0].TryGetProperty("baseURL", out JsonElement initBase)) + { + baseURL = Text(initBase); + } + else if (pattern.Length > 1 && pattern[1].ValueKind == JsonValueKind.String) + { + baseURL = Text(pattern[1]); + } + + if (baseURL != null && component != "username" && component != "password") + { + UrlRecord url = UrlParser.Parse(baseURL); + return component switch + { + "protocol" => url.Scheme, + "hostname" => url.Host ?? string.Empty, + "port" => url.Port?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty, + "pathname" => url.SerializePath(), + "search" => url.Query ?? string.Empty, + _ => url.Fragment ?? string.Empty, + }; + } + + return "*"; + } + + private static string GetComponent(URLPattern pattern, string component) => component switch + { + "protocol" => pattern.Protocol, + "username" => pattern.Username, + "password" => pattern.Password, + "hostname" => pattern.Hostname, + "port" => pattern.Port, + "pathname" => pattern.Pathname, + "search" => pattern.Search, + _ => pattern.Hash, + }; + + private static URLPatternComponentResult GetComponentResult(URLPatternResult result, string component) => component switch + { + "protocol" => result.Protocol, + "username" => result.Username, + "password" => result.Password, + "hostname" => result.Hostname, + "port" => result.Port, + "pathname" => result.Pathname, + "search" => result.Search, + _ => result.Hash, + }; + } +} diff --git a/src/PlaywrightNative/URLPattern/ConstructorStringParser.cs b/src/PlaywrightNative/URLPattern/ConstructorStringParser.cs new file mode 100644 index 00000000..cd359095 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/ConstructorStringParser.cs @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System.Collections.Generic; + +namespace PlaywrightNative +{ + /// + /// Splits a URL pattern string such as "https://*.example.com/:id" into components + /// (https://urlpattern.spec.whatwg.org/#constructor-string-parsing). + /// + internal sealed class ConstructorStringParser + { + private readonly string _input; + private readonly List _tokens; + private readonly URLPatternFields _result = new(); + private int _componentStart; + private int _tokenIndex; + private int _tokenIncrement = 1; + private int _groupDepth; + private int _hostnameIPv6BracketDepth; + private bool _protocolMatchesSpecialScheme; + private State _state = State.Init; + + private ConstructorStringParser(string input) + { + _input = input; + _tokens = PatternTokenizer.Tokenize(input, lenient: true); + } + + private enum State + { + Init, + Protocol, + Authority, + Username, + Password, + Hostname, + Port, + Pathname, + Search, + Hash, + Done, + } + + public static URLPatternFields Parse(string input) + { + var parser = new ConstructorStringParser(input); + parser.Run(); + return parser._result; + } + + private void Run() + { + while (_tokenIndex < _tokens.Count) + { + _tokenIncrement = 1; + if (_tokens[_tokenIndex].Type == PatternTokenType.End) + { + if (_state == State.Init) + { + // No protocol terminator, so this is a relative pattern. + Rewind(); + if (IsHashPrefix()) + { + ChangeState(State.Hash, 1); + } + else if (IsSearchPrefix()) + { + ChangeState(State.Search, 1); + } + else + { + ChangeState(State.Pathname, 0); + } + + _tokenIndex += _tokenIncrement; + continue; + } + + if (_state == State.Authority) + { + // No "@", so there is no username or password. + RewindAndSetState(State.Hostname); + _tokenIndex += _tokenIncrement; + continue; + } + + ChangeState(State.Done, 0); + break; + } + + if (_tokens[_tokenIndex].Type == PatternTokenType.Open) + { + _groupDepth++; + _tokenIndex += _tokenIncrement; + continue; + } + + if (_groupDepth > 0) + { + if (_tokens[_tokenIndex].Type == PatternTokenType.Close) + { + _groupDepth--; + } + else + { + _tokenIndex += _tokenIncrement; + continue; + } + } + + RunState(); + _tokenIndex += _tokenIncrement; + } + + if (_result.Hostname != null && _result.Port == null) + { + _result.Port = string.Empty; + } + } + + private void RunState() + { + switch (_state) + { + case State.Init: + if (IsNonSpecialPatternChar(_tokenIndex, ":")) + { + RewindAndSetState(State.Protocol); + } + + break; + case State.Protocol: + if (IsNonSpecialPatternChar(_tokenIndex, ":")) + { + ComputeProtocolMatchesSpecialSchemeFlag(); + State nextState = State.Pathname; + int skip = 1; + if (IsNonSpecialPatternChar(_tokenIndex + 1, "/") && IsNonSpecialPatternChar(_tokenIndex + 2, "/")) + { + nextState = State.Authority; + skip = 3; + } + else if (_protocolMatchesSpecialScheme) + { + nextState = State.Authority; + } + + ChangeState(nextState, skip); + } + + break; + case State.Authority: + if (IsNonSpecialPatternChar(_tokenIndex, "@")) + { + RewindAndSetState(State.Username); + } + else if (IsNonSpecialPatternChar(_tokenIndex, "/") || IsSearchPrefix() || IsHashPrefix()) + { + RewindAndSetState(State.Hostname); + } + + break; + case State.Username: + if (IsNonSpecialPatternChar(_tokenIndex, ":")) + { + ChangeState(State.Password, 1); + } + else if (IsNonSpecialPatternChar(_tokenIndex, "@")) + { + ChangeState(State.Hostname, 1); + } + + break; + case State.Password: + if (IsNonSpecialPatternChar(_tokenIndex, "@")) + { + ChangeState(State.Hostname, 1); + } + + break; + case State.Hostname: + if (IsNonSpecialPatternChar(_tokenIndex, "[")) + { + _hostnameIPv6BracketDepth++; + } + else if (IsNonSpecialPatternChar(_tokenIndex, "]")) + { + _hostnameIPv6BracketDepth--; + } + else if (IsNonSpecialPatternChar(_tokenIndex, ":") && _hostnameIPv6BracketDepth == 0) + { + ChangeState(State.Port, 1); + } + else + { + ChangeStateAtPathnameSearchOrHash(); + } + + break; + case State.Port: + ChangeStateAtPathnameSearchOrHash(); + break; + case State.Pathname: + if (IsSearchPrefix()) + { + ChangeState(State.Search, 1); + } + else if (IsHashPrefix()) + { + ChangeState(State.Hash, 1); + } + + break; + case State.Search: + if (IsHashPrefix()) + { + ChangeState(State.Hash, 1); + } + + break; + } + } + + private void ChangeStateAtPathnameSearchOrHash() + { + if (IsNonSpecialPatternChar(_tokenIndex, "/")) + { + ChangeState(State.Pathname, 0); + } + else if (IsSearchPrefix()) + { + ChangeState(State.Search, 1); + } + else if (IsHashPrefix()) + { + ChangeState(State.Hash, 1); + } + } + + private void ChangeState(State newState, int skip) + { + if (_state is not (State.Init or State.Authority or State.Done)) + { + SetComponent(_state, MakeComponentString()); + } + + if (_state != State.Init && newState != State.Done) + { + bool beforeHostname = _state is State.Protocol or State.Authority or State.Username or State.Password; + if (beforeHostname && newState is (State.Port or State.Pathname or State.Search or State.Hash) && _result.Hostname == null) + { + _result.Hostname = string.Empty; + } + + bool beforePathname = beforeHostname || _state is State.Hostname or State.Port; + if (beforePathname && newState is (State.Search or State.Hash) && _result.Pathname == null) + { + _result.Pathname = _protocolMatchesSpecialScheme ? "/" : string.Empty; + } + + bool beforeSearch = beforePathname || _state == State.Pathname; + if (beforeSearch && newState == State.Hash && _result.Search == null) + { + _result.Search = string.Empty; + } + } + + _state = newState; + _tokenIndex += skip; + _componentStart = _tokenIndex; + _tokenIncrement = 0; + } + + private void SetComponent(State state, string value) + { + switch (state) + { + case State.Protocol: + _result.Protocol = value; + break; + case State.Username: + _result.Username = value; + break; + case State.Password: + _result.Password = value; + break; + case State.Hostname: + _result.Hostname = value; + break; + case State.Port: + _result.Port = value; + break; + case State.Pathname: + _result.Pathname = value; + break; + case State.Search: + _result.Search = value; + break; + case State.Hash: + _result.Hash = value; + break; + } + } + + private void Rewind() + { + _tokenIndex = _componentStart; + _tokenIncrement = 0; + } + + private void RewindAndSetState(State state) + { + Rewind(); + _state = state; + } + + private PatternToken GetSafeToken(int index) => index < _tokens.Count ? _tokens[index] : _tokens[^1]; + + private bool IsNonSpecialPatternChar(int index, string value) + { + PatternToken token = GetSafeToken(index); + return token.Value == value && token.Type is (PatternTokenType.Char or PatternTokenType.EscapedChar or PatternTokenType.InvalidChar); + } + + private bool IsSearchPrefix() + { + if (IsNonSpecialPatternChar(_tokenIndex, "?")) + { + return true; + } + + if (_tokens[_tokenIndex].Value != "?") + { + return false; + } + + if (_tokenIndex - 1 < 0) + { + return true; + } + + PatternToken previous = GetSafeToken(_tokenIndex - 1); + return previous.Type is not (PatternTokenType.Name or PatternTokenType.Regexp or PatternTokenType.Close or PatternTokenType.Asterisk); + } + + private bool IsHashPrefix() => IsNonSpecialPatternChar(_tokenIndex, "#"); + + private string MakeComponentString() + { + PatternToken token = _tokens[_tokenIndex]; + int componentStartIndex = GetSafeToken(_componentStart).Index; + return _input[componentStartIndex..token.Index]; + } + + private void ComputeProtocolMatchesSpecialSchemeFlag() + { + URLPatternComponent protocol = URLPatternComponent.Compile(MakeComponentString(), URLPatternCanonicalizer.Protocol, PatternOptions.Default); + _protocolMatchesSpecialScheme = URLPattern.ProtocolComponentMatchesSpecialScheme(protocol); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/JsRegExpTranslator.cs b/src/PlaywrightNative/URLPattern/JsRegExpTranslator.cs new file mode 100644 index 00000000..e3b70da1 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/JsRegExpTranslator.cs @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace PlaywrightNative +{ + /// + /// Translates a JavaScript regular expression, compiled with the "v" flag as URL patterns require, + /// into a .NET pattern with the same meaning. It keeps every capture group in the same order, + /// so group numbers match JavaScript. It throws for syntax that + /// the "v" flag rejects but .NET would accept, and for the few features it does not support + /// (class string disjunctions, astral code points in classes, Unicode properties other than general categories). + /// + internal sealed class JsRegExpTranslator + { + private const string Digit = "0-9"; + private const string NotDigit = @"\u0000-\u002F\u003A-\uFFFF"; + private const string Word = "0-9A-Za-z_"; + private const string NotWord = @"\u0000-\u002F\u003A-\u0040\u005B-\u005E\u0060\u007B-\uFFFF"; + private const string AnyChar = @"[\s\S]"; + + private static readonly Regex _quantifier = new(@"\G\{[0-9]+(,[0-9]*)?\}", RegexOptions.CultureInvariant); + + private static readonly HashSet _generalCategories = new(StringComparer.Ordinal) + { + "L", "Lu", "Ll", "Lt", "Lm", "Lo", "M", "Mn", "Mc", "Me", "N", "Nd", "Nl", "No", + "P", "Pc", "Pd", "Ps", "Pe", "Pi", "Pf", "Po", "S", "Sm", "Sc", "Sk", "So", + "Z", "Zs", "Zl", "Zp", "C", "Cc", "Cf", "Cs", "Co", "Cn", + }; + + private readonly string _source; + private readonly int _groupOffset; + private readonly Dictionary _groupNames = new(StringComparer.Ordinal); + private int _captureCount; + private int _pos; + + private JsRegExpTranslator(string source, int groupOffset) + { + _source = source; + _groupOffset = groupOffset; + } + + private bool AtEnd => _pos >= _source.Length; + + /// + /// Translates . is the number of capture groups + /// that come before it in the full expression; named backreferences use it to compute group numbers. + /// + public static string Translate(string source, int groupOffset, out int captureCount) + { + var translator = new JsRegExpTranslator(source, groupOffset); + translator.CollectGroups(); + string result = translator.TranslateAlternatives(); + captureCount = translator._captureCount; + return result; + } + + private static ArgumentException Error(string message) => new($"Invalid regular expression: {message}"); + + private static string Hex4(int codePoint) => @"\u" + codePoint.ToString("X4", CultureInfo.InvariantCulture); + + private static bool IsClassSetSyntaxCharacter(char c) => c is '(' or ')' or '[' or ']' or '{' or '}' or '/' or '-' or '\\' or '|'; + + private static bool IsClassSetReservedDoublePunctuator(char c) => "&!#$%*+,.:;<=>?@^`~".Contains(c, StringComparison.Ordinal); + + private char Peek(int offset = 0) => _pos + offset < _source.Length ? _source[_pos + offset] : '\0'; + + private void CollectGroups() + { + int classDepth = 0; + for (int i = 0; i < _source.Length; i++) + { + char c = _source[i]; + if (c == '\\') + { + i++; + } + else if (classDepth > 0) + { + classDepth += c == '[' ? 1 : (c == ']' ? -1 : 0); + } + else if (c == '[') + { + classDepth = 1; + } + else if (c == '(') + { + if (i + 1 < _source.Length && _source[i + 1] == '?') + { + if (i + 3 < _source.Length && _source[i + 2] == '<' && _source[i + 3] != '=' && _source[i + 3] != '!') + { + _captureCount++; + int end = _source.IndexOf('>', i + 3); + string name = end < 0 ? string.Empty : _source[(i + 3)..end]; + if (name.Length == 0 || !_groupNames.TryAdd(name, _captureCount)) + { + throw Error($"invalid or duplicate group name in '{_source}'."); + } + } + } + else + { + _captureCount++; + } + } + } + } + + private string TranslateAlternatives() + { + var result = new StringBuilder(); + while (!AtEnd) + { + char c = _source[_pos]; + switch (c) + { + case '\\': + result.Append(TranslateEscape(inClass: false).Pattern); + break; + case '[': + _pos++; + result.Append(ParseClass().Matcher); + break; + case '(': + result.Append(TranslateGroupOpening()); + break; + case '.': + _pos++; + result.Append(@"[^\n\r\u2028\u2029]"); + break; + case '$': + _pos++; + result.Append(@"\z"); + break; + case '{': + Match quantifier = _quantifier.Match(_source, _pos); + if (!quantifier.Success) + { + throw Error($"lone quantifier bracket in '{_source}'."); + } + + _pos += quantifier.Length; + result.Append(quantifier.Value); + break; + case '}': + case ']': + throw Error($"lone '{c}' in '{_source}'."); + default: + _pos++; + result.Append(c); + break; + } + } + + return result.ToString(); + } + + private string TranslateGroupOpening() + { + _pos++; + if (Peek() != '?') + { + return "("; + } + + char kind = Peek(1); + if (kind is ':' or '=' or '!') + { + _pos += 2; + return "(?" + kind; + } + + if (kind == '<' && Peek(2) is '=' or '!') + { + char lookbehind = Peek(2); + _pos += 3; + return "(?<" + lookbehind; + } + + if (kind == '<') + { + // Named groups become plain groups. .NET numbers named groups after unnamed ones, + // which would break the positional group numbers URL patterns rely on. + _pos = _source.IndexOf('>', _pos) + 1; + return "("; + } + + // Modifiers such as (?i:...) or (?-i:...). + Match modifiers = Regex.Match(_source[(_pos + 1)..], @"^[ims]*(-[ims]*)?:", RegexOptions.CultureInvariant); + if (modifiers.Success && modifiers.Length > 1) + { + _pos += 1 + modifiers.Length; + return "(?" + modifiers.Value; + } + + throw Error($"invalid group in '{_source}'."); + } + + /// + /// Translates an escape. Returns a stand-alone pattern, the content to use inside a .NET class, + /// and the code point when the escape stands for a single character (or -1). + /// + private (string Pattern, string ClassContent, int CodePoint) TranslateEscape(bool inClass) + { + _pos++; + if (AtEnd) + { + throw Error($"'\\' at end of '{_source}'."); + } + + char e = _source[_pos++]; + switch (e) + { + case 'd': + return ("[" + Digit + "]", Digit, -1); + case 'D': + return ("[^" + Digit + "]", NotDigit, -1); + case 'w': + return ("[" + Word + "]", Word, -1); + case 'W': + return ("[^" + Word + "]", NotWord, -1); + case 's': + return (@"\s", @"\s", -1); + case 'S': + return (@"\S", @"\S", -1); + case 'b' when !inClass: + return (@"\b", null, -1); + case 'B' when !inClass: + return (@"\B", null, -1); + case 'b': + return Single(0x08); + case 'f': + return Single(0x0C); + case 'n': + return Single(0x0A); + case 'r': + return Single(0x0D); + case 't': + return Single(0x09); + case 'v': + return Single(0x0B); + case 'c' when UrlCodePoints.IsAsciiAlpha(Peek()): + return Single(_source[_pos++] % 32); + case '0' when !UrlCodePoints.IsAsciiDigit(Peek()): + return Single(0); + case >= '1' and <= '9' when !inClass: + int start = _pos - 1; + while (UrlCodePoints.IsAsciiDigit(Peek())) + { + _pos++; + } + + return ("(?:\\" + _source[start.._pos] + ")", null, -1); + case 'k' when !inClass && Peek() == '<': + int end = _source.IndexOf('>', _pos); + string name = end < 0 ? string.Empty : _source[(_pos + 1)..end]; + if (!_groupNames.TryGetValue(name, out int local)) + { + throw Error($"unknown group name '{name}' in '{_source}'."); + } + + _pos = end + 1; + return ("(?:\\" + (_groupOffset + local).ToString(CultureInfo.InvariantCulture) + ")", null, -1); + case 'x' when UrlCodePoints.IsAsciiHexDigit(Peek()) && UrlCodePoints.IsAsciiHexDigit(Peek(1)): + _pos += 2; + return Single(int.Parse(_source.AsSpan(_pos - 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + case 'u': + return Single(ParseUnicodeEscape()); + case 'p': + case 'P': + string property = ParseUnicodeProperty(); + string escape = "\\" + e + "{" + property + "}"; + return (escape, escape, -1); + case '-' when inClass: + return Single('-'); + case '^' or '$' or '\\' or '.' or '*' or '+' or '?' or '(' or ')' or '[' or ']' or '{' or '}' or '|' or '/': + return Single(e); + case '&' or '!' or '#' or '%' or ',' or ':' or ';' or '<' or '=' or '>' or '@' or '`' or '~' when inClass: + return Single(e); + default: + throw Error($"invalid escape '\\{e}' in '{_source}'."); + } + + (string Pattern, string ClassContent, int CodePoint) Single(int codePoint) + { + if (codePoint > 0xFFFF) + { + if (inClass) + { + throw Error($"code points above U+FFFF are not supported in classes: '{_source}'."); + } + + string pair = char.ConvertFromUtf32(codePoint); + return ("(?:" + Hex4(pair[0]) + Hex4(pair[1]) + ")", null, codePoint); + } + + return (Hex4(codePoint), Hex4(codePoint), codePoint); + } + } + + private int ParseUnicodeEscape() + { + if (Peek() == '{') + { + int end = _source.IndexOf('}', _pos); + string digits = end < 0 ? string.Empty : _source[(_pos + 1)..end]; + if (digits.Length == 0 || !digits.All(c => UrlCodePoints.IsAsciiHexDigit(c)) + || !int.TryParse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int codePoint) || codePoint > 0x10FFFF) + { + throw Error($"invalid unicode escape in '{_source}'."); + } + + _pos = end + 1; + return codePoint; + } + + int value = ParseHex4(_pos); + _pos += 4; + if (char.IsHighSurrogate((char)value) && Peek() == '\\' && Peek(1) == 'u' && TryParseHex4(_pos + 2, out int low) && char.IsLowSurrogate((char)low)) + { + _pos += 6; + return char.ConvertToUtf32((char)value, (char)low); + } + + return value; + } + + private int ParseHex4(int index) + => TryParseHex4(index, out int value) ? value : throw Error($"invalid unicode escape in '{_source}'."); + + private bool TryParseHex4(int index, out int value) + { + value = 0; + if (index + 4 > _source.Length) + { + return false; + } + + for (int i = index; i < index + 4; i++) + { + if (!UrlCodePoints.IsAsciiHexDigit(_source[i])) + { + return false; + } + + value = (value * 16) + UrlCodePoints.HexValue(_source[i]); + } + + return true; + } + + private string ParseUnicodeProperty() + { + int end = Peek() == '{' ? _source.IndexOf('}', _pos) : -1; + if (end < 0) + { + throw Error($"invalid property escape in '{_source}'."); + } + + string property = _source[(_pos + 1)..end]; + _pos = end + 1; + int equals = property.IndexOf('=', StringComparison.Ordinal); + if (equals >= 0 && property[..equals] is "gc" or "General_Category") + { + property = property[(equals + 1)..]; + } + + return _generalCategories.Contains(property) + ? property + : throw Error($"unsupported Unicode property '{property}' in '{_source}'."); + } + + /// + /// Parses a "v" flag class after its opening bracket. Returns a pattern that consumes one character. + /// + private ClassOperand ParseClass() + { + bool negated = Peek() == '^'; + if (negated) + { + _pos++; + } + + var operands = new List(); + string setOperator = null; + bool lastWasOperand = false; + while (true) + { + if (AtEnd) + { + throw Error($"unterminated class in '{_source}'."); + } + + if (Peek() == ']') + { + _pos++; + break; + } + + if ((Peek() == '&' && Peek(1) == '&') || (Peek() == '-' && Peek(1) == '-')) + { + string op = _source.Substring(_pos, 2); + if (!lastWasOperand || (setOperator ?? op) != op || (setOperator == null && operands.Count > 1)) + { + throw Error($"invalid set operation in '{_source}'."); + } + + setOperator = op; + lastWasOperand = false; + _pos += 2; + continue; + } + + if (setOperator != null && lastWasOperand) + { + throw Error($"mixed set operations in '{_source}'."); + } + + operands.Add(ParseClassOperand(allowRange: setOperator == null)); + lastWasOperand = true; + } + + if (setOperator != null && !lastWasOperand) + { + throw Error($"invalid set operation in '{_source}'."); + } + + string matcher; + if (setOperator == null && operands.TrueForAll(operand => operand.ClassContent != null)) + { + string content = string.Concat(operands.Select(operand => operand.ClassContent)); + if (content.Length == 0) + { + return new ClassOperand(negated ? AnyChar : "(?!)", null); + } + + return new ClassOperand("[" + (negated ? "^" : string.Empty) + content + "]", null); + } + + if (setOperator == null) + { + matcher = "(?:" + string.Join('|', operands.Select(operand => operand.Matcher)) + ")"; + } + else if (setOperator == "&&") + { + matcher = string.Concat(operands.Take(operands.Count - 1).Select(operand => "(?=" + operand.Matcher + ")")) + operands[^1].Matcher; + } + else + { + matcher = string.Concat(operands.Skip(1).Select(operand => "(?!" + operand.Matcher + ")")) + operands[0].Matcher; + } + + return new ClassOperand(negated ? "(?!" + matcher + ")" + AnyChar : matcher, null); + } + + private ClassOperand ParseClassOperand(bool allowRange) + { + if (Peek() == '[') + { + _pos++; + return ParseClass(); + } + + int from = ParseClassCharacter(out ClassOperand escapeClass); + if (escapeClass != null) + { + return escapeClass; + } + + if (allowRange && Peek() == '-' && Peek(1) != '-' && Peek(1) != ']') + { + _pos++; + int to = ParseClassCharacter(out ClassOperand toClass); + if (toClass != null || to < from) + { + throw Error($"invalid class range in '{_source}'."); + } + + string range = Hex4(from) + "-" + Hex4(to); + return new ClassOperand("[" + range + "]", range); + } + + return new ClassOperand("[" + Hex4(from) + "]", Hex4(from)); + } + + /// + /// Parses one class character. When the next item is a class escape such as \d, returns -1 and sets . + /// + private int ParseClassCharacter(out ClassOperand escapeClass) + { + escapeClass = null; + char c = Peek(); + if (c == '\\') + { + if (Peek(1) == 'q') + { + throw Error($"class string disjunctions are not supported: '{_source}'."); + } + + (string pattern, string classContent, int codePoint) = TranslateEscape(inClass: true); + if (codePoint < 0) + { + escapeClass = new ClassOperand(pattern, classContent); + } + + return codePoint; + } + + if (IsClassSetSyntaxCharacter(c) || (IsClassSetReservedDoublePunctuator(c) && Peek(1) == c)) + { + throw Error($"'{c}' must be escaped in a class: '{_source}'."); + } + + _pos++; + return c; + } + + /// + /// One operand of a class: a stand-alone .NET pattern that consumes one character, and, + /// when it can be written inside a plain .NET class, the content to use there. + /// + private sealed record ClassOperand(string Matcher, string ClassContent); + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternOptions.cs b/src/PlaywrightNative/URLPattern/PatternOptions.cs new file mode 100644 index 00000000..49de7799 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternOptions.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// Pattern string options (https://urlpattern.spec.whatwg.org/#options). + /// + internal sealed record PatternOptions(string DelimiterCodePoint, string PrefixCodePoint, bool IgnoreCase = false) + { + public static PatternOptions Default { get; } = new(string.Empty, string.Empty); + + public static PatternOptions Hostname { get; } = new(".", string.Empty); + + public static PatternOptions Pathname { get; } = new("/", "/"); + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternParser.cs b/src/PlaywrightNative/URLPattern/PatternParser.cs new file mode 100644 index 00000000..cf8bd160 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternParser.cs @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace PlaywrightNative +{ + /// + /// The pattern string parser (https://urlpattern.spec.whatwg.org/#parsing-pattern-strings). + /// + internal sealed class PatternParser + { + public const string FullWildcardRegexpValue = ".*"; + + private readonly List _tokens; + private readonly Func _encodingCallback; + private readonly string _segmentWildcardRegexp; + private readonly List _parts = []; + private string _pendingFixedValue = string.Empty; + private int _index; + private int _nextNumericName; + + private PatternParser(string input, PatternOptions options, Func encodingCallback) + { + _tokens = PatternTokenizer.Tokenize(input, lenient: false); + _encodingCallback = encodingCallback; + _segmentWildcardRegexp = GenerateSegmentWildcardRegexp(options); + } + + /// + /// Parses a pattern string into a part list. Throws when the pattern is not well formed. + /// + public static List Parse(string input, PatternOptions options, Func encodingCallback) + { + var parser = new PatternParser(input, options, encodingCallback); + parser.Run(options); + return parser._parts; + } + + public static string GenerateSegmentWildcardRegexp(PatternOptions options) + => "[^" + EscapeRegexpString(options.DelimiterCodePoint) + "]+?"; + + public static string EscapeRegexpString(string input) + { + var result = new System.Text.StringBuilder(input.Length); + foreach (char c in input) + { + if (c is '.' or '+' or '*' or '?' or '^' or '$' or '{' or '}' or '(' or ')' or '[' or ']' or '|' or '/' or '\\') + { + result.Append('\\'); + } + + result.Append(c); + } + + return result.ToString(); + } + + public static string EscapePatternString(string input) + { + var result = new System.Text.StringBuilder(input.Length); + foreach (char c in input) + { + if (c is '+' or '*' or '?' or ':' or '{' or '}' or '(' or ')' or '\\') + { + result.Append('\\'); + } + + result.Append(c); + } + + return result.ToString(); + } + + public static string ModifierToString(PatternPartModifier modifier) => modifier switch + { + PatternPartModifier.ZeroOrMore => "*", + PatternPartModifier.Optional => "?", + PatternPartModifier.OneOrMore => "+", + _ => string.Empty, + }; + + private void Run(PatternOptions options) + { + while (_index < _tokens.Count) + { + PatternToken charToken = TryConsumeToken(PatternTokenType.Char); + PatternToken nameToken = TryConsumeToken(PatternTokenType.Name); + PatternToken regexpOrWildcardToken = TryConsumeRegexpOrWildcardToken(nameToken); + if (nameToken != null || regexpOrWildcardToken != null) + { + string prefix = charToken?.Value ?? string.Empty; + if (prefix.Length > 0 && prefix != options.PrefixCodePoint) + { + _pendingFixedValue += prefix; + prefix = string.Empty; + } + + MaybeAddPartFromPendingFixedValue(); + PatternToken modifierToken = TryConsumeModifierToken(); + AddPart(prefix, nameToken, regexpOrWildcardToken, string.Empty, modifierToken); + continue; + } + + PatternToken fixedToken = charToken ?? TryConsumeToken(PatternTokenType.EscapedChar); + if (fixedToken != null) + { + _pendingFixedValue += fixedToken.Value; + continue; + } + + PatternToken openToken = TryConsumeToken(PatternTokenType.Open); + if (openToken != null) + { + string prefix = ConsumeText(); + nameToken = TryConsumeToken(PatternTokenType.Name); + regexpOrWildcardToken = TryConsumeRegexpOrWildcardToken(nameToken); + string suffix = ConsumeText(); + ConsumeRequiredToken(PatternTokenType.Close); + PatternToken modifierToken = TryConsumeModifierToken(); + AddPart(prefix, nameToken, regexpOrWildcardToken, suffix, modifierToken); + continue; + } + + MaybeAddPartFromPendingFixedValue(); + ConsumeRequiredToken(PatternTokenType.End); + } + } + + private PatternToken TryConsumeToken(PatternTokenType type) + { + PatternToken next = _tokens[_index]; + if (next.Type != type) + { + return null; + } + + _index++; + return next; + } + + private PatternToken TryConsumeModifierToken() + => TryConsumeToken(PatternTokenType.OtherModifier) ?? TryConsumeToken(PatternTokenType.Asterisk); + + private PatternToken TryConsumeRegexpOrWildcardToken(PatternToken nameToken) + { + PatternToken token = TryConsumeToken(PatternTokenType.Regexp); + if (nameToken == null && token == null) + { + token = TryConsumeToken(PatternTokenType.Asterisk); + } + + return token; + } + + private void ConsumeRequiredToken(PatternTokenType type) + { + if (TryConsumeToken(type) == null) + { + throw new ArgumentException($"Invalid pattern: unexpected '{_tokens[_index].Value}' at index {_tokens[_index].Index}."); + } + } + + private string ConsumeText() + { + string result = string.Empty; + while (true) + { + PatternToken token = TryConsumeToken(PatternTokenType.Char) ?? TryConsumeToken(PatternTokenType.EscapedChar); + if (token == null) + { + return result; + } + + result += token.Value; + } + } + + private void MaybeAddPartFromPendingFixedValue() + { + if (_pendingFixedValue.Length == 0) + { + return; + } + + string encodedValue = _encodingCallback(_pendingFixedValue); + _pendingFixedValue = string.Empty; + _parts.Add(new PatternPart(PatternPartType.FixedText, encodedValue, PatternPartModifier.None)); + } + + private void AddPart(string prefix, PatternToken nameToken, PatternToken regexpOrWildcardToken, string suffix, PatternToken modifierToken) + { + PatternPartModifier modifier = modifierToken?.Value switch + { + "?" => PatternPartModifier.Optional, + "*" => PatternPartModifier.ZeroOrMore, + "+" => PatternPartModifier.OneOrMore, + _ => PatternPartModifier.None, + }; + + if (nameToken == null && regexpOrWildcardToken == null && modifier == PatternPartModifier.None) + { + _pendingFixedValue += prefix; + return; + } + + MaybeAddPartFromPendingFixedValue(); + if (nameToken == null && regexpOrWildcardToken == null) + { + if (prefix.Length == 0) + { + return; + } + + _parts.Add(new PatternPart(PatternPartType.FixedText, _encodingCallback(prefix), modifier)); + return; + } + + string regexpValue = regexpOrWildcardToken == null + ? _segmentWildcardRegexp + : (regexpOrWildcardToken.Type == PatternTokenType.Asterisk ? FullWildcardRegexpValue : regexpOrWildcardToken.Value); + + PatternPartType type = PatternPartType.Regexp; + if (regexpValue == _segmentWildcardRegexp) + { + type = PatternPartType.SegmentWildcard; + regexpValue = string.Empty; + } + else if (regexpValue == FullWildcardRegexpValue) + { + type = PatternPartType.FullWildcard; + regexpValue = string.Empty; + } + + string name = string.Empty; + if (nameToken != null) + { + name = nameToken.Value; + } + else if (regexpOrWildcardToken != null) + { + name = _nextNumericName.ToString(CultureInfo.InvariantCulture); + _nextNumericName++; + } + + if (_parts.Exists(part => part.Name == name)) + { + throw new ArgumentException($"Invalid pattern: duplicate group name '{name}'."); + } + + _parts.Add(new PatternPart(type, regexpValue, modifier, name, _encodingCallback(prefix), _encodingCallback(suffix))); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternPart.cs b/src/PlaywrightNative/URLPattern/PatternPart.cs new file mode 100644 index 00000000..843df08f --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternPart.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// One piece of a parsed pattern string. + /// + internal sealed record PatternPart(PatternPartType Type, string Value, PatternPartModifier Modifier, string Name = "", string Prefix = "", string Suffix = ""); +} diff --git a/src/PlaywrightNative/URLPattern/PatternPartModifier.cs b/src/PlaywrightNative/URLPattern/PatternPartModifier.cs new file mode 100644 index 00000000..75fd142e --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternPartModifier.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// Part modifiers (https://urlpattern.spec.whatwg.org/#part-modifier). + /// + internal enum PatternPartModifier + { + None, + Optional, + ZeroOrMore, + OneOrMore, + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternPartType.cs b/src/PlaywrightNative/URLPattern/PatternPartType.cs new file mode 100644 index 00000000..e9c80f58 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternPartType.cs @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// Part types of a parsed pattern string (https://urlpattern.spec.whatwg.org/#parts). + /// + internal enum PatternPartType + { + FixedText, + Regexp, + SegmentWildcard, + FullWildcard, + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternToken.cs b/src/PlaywrightNative/URLPattern/PatternToken.cs new file mode 100644 index 00000000..203a0e31 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternToken.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// A pattern string token. is a UTF-16 offset into the pattern string. + /// + internal sealed record PatternToken(PatternTokenType Type, int Index, string Value); +} diff --git a/src/PlaywrightNative/URLPattern/PatternTokenType.cs b/src/PlaywrightNative/URLPattern/PatternTokenType.cs new file mode 100644 index 00000000..f34643bc --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternTokenType.cs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// Token types of a pattern string (https://urlpattern.spec.whatwg.org/#tokens). + /// + internal enum PatternTokenType + { + Open, + Close, + Regexp, + Name, + Char, + EscapedChar, + OtherModifier, + Asterisk, + End, + InvalidChar, + } +} diff --git a/src/PlaywrightNative/URLPattern/PatternTokenizer.cs b/src/PlaywrightNative/URLPattern/PatternTokenizer.cs new file mode 100644 index 00000000..ca1febca --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PatternTokenizer.cs @@ -0,0 +1,300 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace PlaywrightNative +{ + /// + /// The pattern string tokenizer (https://urlpattern.spec.whatwg.org/#tokenizing). + /// It walks code points, but keeps UTF-16 offsets so substrings line up with the input. + /// + internal sealed class PatternTokenizer + { + private readonly string _input; + private readonly bool _lenient; + private readonly List _tokens = []; + private int _index; + private int _nextIndex; + private int _codePoint; + + private PatternTokenizer(string input, bool lenient) + { + _input = input; + _lenient = lenient; + } + + /// + /// Tokenizes . The strict policy throws on invalid input; + /// the lenient policy emits "invalid-char" tokens instead. + /// + public static List Tokenize(string input, bool lenient) + { + var tokenizer = new PatternTokenizer(input, lenient); + tokenizer.Run(); + return tokenizer._tokens; + } + + /// + /// Returns whether can appear in a group name (JavaScript IdentifierStart / IdentifierPart). + /// + public static bool IsValidNameCodePoint(int codePoint, bool first) + { + if (codePoint == '$' || codePoint == '_') + { + return true; + } + + if (!first && (codePoint == 0x200C || codePoint == 0x200D)) + { + return true; + } + + // Other_ID_Start and Other_ID_Continue code points from Unicode PropList.txt. + if (codePoint is 0x1885 or 0x1886 or 0x2118 or 0x212E or 0x309B or 0x309C) + { + return true; + } + + if (!first && codePoint is 0x00B7 or 0x0387 or (>= 0x1369 and <= 0x1371) or 0x19DA or 0x30FB or 0xFF65) + { + return true; + } + + // Pattern_Syntax code points that are otherwise letters. + if (codePoint is 0x2E2F) + { + return false; + } + + UnicodeCategory category = CharUnicodeInfo.GetUnicodeCategory(codePoint); + switch (category) + { + case UnicodeCategory.UppercaseLetter: + case UnicodeCategory.LowercaseLetter: + case UnicodeCategory.TitlecaseLetter: + case UnicodeCategory.ModifierLetter: + case UnicodeCategory.OtherLetter: + case UnicodeCategory.LetterNumber: + return true; + case UnicodeCategory.NonSpacingMark: + case UnicodeCategory.SpacingCombiningMark: + case UnicodeCategory.DecimalDigitNumber: + case UnicodeCategory.ConnectorPunctuation: + return !first; + default: + return false; + } + } + + private static bool IsAscii(int codePoint) => codePoint <= 0x7F; + + private void Run() + { + while (_index < _input.Length) + { + SeekAndGetNextCodePoint(_index); + switch (_codePoint) + { + case '*': + AddTokenWithDefaultPositionAndLength(PatternTokenType.Asterisk); + continue; + case '+': + case '?': + AddTokenWithDefaultPositionAndLength(PatternTokenType.OtherModifier); + continue; + case '\\': + if (_nextIndex >= _input.Length) + { + ProcessTokenizingError(_nextIndex, _index); + continue; + } + + int escapedIndex = _nextIndex; + GetNextCodePoint(); + AddTokenWithDefaultLength(PatternTokenType.EscapedChar, _nextIndex, escapedIndex); + continue; + case '{': + AddTokenWithDefaultPositionAndLength(PatternTokenType.Open); + continue; + case '}': + AddTokenWithDefaultPositionAndLength(PatternTokenType.Close); + continue; + case ':': + TokenizeName(); + continue; + case '(': + TokenizeRegexp(); + continue; + default: + AddTokenWithDefaultPositionAndLength(PatternTokenType.Char); + continue; + } + } + + AddTokenWithDefaultLength(PatternTokenType.End, _index, _index); + } + + private void TokenizeName() + { + int namePosition = _nextIndex; + int nameStart = namePosition; + while (namePosition < _input.Length) + { + SeekAndGetNextCodePoint(namePosition); + if (!IsValidNameCodePoint(_codePoint, namePosition == nameStart)) + { + break; + } + + namePosition = _nextIndex; + } + + if (namePosition <= nameStart) + { + ProcessTokenizingError(nameStart, _index); + return; + } + + AddTokenWithDefaultLength(PatternTokenType.Name, namePosition, nameStart); + } + + private void TokenizeRegexp() + { + int depth = 1; + int regexpPosition = _nextIndex; + int regexpStart = regexpPosition; + while (regexpPosition < _input.Length) + { + SeekAndGetNextCodePoint(regexpPosition); + if (!IsAscii(_codePoint) || (regexpPosition == regexpStart && _codePoint == '?')) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + if (_codePoint == '\\') + { + if (_nextIndex >= _input.Length) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + GetNextCodePoint(); + if (!IsAscii(_codePoint)) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + regexpPosition = _nextIndex; + continue; + } + + if (_codePoint == ')') + { + depth--; + if (depth == 0) + { + regexpPosition = _nextIndex; + break; + } + } + else if (_codePoint == '(') + { + depth++; + if (_nextIndex >= _input.Length) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + int temporaryPosition = _nextIndex; + GetNextCodePoint(); + if (_codePoint != '?') + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + _nextIndex = temporaryPosition; + } + + regexpPosition = _nextIndex; + } + + if (depth != 0) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + int regexpLength = regexpPosition - regexpStart - 1; + if (regexpLength == 0) + { + ProcessTokenizingError(regexpStart, _index); + return; + } + + AddToken(PatternTokenType.Regexp, regexpPosition, regexpStart, regexpLength); + } + + private void GetNextCodePoint() + { + char c = _input[_nextIndex]; + if (char.IsHighSurrogate(c) && _nextIndex + 1 < _input.Length && char.IsLowSurrogate(_input[_nextIndex + 1])) + { + _codePoint = char.ConvertToUtf32(c, _input[_nextIndex + 1]); + _nextIndex += 2; + } + else + { + _codePoint = c; + _nextIndex++; + } + } + + private void SeekAndGetNextCodePoint(int index) + { + _nextIndex = index; + GetNextCodePoint(); + } + + private void AddToken(PatternTokenType type, int nextPosition, int valuePosition, int valueLength) + { + _tokens.Add(new PatternToken(type, _index, _input.Substring(valuePosition, valueLength))); + _index = nextPosition; + } + + private void AddTokenWithDefaultLength(PatternTokenType type, int nextPosition, int valuePosition) + => AddToken(type, nextPosition, valuePosition, nextPosition - valuePosition); + + private void AddTokenWithDefaultPositionAndLength(PatternTokenType type) + => AddTokenWithDefaultLength(type, _nextIndex, _index); + + private void ProcessTokenizingError(int nextPosition, int valuePosition) + { + if (!_lenient) + { + throw new ArgumentException($"Invalid pattern '{_input}' at index {valuePosition}."); + } + + AddTokenWithDefaultLength(PatternTokenType.InvalidChar, nextPosition, valuePosition); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/PercentEncodeSet.cs b/src/PlaywrightNative/URLPattern/PercentEncodeSet.cs new file mode 100644 index 00000000..0dfeef97 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/PercentEncodeSet.cs @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// The percent-encode sets from the WHATWG URL standard (https://url.spec.whatwg.org/#percent-encoded-bytes). + /// + internal enum PercentEncodeSet + { + C0Control, + Fragment, + Query, + SpecialQuery, + Path, + Userinfo, + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPattern.cs b/src/PlaywrightNative/URLPattern/URLPattern.cs new file mode 100644 index 00000000..2daa9984 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPattern.cs @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; + +namespace PlaywrightNative +{ + /// + /// A URL pattern, as defined by the WHATWG URL Pattern standard (https://urlpattern.spec.whatwg.org/). + /// It matches URLs component by component, for example new URLPattern("https://*.example.com/books/:id"). + /// + /// + /// + /// This type gives parity with the JavaScript URLPattern that Playwright for Node.js accepts. + /// It is a PlaywrightNative extension: playwright-dotnet has no such type. If a future + /// Microsoft.Playwright release adds its own URLPattern, code that imports both namespaces + /// can pick one with a using alias, for example using URLPattern = PlaywrightNative.URLPattern;. + /// + /// + /// Custom regular expression groups use JavaScript syntax with the "v" flag. They run on .NET + /// after translation. Class string disjunctions (\q{...}), + /// and Unicode properties other than general categories, are not supported and throw. + /// + /// + public sealed class URLPattern + { + private static readonly string[] _specialSchemes = ["ftp", "file", "http", "https", "ws", "wss"]; + + private readonly URLPatternComponent _protocol; + private readonly URLPatternComponent _username; + private readonly URLPatternComponent _password; + private readonly URLPatternComponent _hostname; + private readonly URLPatternComponent _port; + private readonly URLPatternComponent _pathname; + private readonly URLPatternComponent _search; + private readonly URLPatternComponent _hash; + + /// + /// Initializes a new instance of the class from a pattern string. + /// The string must be absolute (start with a protocol). + /// + /// The pattern string, for example https://example.com/books/:id. + /// Matching options. + /// The pattern is not valid. + public URLPattern(string input, URLPatternOptions options = null) + : this(input, null, options) + { + } + + /// + /// Initializes a new instance of the class from a pattern string resolved against a base URL. + /// + /// The pattern string. It can be relative when is given. + /// The base URL, or null. + /// Matching options. + /// The pattern or the base URL is not valid. + public URLPattern(string input, string baseURL, URLPatternOptions options = null) + { + ArgumentNullException.ThrowIfNull(input); + URLPatternFields init = ConstructorStringParser.Parse(UrlCodePoints.ToUsvString(input)); + if (baseURL == null && init.Protocol == null) + { + throw new ArgumentException($"Relative URL pattern '{input}' needs a base URL.", nameof(input)); + } + + if (baseURL != null) + { + init.BaseURL = UrlCodePoints.ToUsvString(baseURL); + } + + (_protocol, _username, _password, _hostname, _port, _pathname, _search, _hash) = Create(init, options); + Predicate = url => Test(url); + } + + /// + /// Initializes a new instance of the class from separate components. + /// Components that are not given match anything, unless supplies them. + /// + /// The component patterns. + /// Matching options. + /// The pattern is not valid. + public URLPattern(URLPatternInit init, URLPatternOptions options = null) + { + ArgumentNullException.ThrowIfNull(init); + (_protocol, _username, _password, _hostname, _port, _pathname, _search, _hash) = Create(URLPatternFields.From(init), options); + Predicate = url => Test(url); + } + + /// + /// Gets the normalized protocol pattern. + /// + public string Protocol => _protocol.PatternString; + + /// + /// Gets the normalized username pattern. + /// + public string Username => _username.PatternString; + + /// + /// Gets the normalized password pattern. + /// + public string Password => _password.PatternString; + + /// + /// Gets the normalized hostname pattern. + /// + public string Hostname => _hostname.PatternString; + + /// + /// Gets the normalized port pattern. + /// + public string Port => _port.PatternString; + + /// + /// Gets the normalized pathname pattern. + /// + public string Pathname => _pathname.PatternString; + + /// + /// Gets the normalized search pattern. + /// + public string Search => _search.PatternString; + + /// + /// Gets the normalized hash pattern. + /// + public string Hash => _hash.PatternString; + + /// + /// Gets a value indicating whether any component uses a custom regular expression group. + /// + public bool HasRegExpGroups => _protocol.HasRegExpGroups || _username.HasRegExpGroups || _password.HasRegExpGroups + || _hostname.HasRegExpGroups || _port.HasRegExpGroups || _pathname.HasRegExpGroups + || _search.HasRegExpGroups || _hash.HasRegExpGroups; + + /// + /// Gets a URL predicate for this pattern. It is created once, so callers can compare it by reference. + /// A URL that cannot be parsed does not match. + /// + internal Func Predicate { get; } + + /// + /// Tests whether a URL matches this pattern. + /// + /// The URL. It can be relative when is given. + /// The base URL, or null. + /// Whether the URL matches. A URL that cannot be parsed does not match. + public bool Test(string input, string baseURL = null) => Exec(input, baseURL) != null; + + /// + /// Tests whether separate URL components match this pattern. Components that are not given are empty, + /// unless supplies them. + /// + /// The URL components. + /// Whether the components match. Components that cannot be canonicalized do not match. + public bool Test(URLPatternInit input) => Exec(input) != null; + + /// + /// Matches a URL against this pattern and returns the matched groups. + /// + /// The URL. It can be relative when is given. + /// The base URL, or null. + /// The match, or null when the URL does not match or cannot be parsed. + public URLPatternResult Exec(string input, string baseURL = null) + { + ArgumentNullException.ThrowIfNull(input); + UrlRecord baseUrl = null; + if (baseURL != null) + { + baseUrl = UrlParser.Parse(UrlCodePoints.ToUsvString(baseURL)); + if (baseUrl == null) + { + return null; + } + } + + UrlRecord url = UrlParser.Parse(UrlCodePoints.ToUsvString(input), baseUrl); + if (url == null) + { + return null; + } + + return Match( + url.Scheme, + url.Username, + url.Password, + url.Host ?? string.Empty, + url.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + url.SerializePath(), + url.Query ?? string.Empty, + url.Fragment ?? string.Empty); + } + + /// + /// Matches separate URL components against this pattern and returns the matched groups. + /// + /// The URL components. + /// The match, or null when the components do not match or cannot be canonicalized. + public URLPatternResult Exec(URLPatternInit input) + { + ArgumentNullException.ThrowIfNull(input); + URLPatternFields values; + try + { + var empty = new URLPatternFields + { + Protocol = string.Empty, + Username = string.Empty, + Password = string.Empty, + Hostname = string.Empty, + Port = string.Empty, + Pathname = string.Empty, + Search = string.Empty, + Hash = string.Empty, + }; + values = URLPatternInitProcessor.Process(URLPatternFields.From(input), isPattern: false, empty); + } + catch (ArgumentException) + { + return null; + } + + return Match(values.Protocol, values.Username, values.Password, values.Hostname, values.Port, values.Pathname, values.Search, values.Hash); + } + + /// + /// Returns whether a compiled protocol component matches any special scheme. + /// + internal static bool ProtocolComponentMatchesSpecialScheme(URLPatternComponent protocol) + => Array.Exists(_specialSchemes, scheme => protocol.RegularExpression.IsMatch(scheme)); + + private static (URLPatternComponent Protocol, URLPatternComponent Username, URLPatternComponent Password, URLPatternComponent Hostname, + URLPatternComponent Port, URLPatternComponent Pathname, URLPatternComponent Search, URLPatternComponent Hash) Create(URLPatternFields init, URLPatternOptions options) + { + URLPatternFields processed = URLPatternInitProcessor.Process(init, isPattern: true, new URLPatternFields()); + processed.Protocol ??= "*"; + processed.Username ??= "*"; + processed.Password ??= "*"; + processed.Hostname ??= "*"; + processed.Port ??= "*"; + processed.Pathname ??= "*"; + processed.Search ??= "*"; + processed.Hash ??= "*"; + if (UrlRecord.IsSpecialScheme(processed.Protocol) + && processed.Port == UrlRecord.DefaultPort(processed.Protocol)?.ToString(CultureInfo.InvariantCulture)) + { + processed.Port = string.Empty; + } + + bool ignoreCase = options?.IgnoreCase ?? false; + URLPatternComponent protocol = URLPatternComponent.Compile(processed.Protocol, URLPatternCanonicalizer.Protocol, PatternOptions.Default); + URLPatternComponent username = URLPatternComponent.Compile(processed.Username, URLPatternCanonicalizer.Username, PatternOptions.Default); + URLPatternComponent password = URLPatternComponent.Compile(processed.Password, URLPatternCanonicalizer.Password, PatternOptions.Default); + URLPatternComponent hostname = URLPatternComponent.Compile( + processed.Hostname, + IsIPv6HostnamePattern(processed.Hostname) ? URLPatternCanonicalizer.IPv6Hostname : URLPatternCanonicalizer.Hostname, + PatternOptions.Hostname); + URLPatternComponent port = URLPatternComponent.Compile(processed.Port, URLPatternCanonicalizer.Port, PatternOptions.Default); + PatternOptions compileOptions = PatternOptions.Default with { IgnoreCase = ignoreCase }; + URLPatternComponent pathname = ProtocolComponentMatchesSpecialScheme(protocol) + ? URLPatternComponent.Compile(processed.Pathname, URLPatternCanonicalizer.Pathname, PatternOptions.Pathname with { IgnoreCase = ignoreCase }) + : URLPatternComponent.Compile(processed.Pathname, URLPatternCanonicalizer.OpaquePathname, compileOptions); + URLPatternComponent search = URLPatternComponent.Compile(processed.Search, URLPatternCanonicalizer.Search, compileOptions); + URLPatternComponent hash = URLPatternComponent.Compile(processed.Hash, URLPatternCanonicalizer.Hash, compileOptions); + return (protocol, username, password, hostname, port, pathname, search, hash); + } + + private static bool IsIPv6HostnamePattern(string input) + => input.Length >= 2 && (input[0] == '[' || ((input[0] is '{' or '\\') && input[1] == '[')); + + private URLPatternResult Match(string protocol, string username, string password, string hostname, string port, string pathname, string search, string hash) + { + URLPatternComponentResult protocolResult = _protocol.Exec(protocol); + URLPatternComponentResult usernameResult = protocolResult == null ? null : _username.Exec(username); + URLPatternComponentResult passwordResult = usernameResult == null ? null : _password.Exec(password); + URLPatternComponentResult hostnameResult = passwordResult == null ? null : _hostname.Exec(hostname); + URLPatternComponentResult portResult = hostnameResult == null ? null : _port.Exec(port); + URLPatternComponentResult pathnameResult = portResult == null ? null : _pathname.Exec(pathname); + URLPatternComponentResult searchResult = pathnameResult == null ? null : _search.Exec(search); + URLPatternComponentResult hashResult = searchResult == null ? null : _hash.Exec(hash); + return hashResult == null + ? null + : new URLPatternResult(protocolResult, usernameResult, passwordResult, hostnameResult, portResult, pathnameResult, searchResult, hashResult); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternCanonicalizer.cs b/src/PlaywrightNative/URLPattern/URLPatternCanonicalizer.cs new file mode 100644 index 00000000..d9b2ffdc --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternCanonicalizer.cs @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; +using System.Text; + +namespace PlaywrightNative +{ + /// + /// The URL pattern encoding callbacks (https://urlpattern.spec.whatwg.org/#canon-encoding-callbacks). + /// Each one runs the WHATWG URL parser on a dummy URL and throws on failure. + /// + internal static class URLPatternCanonicalizer + { + public static string Protocol(string value) + { + if (value.Length == 0) + { + return value; + } + + UrlRecord url = UrlParser.Parse(value + "://dummy.invalid/"); + return url?.Scheme ?? throw Invalid("protocol", value); + } + + public static string Username(string value) => UrlCodePoints.PercentEncode(value, PercentEncodeSet.Userinfo); + + public static string Password(string value) => UrlCodePoints.PercentEncode(value, PercentEncodeSet.Userinfo); + + public static string Hostname(string value) + { + if (value.Length == 0) + { + return value; + } + + UrlRecord url = UrlRecord.CreateDummy(); + if (!UrlParser.ParseInto(value, url, UrlParserState.Hostname)) + { + throw Invalid("hostname", value); + } + + return url.Host ?? string.Empty; + } + + public static string IPv6Hostname(string value) + { + var result = new StringBuilder(value.Length); + foreach (char c in value) + { + if (!UrlCodePoints.IsAsciiHexDigit(c) && c != '[' && c != ']' && c != ':') + { + throw Invalid("hostname", value); + } + + result.Append((char)UrlCodePoints.ToAsciiLower(c)); + } + + return result.ToString(); + } + + public static string Port(string value) => Port(value, null); + + public static string Port(string portValue, string protocolValue) + { + if (portValue.Length == 0) + { + return portValue; + } + + UrlRecord url = UrlRecord.CreateDummy(); + url.Scheme = protocolValue ?? string.Empty; + if (!UrlParser.ParseInto(portValue, url, UrlParserState.Port)) + { + throw Invalid("port", portValue); + } + + return url.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + } + + public static string Pathname(string value) + { + if (value.Length == 0) + { + return value; + } + + bool leadingSlash = value[0] == '/'; + UrlRecord url = UrlRecord.CreateDummy(); + url.Path.Clear(); + UrlParser.ParseInto(leadingSlash ? value : "/-" + value, url, UrlParserState.PathStart); + string result = url.SerializePath(); + return leadingSlash ? result : result[2..]; + } + + public static string OpaquePathname(string value) + { + if (value.Length == 0) + { + return value; + } + + UrlRecord url = UrlRecord.CreateDummy(); + url.OpaquePath = string.Empty; + if (!UrlParser.ParseInto(value, url, UrlParserState.OpaquePath)) + { + throw Invalid("pathname", value); + } + + return url.SerializePath(); + } + + public static string Search(string value) + { + if (value.Length == 0) + { + return value; + } + + UrlRecord url = UrlRecord.CreateDummy(); + url.Query = string.Empty; + UrlParser.ParseInto(value, url, UrlParserState.Query); + return url.Query; + } + + public static string Hash(string value) + { + if (value.Length == 0) + { + return value; + } + + UrlRecord url = UrlRecord.CreateDummy(); + url.Fragment = string.Empty; + UrlParser.ParseInto(value, url, UrlParserState.Fragment); + return url.Fragment; + } + + private static ArgumentException Invalid(string component, string value) + => new($"Invalid {component} '{value}' in URL pattern."); + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternComponent.cs b/src/PlaywrightNative/URLPattern/URLPatternComponent.cs new file mode 100644 index 00000000..12261dfe --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternComponent.cs @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace PlaywrightNative +{ + /// + /// A compiled URL pattern component (https://urlpattern.spec.whatwg.org/#component). + /// + internal sealed class URLPatternComponent + { + private URLPatternComponent(string patternString, Regex regularExpression, List groupNameList, bool hasRegExpGroups) + { + PatternString = patternString; + RegularExpression = regularExpression; + GroupNameList = groupNameList; + HasRegExpGroups = hasRegExpGroups; + } + + public string PatternString { get; } + + public Regex RegularExpression { get; } + + public IReadOnlyList GroupNameList { get; } + + public bool HasRegExpGroups { get; } + + /// + /// Compiles a component (https://urlpattern.spec.whatwg.org/#compile-a-component). + /// + public static URLPatternComponent Compile(string input, Func encodingCallback, PatternOptions options) + { + List parts = PatternParser.Parse(input, options, encodingCallback); + (string regexpString, List nameList) = GenerateRegularExpressionAndNameList(parts, options); + RegexOptions regexOptions = RegexOptions.CultureInvariant | (options.IgnoreCase ? RegexOptions.IgnoreCase : RegexOptions.None); + Regex regex; + try + { + regex = new Regex(regexpString, regexOptions); + } + catch (ArgumentException ex) + { + throw new ArgumentException($"Invalid regular expression in URL pattern '{input}': {ex.Message}", ex); + } + + return new URLPatternComponent( + GeneratePatternString(parts, options), + regex, + nameList, + parts.Exists(part => part.Type == PatternPartType.Regexp)); + } + + /// + /// Matches and returns the group values, or null when it does not match. + /// Groups that did not participate in the match map to null. + /// + public URLPatternComponentResult Exec(string input) + { + Match match = RegularExpression.Match(input); + if (!match.Success) + { + return null; + } + + var groups = new Dictionary(GroupNameList.Count); + for (int i = 0; i < GroupNameList.Count; i++) + { + Group group = match.Groups[i + 1]; + groups[GroupNameList[i]] = group.Success ? group.Value : null; + } + + return new URLPatternComponentResult(input, groups); + } + + /// + /// Generates the regular expression and group name list (https://urlpattern.spec.whatwg.org/#generate-a-regular-expression-and-name-list). + /// User regular expressions are translated from JavaScript to .NET syntax on the way. + /// + private static (string RegexpString, List NameList) GenerateRegularExpressionAndNameList(List parts, PatternOptions options) + { + var result = new StringBuilder("^"); + var nameList = new List(); + int captureCount = 0; + foreach (PatternPart part in parts) + { + if (part.Type == PatternPartType.FixedText) + { + if (part.Modifier == PatternPartModifier.None) + { + result.Append(PatternParser.EscapeRegexpString(part.Value)); + } + else + { + result.Append("(?:").Append(PatternParser.EscapeRegexpString(part.Value)).Append(')') + .Append(PatternParser.ModifierToString(part.Modifier)); + } + + continue; + } + + nameList.Add(part.Name); + string regexpValue = part.Type switch + { + PatternPartType.SegmentWildcard => PatternParser.GenerateSegmentWildcardRegexp(options), + PatternPartType.FullWildcard => PatternParser.FullWildcardRegexpValue, + _ => part.Value, + }; + + // JavaScript numbers capture groups by the position of their opening parenthesis. + // The translator keeps every capture group, so the numbering stays the same in .NET. + captureCount++; + string translated = JsRegExpTranslator.Translate(regexpValue, captureCount, out int innerCaptures); + captureCount += innerCaptures; + string modifier = PatternParser.ModifierToString(part.Modifier); + bool repeating = part.Modifier is PatternPartModifier.ZeroOrMore or PatternPartModifier.OneOrMore; + + if (part.Prefix.Length == 0 && part.Suffix.Length == 0) + { + if (!repeating) + { + result.Append('(').Append(translated).Append(')').Append(modifier); + } + else + { + result.Append("((?:").Append(translated).Append(')').Append(modifier).Append(')'); + } + + continue; + } + + string prefix = PatternParser.EscapeRegexpString(part.Prefix); + string suffix = PatternParser.EscapeRegexpString(part.Suffix); + if (!repeating) + { + result.Append("(?:").Append(prefix).Append('(').Append(translated).Append(')').Append(suffix).Append(')').Append(modifier); + continue; + } + + string repeated = JsRegExpTranslator.Translate(regexpValue, captureCount, out innerCaptures); + captureCount += innerCaptures; + result.Append("(?:").Append(prefix).Append("((?:").Append(translated).Append(")(?:").Append(suffix).Append(prefix) + .Append("(?:").Append(repeated).Append("))*)").Append(suffix).Append(')'); + if (part.Modifier == PatternPartModifier.ZeroOrMore) + { + result.Append('?'); + } + } + + result.Append(@"\z"); + return (result.ToString(), nameList); + } + + /// + /// Generates the normalized pattern string (https://urlpattern.spec.whatwg.org/#generate-a-pattern-string). + /// + private static string GeneratePatternString(List parts, PatternOptions options) + { + var result = new StringBuilder(); + for (int index = 0; index < parts.Count; index++) + { + PatternPart part = parts[index]; + PatternPart previousPart = index > 0 ? parts[index - 1] : null; + PatternPart nextPart = index < parts.Count - 1 ? parts[index + 1] : null; + if (part.Type == PatternPartType.FixedText) + { + if (part.Modifier == PatternPartModifier.None) + { + result.Append(PatternParser.EscapePatternString(part.Value)); + } + else + { + result.Append('{').Append(PatternParser.EscapePatternString(part.Value)).Append('}') + .Append(PatternParser.ModifierToString(part.Modifier)); + } + + continue; + } + + bool customName = !UrlCodePoints.IsAsciiDigit(part.Name[0]); + bool needsGrouping = part.Suffix.Length > 0 || (part.Prefix.Length > 0 && part.Prefix != options.PrefixCodePoint); + if (!needsGrouping + && customName + && part.Type == PatternPartType.SegmentWildcard + && part.Modifier == PatternPartModifier.None + && nextPart != null + && nextPart.Prefix.Length == 0 + && nextPart.Suffix.Length == 0) + { + needsGrouping = nextPart.Type == PatternPartType.FixedText + ? nextPart.Value.Length > 0 && PatternTokenizer.IsValidNameCodePoint(char.ConvertToUtf32(nextPart.Value, 0), false) + : UrlCodePoints.IsAsciiDigit(nextPart.Name[0]); + } + + if (!needsGrouping + && part.Prefix.Length == 0 + && previousPart != null + && previousPart.Type == PatternPartType.FixedText + && options.PrefixCodePoint.Length > 0 + && previousPart.Value.EndsWith(options.PrefixCodePoint, StringComparison.Ordinal)) + { + needsGrouping = true; + } + + if (needsGrouping) + { + result.Append('{'); + } + + result.Append(PatternParser.EscapePatternString(part.Prefix)); + if (customName) + { + result.Append(':').Append(part.Name); + } + + if (part.Type == PatternPartType.Regexp) + { + result.Append('(').Append(part.Value).Append(')'); + } + else if (part.Type == PatternPartType.SegmentWildcard && !customName) + { + result.Append('(').Append(PatternParser.GenerateSegmentWildcardRegexp(options)).Append(')'); + } + else if (part.Type == PatternPartType.FullWildcard) + { + if (!customName + && (previousPart == null + || previousPart.Type == PatternPartType.FixedText + || previousPart.Modifier != PatternPartModifier.None + || needsGrouping + || part.Prefix.Length > 0)) + { + result.Append('*'); + } + else + { + result.Append('(').Append(PatternParser.FullWildcardRegexpValue).Append(')'); + } + } + + if (part.Type == PatternPartType.SegmentWildcard + && customName + && part.Suffix.Length > 0 + && PatternTokenizer.IsValidNameCodePoint(char.ConvertToUtf32(part.Suffix, 0), false)) + { + result.Append('\\'); + } + + result.Append(PatternParser.EscapePatternString(part.Suffix)); + if (needsGrouping) + { + result.Append('}'); + } + + result.Append(PatternParser.ModifierToString(part.Modifier)); + } + + return result.ToString(); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternComponentResult.cs b/src/PlaywrightNative/URLPattern/URLPatternComponentResult.cs new file mode 100644 index 00000000..58858d0a --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternComponentResult.cs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System.Collections.Generic; + +namespace PlaywrightNative +{ + /// + /// The match of one URL component. Mirrors the JavaScript URLPatternComponentResult dictionary. + /// + public sealed class URLPatternComponentResult + { + internal URLPatternComponentResult(string input, IReadOnlyDictionary groups) + { + Input = input; + Groups = groups; + } + + /// + /// Gets the canonical component value that was matched. + /// + public string Input { get; } + + /// + /// Gets the matched groups by name. Unnamed groups use their index ("0", "1", ...). + /// A group that did not take part in the match maps to null (JavaScript undefined). + /// + public IReadOnlyDictionary Groups { get; } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternFields.cs b/src/PlaywrightNative/URLPattern/URLPatternFields.cs new file mode 100644 index 00000000..0e3c634a --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternFields.cs @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// A mutable URLPatternInit dictionary. A null value means the member does not exist. + /// + internal sealed class URLPatternFields + { + public string Protocol { get; set; } + + public string Username { get; set; } + + public string Password { get; set; } + + public string Hostname { get; set; } + + public string Port { get; set; } + + public string Pathname { get; set; } + + public string Search { get; set; } + + public string Hash { get; set; } + + public string BaseURL { get; set; } + + public static URLPatternFields From(URLPatternInit init) => new() + { + Protocol = UrlCodePoints.ToUsvString(init.Protocol), + Username = UrlCodePoints.ToUsvString(init.Username), + Password = UrlCodePoints.ToUsvString(init.Password), + Hostname = UrlCodePoints.ToUsvString(init.Hostname), + Port = UrlCodePoints.ToUsvString(init.Port), + Pathname = UrlCodePoints.ToUsvString(init.Pathname), + Search = UrlCodePoints.ToUsvString(init.Search), + Hash = UrlCodePoints.ToUsvString(init.Hash), + BaseURL = UrlCodePoints.ToUsvString(init.BaseURL), + }; + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternInit.cs b/src/PlaywrightNative/URLPattern/URLPatternInit.cs new file mode 100644 index 00000000..dc0881ff --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternInit.cs @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// The separate URL components of a , or of a URL to test against one. + /// Mirrors the JavaScript URLPatternInit dictionary. A null property is absent. + /// + public sealed class URLPatternInit + { + /// + /// Gets the protocol, for example https. A trailing : is removed. + /// + public string Protocol { get; init; } + + /// + /// Gets the username. + /// + public string Username { get; init; } + + /// + /// Gets the password. + /// + public string Password { get; init; } + + /// + /// Gets the hostname, for example *.example.com. + /// + public string Hostname { get; init; } + + /// + /// Gets the port. + /// + public string Port { get; init; } + + /// + /// Gets the pathname, for example /books/:id. + /// + public string Pathname { get; init; } + + /// + /// Gets the search (query). A leading ? is removed. + /// + public string Search { get; init; } + + /// + /// Gets the hash (fragment). A leading # is removed. + /// + public string Hash { get; init; } + + /// + /// Gets the base URL. It supplies the components that are not given, and resolves a relative pathname. + /// + public string BaseURL { get; init; } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternInitProcessor.cs b/src/PlaywrightNative/URLPattern/URLPatternInitProcessor.cs new file mode 100644 index 00000000..516ddeec --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternInitProcessor.cs @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; + +namespace PlaywrightNative +{ + /// + /// Processes a URLPatternInit (https://urlpattern.spec.whatwg.org/#process-a-urlpatterninit). + /// For patterns, values pass through and the base URL is escaped. For URLs, every value is canonicalized. + /// + internal static class URLPatternInitProcessor + { + /// + /// Processes . selects the "pattern" type over the "url" type. + /// holds the starting values; its null members stay absent. + /// Throws when a value or the base URL is invalid. + /// + public static URLPatternFields Process(URLPatternFields init, bool isPattern, URLPatternFields defaults) + { + var result = new URLPatternFields + { + Protocol = defaults.Protocol, + Username = defaults.Username, + Password = defaults.Password, + Hostname = defaults.Hostname, + Port = defaults.Port, + Pathname = defaults.Pathname, + Search = defaults.Search, + Hash = defaults.Hash, + }; + + UrlRecord baseUrl = null; + if (init.BaseURL != null) + { + baseUrl = UrlParser.Parse(init.BaseURL) ?? throw new ArgumentException($"Invalid base URL '{init.BaseURL}'."); + bool hasProtocol = init.Protocol != null; + bool hasHostname = hasProtocol || init.Hostname != null; + bool hasPort = hasHostname || init.Port != null; + bool hasPathname = hasPort || init.Pathname != null; + bool hasSearch = hasPathname || init.Search != null; + bool hasUsername = hasPort || init.Username != null; + + if (!hasProtocol) + { + result.Protocol = ProcessBaseURLString(baseUrl.Scheme, isPattern); + } + + if (!isPattern && !hasUsername) + { + result.Username = ProcessBaseURLString(baseUrl.Username, isPattern); + } + + if (!isPattern && !hasUsername && init.Password == null) + { + result.Password = ProcessBaseURLString(baseUrl.Password, isPattern); + } + + if (!hasHostname) + { + result.Hostname = ProcessBaseURLString(baseUrl.Host ?? string.Empty, isPattern); + } + + if (!hasPort) + { + result.Port = baseUrl.Port?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + } + + if (!hasPathname) + { + result.Pathname = ProcessBaseURLString(baseUrl.SerializePath(), isPattern); + } + + if (!hasSearch) + { + result.Search = ProcessBaseURLString(baseUrl.Query ?? string.Empty, isPattern); + } + + if (!hasSearch && init.Hash == null) + { + result.Hash = ProcessBaseURLString(baseUrl.Fragment ?? string.Empty, isPattern); + } + } + + if (init.Protocol != null) + { + string stripped = init.Protocol.EndsWith(':') ? init.Protocol[..^1] : init.Protocol; + result.Protocol = isPattern ? stripped : URLPatternCanonicalizer.Protocol(stripped); + } + + if (init.Username != null) + { + result.Username = isPattern ? init.Username : URLPatternCanonicalizer.Username(init.Username); + } + + if (init.Password != null) + { + result.Password = isPattern ? init.Password : URLPatternCanonicalizer.Password(init.Password); + } + + if (init.Hostname != null) + { + result.Hostname = isPattern ? init.Hostname : URLPatternCanonicalizer.Hostname(init.Hostname); + } + + string resultProtocol = result.Protocol ?? string.Empty; + if (init.Port != null) + { + result.Port = isPattern ? init.Port : URLPatternCanonicalizer.Port(init.Port, resultProtocol); + } + + if (init.Pathname != null) + { + result.Pathname = init.Pathname; + if (baseUrl != null && !baseUrl.HasOpaquePath && !IsAbsolutePathname(result.Pathname, isPattern)) + { + string baseUrlPath = ProcessBaseURLString(baseUrl.SerializePath(), isPattern); + int slashIndex = baseUrlPath.LastIndexOf('/'); + if (slashIndex >= 0) + { + result.Pathname = baseUrlPath[..(slashIndex + 1)] + result.Pathname; + } + } + + result.Pathname = ProcessPathname(result.Pathname, resultProtocol, isPattern); + } + + if (init.Search != null) + { + string stripped = init.Search.StartsWith('?') ? init.Search[1..] : init.Search; + result.Search = isPattern ? stripped : URLPatternCanonicalizer.Search(stripped); + } + + if (init.Hash != null) + { + string stripped = init.Hash.StartsWith('#') ? init.Hash[1..] : init.Hash; + result.Hash = isPattern ? stripped : URLPatternCanonicalizer.Hash(stripped); + } + + return result; + } + + private static string ProcessBaseURLString(string input, bool isPattern) + => isPattern ? PatternParser.EscapePatternString(input) : input; + + private static bool IsAbsolutePathname(string input, bool isPattern) + { + if (input.Length == 0) + { + return false; + } + + if (input[0] == '/') + { + return true; + } + + if (!isPattern || input.Length < 2) + { + return false; + } + + return (input[0] == '\\' || input[0] == '{') && input[1] == '/'; + } + + private static string ProcessPathname(string pathname, string protocol, bool isPattern) + { + if (isPattern) + { + return pathname; + } + + return protocol.Length == 0 || UrlRecord.IsSpecialScheme(protocol) + ? URLPatternCanonicalizer.Pathname(pathname) + : URLPatternCanonicalizer.OpaquePathname(pathname); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternOptions.cs b/src/PlaywrightNative/URLPattern/URLPatternOptions.cs new file mode 100644 index 00000000..18de3097 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternOptions.cs @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// Options for . Mirrors the JavaScript URLPatternOptions dictionary. + /// + public sealed class URLPatternOptions + { + /// + /// Gets or sets a value indicating whether the pathname, search, and hash match without regard to case. + /// Defaults to false. + /// + public bool IgnoreCase { get; set; } + } +} diff --git a/src/PlaywrightNative/URLPattern/URLPatternResult.cs b/src/PlaywrightNative/URLPattern/URLPatternResult.cs new file mode 100644 index 00000000..d78e6c96 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/URLPatternResult.cs @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// The result of . Mirrors the JavaScript URLPatternResult + /// dictionary, without its inputs member. + /// + public sealed class URLPatternResult + { + internal URLPatternResult( + URLPatternComponentResult protocol, + URLPatternComponentResult username, + URLPatternComponentResult password, + URLPatternComponentResult hostname, + URLPatternComponentResult port, + URLPatternComponentResult pathname, + URLPatternComponentResult search, + URLPatternComponentResult hash) + { + Protocol = protocol; + Username = username; + Password = password; + Hostname = hostname; + Port = port; + Pathname = pathname; + Search = search; + Hash = hash; + } + + /// + /// Gets the protocol match. + /// + public URLPatternComponentResult Protocol { get; } + + /// + /// Gets the username match. + /// + public URLPatternComponentResult Username { get; } + + /// + /// Gets the password match. + /// + public URLPatternComponentResult Password { get; } + + /// + /// Gets the hostname match. + /// + public URLPatternComponentResult Hostname { get; } + + /// + /// Gets the port match. + /// + public URLPatternComponentResult Port { get; } + + /// + /// Gets the pathname match. + /// + public URLPatternComponentResult Pathname { get; } + + /// + /// Gets the search match. + /// + public URLPatternComponentResult Search { get; } + + /// + /// Gets the hash match. + /// + public URLPatternComponentResult Hash { get; } + } +} diff --git a/src/PlaywrightNative/URLPattern/UrlCodePoints.cs b/src/PlaywrightNative/URLPattern/UrlCodePoints.cs new file mode 100644 index 00000000..0b0bb219 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/UrlCodePoints.cs @@ -0,0 +1,232 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Collections.Generic; +using System.Text; + +namespace PlaywrightNative +{ + /// + /// Code point helpers shared by the WHATWG URL parser and the URL pattern parser. + /// + internal static class UrlCodePoints + { + /// + /// Marks the end of input in the URL parser state machine. + /// + public const int Eof = -1; + + public static bool IsAsciiDigit(int c) => c >= '0' && c <= '9'; + + public static bool IsAsciiHexDigit(int c) => IsAsciiDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + + public static bool IsAsciiAlpha(int c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + + public static bool IsAsciiAlphanumeric(int c) => IsAsciiAlpha(c) || IsAsciiDigit(c); + + public static int ToAsciiLower(int c) => c >= 'A' && c <= 'Z' ? c + 0x20 : c; + + public static string ToAsciiLower(string input) + { + var output = new StringBuilder(input.Length); + foreach (char c in input) + { + output.Append((char)ToAsciiLower(c)); + } + + return output.ToString(); + } + + /// + /// Converts a string to a USVString (lone surrogates become U+FFFD), as WebIDL does for USVString arguments. + /// + public static string ToUsvString(string input) + { + if (input == null) + { + return null; + } + + StringBuilder builder = null; + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + bool lone = false; + if (char.IsHighSurrogate(c)) + { + if (i + 1 < input.Length && char.IsLowSurrogate(input[i + 1])) + { + builder?.Append(c).Append(input[i + 1]); + i++; + continue; + } + + lone = true; + } + else if (char.IsLowSurrogate(c)) + { + lone = true; + } + + if (lone && builder == null) + { + builder = new StringBuilder(input.Length); + builder.Append(input, 0, i); + } + + builder?.Append(lone ? '\uFFFD' : c); + } + + return builder?.ToString() ?? input; + } + + public static int[] ToCodePoints(string input) + { + var result = new List(input.Length); + for (int i = 0; i < input.Length; i++) + { + if (char.IsHighSurrogate(input[i]) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1])) + { + result.Add(char.ConvertToUtf32(input[i], input[i + 1])); + i++; + } + else + { + result.Add(input[i]); + } + } + + return result.ToArray(); + } + + public static void AppendCodePoint(StringBuilder output, int codePoint) + { + if (codePoint > 0xFFFF) + { + output.Append(char.ConvertFromUtf32(codePoint)); + } + else + { + output.Append((char)codePoint); + } + } + + public static int CodePointLength(string input) + { + int length = 0; + for (int i = 0; i < input.Length; i++) + { + if (char.IsHighSurrogate(input[i]) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1])) + { + i++; + } + + length++; + } + + return length; + } + + public static bool IsAscii(string input) + { + foreach (char c in input) + { + if (c > 0x7F) + { + return false; + } + } + + return true; + } + + public static bool InSet(int c, PercentEncodeSet set) + { + if (c < 0x20 || c > 0x7E) + { + return true; + } + + return set switch + { + PercentEncodeSet.C0Control => false, + PercentEncodeSet.Fragment => c is ' ' or '"' or '<' or '>' or '`', + PercentEncodeSet.Query => IsQuery(c), + PercentEncodeSet.SpecialQuery => IsQuery(c) || c == '\'', + PercentEncodeSet.Path => IsPath(c), + PercentEncodeSet.Userinfo => IsPath(c) || c is '/' or ':' or ';' or '=' or '@' or '[' or '\\' or ']' or '|', + _ => throw new ArgumentOutOfRangeException(nameof(set)), + }; + } + + /// + /// UTF-8 percent-encodes a single code point using and appends it to . + /// + public static void AppendPercentEncoded(StringBuilder output, int codePoint, PercentEncodeSet set) + { + if (!InSet(codePoint, set)) + { + output.Append((char)codePoint); + return; + } + + foreach (byte b in Encoding.UTF8.GetBytes(char.ConvertFromUtf32(codePoint))) + { + output.Append('%').Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture)); + } + } + + public static string PercentEncode(string input, PercentEncodeSet set) + { + var output = new StringBuilder(input.Length); + foreach (int c in ToCodePoints(input)) + { + AppendPercentEncoded(output, c, set); + } + + return output.ToString(); + } + + /// + /// Percent-decodes the UTF-8 bytes of and decodes the result as UTF-8. + /// + public static string PercentDecode(string input) + { + byte[] bytes = Encoding.UTF8.GetBytes(input); + var output = new List(bytes.Length); + for (int i = 0; i < bytes.Length; i++) + { + if (bytes[i] == '%' && i + 2 < bytes.Length && IsAsciiHexDigit(bytes[i + 1]) && IsAsciiHexDigit(bytes[i + 2])) + { + output.Add((byte)((HexValue(bytes[i + 1]) << 4) | HexValue(bytes[i + 2]))); + i += 2; + } + else + { + output.Add(bytes[i]); + } + } + + return Encoding.UTF8.GetString(output.ToArray()); + } + + public static int HexValue(int c) => c <= '9' ? c - '0' : ToAsciiLower(c) - 'a' + 10; + + private static bool IsQuery(int c) => c is ' ' or '"' or '#' or '<' or '>'; + + private static bool IsPath(int c) => IsQuery(c) || c is '?' or '^' or '`' or '{' or '}'; + } +} diff --git a/src/PlaywrightNative/URLPattern/UrlHostParser.cs b/src/PlaywrightNative/URLPattern/UrlHostParser.cs new file mode 100644 index 00000000..00e5d7c1 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/UrlHostParser.cs @@ -0,0 +1,453 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; +using System.Text; + +namespace PlaywrightNative +{ + /// + /// The WHATWG host parser and serializer (https://url.spec.whatwg.org/#host-parsing). + /// Hosts are kept in their serialized form. + /// + internal static class UrlHostParser + { + private static readonly IdnMapping _idn = new() { AllowUnassigned = true, UseStd3AsciiRules = false }; + + /// + /// Parses as a host and returns its serialization, or null on failure. + /// + public static string Parse(string input, bool isOpaque) + { + if (input.StartsWith('[')) + { + if (!input.EndsWith(']')) + { + return null; + } + + ushort[] address = ParseIPv6(UrlCodePoints.ToCodePoints(input[1..^1])); + return address == null ? null : "[" + SerializeIPv6(address) + "]"; + } + + if (isOpaque) + { + return ParseOpaqueHost(input); + } + + string domain = UrlCodePoints.PercentDecode(input); + string asciiDomain = DomainToAscii(domain); + if (asciiDomain == null) + { + return null; + } + + if (EndsInNumber(asciiDomain)) + { + return ParseIPv4(asciiDomain) is uint ipv4 ? SerializeIPv4(ipv4) : null; + } + + return asciiDomain; + } + + public static bool IsForbiddenHostCodePoint(int c) + => c is 0 or '\t' or '\n' or '\r' or ' ' or '#' or '/' or ':' or '<' or '>' or '?' or '@' or '[' or '\\' or ']' or '^' or '|'; + + private static bool IsForbiddenDomainCodePoint(int c) + => IsForbiddenHostCodePoint(c) || c <= 0x1F || c == '%' || c == 0x7F; + + private static string DomainToAscii(string domain) + { + string result; + if (UrlCodePoints.IsAscii(domain)) + { + result = UrlCodePoints.ToAsciiLower(domain); + } + else + { + try + { + result = _idn.GetAscii(domain); + } + catch (ArgumentException) + { + return null; + } + } + + if (result.Length == 0) + { + return null; + } + + foreach (char c in result) + { + if (IsForbiddenDomainCodePoint(c)) + { + return null; + } + } + + return result; + } + + private static string ParseOpaqueHost(string input) + { + foreach (int c in UrlCodePoints.ToCodePoints(input)) + { + if (IsForbiddenHostCodePoint(c)) + { + return null; + } + } + + return UrlCodePoints.PercentEncode(input, PercentEncodeSet.C0Control); + } + + private static bool EndsInNumber(string input) + { + string[] parts = input.Split('.'); + string last = parts[^1]; + if (last.Length == 0) + { + if (parts.Length == 1) + { + return false; + } + + last = parts[^2]; + } + + if (last.Length > 0 && IsAllDigits(last)) + { + return true; + } + + return ParseIPv4Number(last).HasValue; + } + + private static bool IsAllDigits(string value) + { + foreach (char c in value) + { + if (!UrlCodePoints.IsAsciiDigit(c)) + { + return false; + } + } + + return true; + } + + private static uint? ParseIPv4(string input) + { + string[] parts = input.Split('.'); + int count = parts.Length; + if (parts[^1].Length == 0 && count > 1) + { + count--; + } + + if (count > 4) + { + return null; + } + + ulong[] numbers = new ulong[count]; + for (int i = 0; i < count; i++) + { + ulong? number = ParseIPv4Number(parts[i]); + if (!number.HasValue) + { + return null; + } + + numbers[i] = number.Value; + } + + for (int i = 0; i < count - 1; i++) + { + if (numbers[i] > 255) + { + return null; + } + } + + if (numbers[count - 1] >= 1UL << (8 * (5 - count))) + { + return null; + } + + ulong ipv4 = numbers[count - 1]; + for (int i = 0; i < count - 1; i++) + { + ipv4 += numbers[i] << (8 * (3 - i)); + } + + return (uint)ipv4; + } + + /// + /// Parses an IPv4 number. Values that do not fit in 40 bits saturate, which still fails the range checks. + /// + private static ulong? ParseIPv4Number(string input) + { + if (input.Length == 0) + { + return null; + } + + int radix = 10; + if (input.Length >= 2 && (input.StartsWith("0x", StringComparison.Ordinal) || input.StartsWith("0X", StringComparison.Ordinal))) + { + input = input[2..]; + radix = 16; + } + else if (input.Length >= 2 && input[0] == '0') + { + input = input[1..]; + radix = 8; + } + + ulong value = 0; + foreach (char c in input) + { + int digit = radix == 16 && UrlCodePoints.IsAsciiHexDigit(c) ? UrlCodePoints.HexValue(c) : (UrlCodePoints.IsAsciiDigit(c) ? c - '0' : -1); + if (digit < 0 || digit >= radix) + { + return null; + } + + value = Math.Min((value * (ulong)radix) + (ulong)digit, 1UL << 40); + } + + return value; + } + + private static string SerializeIPv4(uint address) + => string.Join('.', address >> 24, (address >> 16) & 0xFF, (address >> 8) & 0xFF, address & 0xFF); + + private static ushort[] ParseIPv6(int[] input) + { + ushort[] address = new ushort[8]; + int pieceIndex = 0; + int? compress = null; + int pointer = 0; + int At(int i) => i < input.Length ? input[i] : UrlCodePoints.Eof; + + if (At(pointer) == ':') + { + if (At(pointer + 1) != ':') + { + return null; + } + + pointer += 2; + pieceIndex++; + compress = pieceIndex; + } + + while (At(pointer) != UrlCodePoints.Eof) + { + if (pieceIndex == 8) + { + return null; + } + + if (At(pointer) == ':') + { + if (compress.HasValue) + { + return null; + } + + pointer++; + pieceIndex++; + compress = pieceIndex; + continue; + } + + int value = 0; + int length = 0; + while (length < 4 && UrlCodePoints.IsAsciiHexDigit(At(pointer))) + { + value = (value * 0x10) + UrlCodePoints.HexValue(At(pointer)); + pointer++; + length++; + } + + if (At(pointer) == '.') + { + if (length == 0) + { + return null; + } + + pointer -= length; + if (pieceIndex > 6) + { + return null; + } + + int numbersSeen = 0; + while (At(pointer) != UrlCodePoints.Eof) + { + int? ipv4Piece = null; + if (numbersSeen > 0) + { + if (At(pointer) == '.' && numbersSeen < 4) + { + pointer++; + } + else + { + return null; + } + } + + if (!UrlCodePoints.IsAsciiDigit(At(pointer))) + { + return null; + } + + while (UrlCodePoints.IsAsciiDigit(At(pointer))) + { + int number = At(pointer) - '0'; + if (ipv4Piece == null) + { + ipv4Piece = number; + } + else if (ipv4Piece == 0) + { + return null; + } + else + { + ipv4Piece = (ipv4Piece * 10) + number; + } + + if (ipv4Piece > 255) + { + return null; + } + + pointer++; + } + + address[pieceIndex] = (ushort)((address[pieceIndex] * 0x100) + ipv4Piece.Value); + numbersSeen++; + if (numbersSeen is 2 or 4) + { + pieceIndex++; + } + } + + if (numbersSeen != 4) + { + return null; + } + + break; + } + else if (At(pointer) == ':') + { + pointer++; + if (At(pointer) == UrlCodePoints.Eof) + { + return null; + } + } + else if (At(pointer) != UrlCodePoints.Eof) + { + return null; + } + + address[pieceIndex] = (ushort)value; + pieceIndex++; + } + + if (compress.HasValue) + { + int swaps = pieceIndex - compress.Value; + pieceIndex = 7; + while (pieceIndex != 0 && swaps > 0) + { + (address[pieceIndex], address[compress.Value + swaps - 1]) = (address[compress.Value + swaps - 1], address[pieceIndex]); + pieceIndex--; + swaps--; + } + } + else if (pieceIndex != 8) + { + return null; + } + + return address; + } + + private static string SerializeIPv6(ushort[] address) + { + // Find the first longest run of two or more zero pieces. + int compress = -1; + int longest = 1; + for (int i = 0; i < 8;) + { + int start = i; + while (i < 8 && address[i] == 0) + { + i++; + } + + if (i - start > longest) + { + longest = i - start; + compress = start; + } + + if (i == start) + { + i++; + } + } + + var output = new StringBuilder(); + bool ignore0 = false; + for (int i = 0; i < 8; i++) + { + if (ignore0 && address[i] == 0) + { + continue; + } + + ignore0 = false; + if (compress == i) + { + output.Append(i == 0 ? "::" : ":"); + ignore0 = true; + continue; + } + + output.Append(address[i].ToString("x", CultureInfo.InvariantCulture)); + if (i != 7) + { + output.Append(':'); + } + } + + return output.ToString(); + } + } +} diff --git a/src/PlaywrightNative/URLPattern/UrlParser.cs b/src/PlaywrightNative/URLPattern/UrlParser.cs new file mode 100644 index 00000000..84fc3da9 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/UrlParser.cs @@ -0,0 +1,808 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System; +using System.Globalization; +using System.Text; +using static PlaywrightNative.UrlCodePoints; + +namespace PlaywrightNative +{ + /// + /// The WHATWG basic URL parser (https://url.spec.whatwg.org/#concept-basic-url-parser). + /// It reports failures only, not validation errors, and always uses UTF-8. + /// + internal sealed class UrlParser + { + private readonly int[] _input; + private readonly UrlRecord _base; + private readonly UrlRecord _url; + private readonly bool _hasOverride; + private readonly UrlParserState? _stateOverride; + private readonly StringBuilder _buffer = new(); + private UrlParserState _state; + private int _pointer; + private bool _atSignSeen; + private bool _insideBrackets; + private bool _passwordTokenSeen; + + private UrlParser(string input, UrlRecord baseUrl, UrlRecord url, UrlParserState? stateOverride) + { + if (url == null) + { + url = new UrlRecord(); + input = TrimC0ControlOrSpace(input); + } + + input = input.Replace("\t", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal); + _input = ToCodePoints(input); + _base = baseUrl; + _url = url; + _stateOverride = stateOverride; + _hasOverride = stateOverride.HasValue; + _state = stateOverride ?? UrlParserState.SchemeStart; + } + + private enum Outcome + { + Continue, + Return, + Failure, + } + + private bool IsSpecial => _url.IsSpecial; + + /// + /// Parses against an optional base URL. Returns null on failure. + /// + public static UrlRecord Parse(string input, UrlRecord baseUrl = null) + { + var parser = new UrlParser(input, baseUrl, null, null); + return parser.Run() ? parser._url : null; + } + + /// + /// Runs the parser on , starting in . Returns false on failure. + /// + public static bool ParseInto(string input, UrlRecord url, UrlParserState stateOverride) + => new UrlParser(input, null, url, stateOverride).Run(); + + public static bool IsWindowsDriveLetter(string value) + => value.Length == 2 && IsAsciiAlpha(value[0]) && (value[1] == ':' || value[1] == '|'); + + public static bool IsNormalizedWindowsDriveLetter(string value) => IsWindowsDriveLetter(value) && value[1] == ':'; + + private static string TrimC0ControlOrSpace(string input) + { + int start = 0; + int end = input.Length; + while (start < end && input[start] <= 0x20) + { + start++; + } + + while (end > start && input[end - 1] <= 0x20) + { + end--; + } + + return input[start..end]; + } + + private static bool IsSingleDot(string segment) => segment == "." || segment.Equals("%2e", StringComparison.OrdinalIgnoreCase); + + private static bool IsDoubleDot(string segment) => ToAsciiLower(segment) is ".." or ".%2e" or "%2e." or "%2e%2e"; + + private int CodePointAt(int index) => index >= 0 && index < _input.Length ? _input[index] : Eof; + + private bool RemainingStartsWith(char c) => CodePointAt(_pointer + 1) == c; + + private bool StartsWithWindowsDriveLetter(int index) + { + int length = _input.Length - index; + if (length < 2 || !IsAsciiAlpha(_input[index]) || (_input[index + 1] != ':' && _input[index + 1] != '|')) + { + return false; + } + + return length == 2 || _input[index + 2] is '/' or '\\' or '?' or '#'; + } + + private void CopyAuthorityFromBase() + { + _url.Username = _base.Username; + _url.Password = _base.Password; + _url.Host = _base.Host; + _url.Port = _base.Port; + } + + private bool Run() + { + for (; ; _pointer++) + { + Outcome outcome = RunState(CodePointAt(_pointer)); + if (outcome != Outcome.Continue) + { + return outcome == Outcome.Return; + } + + if (_pointer >= _input.Length) + { + return true; + } + } + } + + private Outcome RunState(int c) => _state switch + { + UrlParserState.SchemeStart => SchemeStartState(c), + UrlParserState.Scheme => SchemeState(c), + UrlParserState.NoScheme => NoSchemeState(c), + UrlParserState.SpecialRelativeOrAuthority => SpecialRelativeOrAuthorityState(c), + UrlParserState.PathOrAuthority => PathOrAuthorityState(c), + UrlParserState.Relative => RelativeState(c), + UrlParserState.RelativeSlash => RelativeSlashState(c), + UrlParserState.SpecialAuthoritySlashes => SpecialAuthoritySlashesState(c), + UrlParserState.SpecialAuthorityIgnoreSlashes => SpecialAuthorityIgnoreSlashesState(c), + UrlParserState.Authority => AuthorityState(c), + UrlParserState.Host or UrlParserState.Hostname => HostState(c), + UrlParserState.Port => PortState(c), + UrlParserState.File => FileState(c), + UrlParserState.FileSlash => FileSlashState(c), + UrlParserState.FileHost => FileHostState(c), + UrlParserState.PathStart => PathStartState(c), + UrlParserState.Path => PathState(c), + UrlParserState.OpaquePath => OpaquePathState(c), + UrlParserState.Query => QueryState(c), + _ => FragmentState(c), + }; + + private Outcome SchemeStartState(int c) + { + if (IsAsciiAlpha(c)) + { + _buffer.Append((char)ToAsciiLower(c)); + _state = UrlParserState.Scheme; + } + else if (!_hasOverride) + { + _state = UrlParserState.NoScheme; + _pointer--; + } + else + { + return Outcome.Failure; + } + + return Outcome.Continue; + } + + private Outcome SchemeState(int c) + { + if (IsAsciiAlphanumeric(c) || c is '+' or '-' or '.') + { + _buffer.Append((char)ToAsciiLower(c)); + return Outcome.Continue; + } + + if (c != ':') + { + if (_hasOverride) + { + return Outcome.Failure; + } + + _buffer.Clear(); + _state = UrlParserState.NoScheme; + _pointer = -1; + return Outcome.Continue; + } + + string scheme = _buffer.ToString(); + if (_hasOverride) + { + if (_url.IsSpecial != UrlRecord.IsSpecialScheme(scheme) + || ((_url.IncludesCredentials || _url.Port.HasValue) && scheme == "file") + || (_url.Scheme == "file" && _url.Host?.Length == 0)) + { + return Outcome.Return; + } + } + + _url.Scheme = scheme; + if (_hasOverride) + { + if (_url.Port == UrlRecord.DefaultPort(scheme)) + { + _url.Port = null; + } + + return Outcome.Return; + } + + _buffer.Clear(); + if (scheme == "file") + { + _state = UrlParserState.File; + } + else if (IsSpecial && _base != null && _base.Scheme == scheme) + { + _state = UrlParserState.SpecialRelativeOrAuthority; + } + else if (IsSpecial) + { + _state = UrlParserState.SpecialAuthoritySlashes; + } + else if (RemainingStartsWith('/')) + { + _state = UrlParserState.PathOrAuthority; + _pointer++; + } + else + { + _url.OpaquePath = string.Empty; + _state = UrlParserState.OpaquePath; + } + + return Outcome.Continue; + } + + private Outcome NoSchemeState(int c) + { + if (_base == null || (_base.HasOpaquePath && c != '#')) + { + return Outcome.Failure; + } + + if (_base.HasOpaquePath) + { + _url.Scheme = _base.Scheme; + _url.OpaquePath = _base.OpaquePath; + _url.Query = _base.Query; + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + else + { + _state = _base.Scheme != "file" ? UrlParserState.Relative : UrlParserState.File; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome SpecialRelativeOrAuthorityState(int c) + { + if (c == '/' && RemainingStartsWith('/')) + { + _state = UrlParserState.SpecialAuthorityIgnoreSlashes; + _pointer++; + } + else + { + _state = UrlParserState.Relative; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome PathOrAuthorityState(int c) + { + if (c == '/') + { + _state = UrlParserState.Authority; + } + else + { + _state = UrlParserState.Path; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome RelativeState(int c) + { + _url.Scheme = _base.Scheme; + if (c == '/' || (IsSpecial && c == '\\')) + { + _state = UrlParserState.RelativeSlash; + return Outcome.Continue; + } + + CopyAuthorityFromBase(); + _url.Path = [.. _base.Path]; + _url.Query = _base.Query; + if (c == '?') + { + _url.Query = string.Empty; + _state = UrlParserState.Query; + } + else if (c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + else if (c != Eof) + { + _url.Query = null; + _url.ShortenPath(); + _state = UrlParserState.Path; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome RelativeSlashState(int c) + { + if (IsSpecial && (c == '/' || c == '\\')) + { + _state = UrlParserState.SpecialAuthorityIgnoreSlashes; + } + else if (c == '/') + { + _state = UrlParserState.Authority; + } + else + { + CopyAuthorityFromBase(); + _state = UrlParserState.Path; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome SpecialAuthoritySlashesState(int c) + { + _state = UrlParserState.SpecialAuthorityIgnoreSlashes; + if (c == '/' && RemainingStartsWith('/')) + { + _pointer++; + } + else + { + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome SpecialAuthorityIgnoreSlashesState(int c) + { + if (c != '/' && c != '\\') + { + _state = UrlParserState.Authority; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome AuthorityState(int c) + { + if (c == '@') + { + if (_atSignSeen) + { + _buffer.Insert(0, "%40"); + } + + _atSignSeen = true; + var username = new StringBuilder(_url.Username); + var password = new StringBuilder(_url.Password); + foreach (int codePoint in ToCodePoints(_buffer.ToString())) + { + if (codePoint == ':' && !_passwordTokenSeen) + { + _passwordTokenSeen = true; + continue; + } + + AppendPercentEncoded(_passwordTokenSeen ? password : username, codePoint, PercentEncodeSet.Userinfo); + } + + _url.Username = username.ToString(); + _url.Password = password.ToString(); + _buffer.Clear(); + } + else if (c is Eof or '/' or '?' or '#' || (IsSpecial && c == '\\')) + { + if (_atSignSeen && _buffer.Length == 0) + { + return Outcome.Failure; + } + + _pointer -= CodePointLength(_buffer.ToString()) + 1; + _buffer.Clear(); + _state = UrlParserState.Host; + } + else + { + AppendCodePoint(_buffer, c); + } + + return Outcome.Continue; + } + + private Outcome HostState(int c) + { + if (_hasOverride && _url.Scheme == "file") + { + _pointer--; + _state = UrlParserState.FileHost; + } + else if (c == ':' && !_insideBrackets) + { + if (_buffer.Length == 0 || _stateOverride == UrlParserState.Hostname) + { + return Outcome.Failure; + } + + string host = UrlHostParser.Parse(_buffer.ToString(), !IsSpecial); + if (host == null) + { + return Outcome.Failure; + } + + _url.Host = host; + _buffer.Clear(); + _state = UrlParserState.Port; + } + else if (c is Eof or '/' or '?' or '#' || (IsSpecial && c == '\\')) + { + _pointer--; + if (IsSpecial && _buffer.Length == 0) + { + return Outcome.Failure; + } + + if (_hasOverride && _buffer.Length == 0 && (_url.IncludesCredentials || _url.Port.HasValue)) + { + return Outcome.Failure; + } + + string host = UrlHostParser.Parse(_buffer.ToString(), !IsSpecial); + if (host == null) + { + return Outcome.Failure; + } + + _url.Host = host; + _buffer.Clear(); + _state = UrlParserState.PathStart; + if (_hasOverride) + { + return Outcome.Return; + } + } + else + { + if (c == '[') + { + _insideBrackets = true; + } + else if (c == ']') + { + _insideBrackets = false; + } + + AppendCodePoint(_buffer, c); + } + + return Outcome.Continue; + } + + private Outcome PortState(int c) + { + if (IsAsciiDigit(c)) + { + _buffer.Append((char)c); + return Outcome.Continue; + } + + if (c is Eof or '/' or '?' or '#' || (IsSpecial && c == '\\') || _hasOverride) + { + if (_buffer.Length != 0) + { + string digits = _buffer.ToString().TrimStart('0'); + if (digits.Length > 5 || (digits.Length > 0 && int.Parse(digits, CultureInfo.InvariantCulture) > 65535)) + { + return Outcome.Failure; + } + + int port = digits.Length == 0 ? 0 : int.Parse(digits, CultureInfo.InvariantCulture); + _url.Port = port == UrlRecord.DefaultPort(_url.Scheme) ? null : port; + _buffer.Clear(); + if (_hasOverride) + { + return Outcome.Return; + } + } + + if (_hasOverride) + { + return Outcome.Failure; + } + + _state = UrlParserState.PathStart; + _pointer--; + return Outcome.Continue; + } + + return Outcome.Failure; + } + + private Outcome FileState(int c) + { + _url.Scheme = "file"; + _url.Host = string.Empty; + if (c == '/' || c == '\\') + { + _state = UrlParserState.FileSlash; + } + else if (_base != null && _base.Scheme == "file") + { + _url.Host = _base.Host; + _url.Path = [.. _base.Path]; + _url.Query = _base.Query; + if (c == '?') + { + _url.Query = string.Empty; + _state = UrlParserState.Query; + } + else if (c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + else if (c != Eof) + { + _url.Query = null; + if (!StartsWithWindowsDriveLetter(_pointer)) + { + _url.ShortenPath(); + } + else + { + _url.Path.Clear(); + } + + _state = UrlParserState.Path; + _pointer--; + } + } + else + { + _state = UrlParserState.Path; + _pointer--; + } + + return Outcome.Continue; + } + + private Outcome FileSlashState(int c) + { + if (c == '/' || c == '\\') + { + _state = UrlParserState.FileHost; + return Outcome.Continue; + } + + if (_base != null && _base.Scheme == "file") + { + _url.Host = _base.Host; + if (!StartsWithWindowsDriveLetter(_pointer) && _base.Path.Count > 0 && IsNormalizedWindowsDriveLetter(_base.Path[0])) + { + _url.Path.Add(_base.Path[0]); + } + } + + _state = UrlParserState.Path; + _pointer--; + return Outcome.Continue; + } + + private Outcome FileHostState(int c) + { + if (c is not (Eof or '/' or '\\' or '?' or '#')) + { + AppendCodePoint(_buffer, c); + return Outcome.Continue; + } + + _pointer--; + if (!_hasOverride && IsWindowsDriveLetter(_buffer.ToString())) + { + _state = UrlParserState.Path; + } + else if (_buffer.Length == 0) + { + _url.Host = string.Empty; + if (_hasOverride) + { + return Outcome.Return; + } + + _state = UrlParserState.PathStart; + } + else + { + string host = UrlHostParser.Parse(_buffer.ToString(), !IsSpecial); + if (host == null) + { + return Outcome.Failure; + } + + _url.Host = host == "localhost" ? string.Empty : host; + if (_hasOverride) + { + return Outcome.Return; + } + + _buffer.Clear(); + _state = UrlParserState.PathStart; + } + + return Outcome.Continue; + } + + private Outcome PathStartState(int c) + { + if (IsSpecial) + { + _state = UrlParserState.Path; + if (c != '/' && c != '\\') + { + _pointer--; + } + } + else if (!_hasOverride && c == '?') + { + _url.Query = string.Empty; + _state = UrlParserState.Query; + } + else if (!_hasOverride && c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + else if (c != Eof) + { + _state = UrlParserState.Path; + if (c != '/') + { + _pointer--; + } + } + else if (_hasOverride && _url.Host == null) + { + _url.Path.Add(string.Empty); + } + + return Outcome.Continue; + } + + private Outcome PathState(int c) + { + bool slash = c == '/' || (IsSpecial && c == '\\'); + if (c == Eof || slash || (!_hasOverride && c is '?' or '#')) + { + string segment = _buffer.ToString(); + if (IsDoubleDot(segment)) + { + _url.ShortenPath(); + if (!slash) + { + _url.Path.Add(string.Empty); + } + } + else if (IsSingleDot(segment) && !slash) + { + _url.Path.Add(string.Empty); + } + else if (!IsSingleDot(segment)) + { + if (_url.Scheme == "file" && _url.Path.Count == 0 && IsWindowsDriveLetter(segment)) + { + segment = segment[0] + ":"; + } + + _url.Path.Add(segment); + } + + _buffer.Clear(); + if (c == '?') + { + _url.Query = string.Empty; + _state = UrlParserState.Query; + } + else if (c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + } + else + { + AppendPercentEncoded(_buffer, c, PercentEncodeSet.Path); + } + + return Outcome.Continue; + } + + private Outcome OpaquePathState(int c) + { + if (c == '?') + { + _url.Query = string.Empty; + _state = UrlParserState.Query; + } + else if (c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + else if (c == ' ') + { + _url.OpaquePath += RemainingStartsWith('?') || RemainingStartsWith('#') ? "%20" : " "; + } + else if (c != Eof) + { + var encoded = new StringBuilder(); + AppendPercentEncoded(encoded, c, PercentEncodeSet.C0Control); + _url.OpaquePath += encoded.ToString(); + } + + return Outcome.Continue; + } + + private Outcome QueryState(int c) + { + if ((!_hasOverride && c == '#') || c == Eof) + { + _url.Query += PercentEncode(_buffer.ToString(), IsSpecial ? PercentEncodeSet.SpecialQuery : PercentEncodeSet.Query); + _buffer.Clear(); + if (c == '#') + { + _url.Fragment = string.Empty; + _state = UrlParserState.Fragment; + } + } + else + { + AppendCodePoint(_buffer, c); + } + + return Outcome.Continue; + } + + private Outcome FragmentState(int c) + { + if (c != Eof) + { + var encoded = new StringBuilder(); + AppendPercentEncoded(encoded, c, PercentEncodeSet.Fragment); + _url.Fragment += encoded.ToString(); + } + + return Outcome.Continue; + } + } +} diff --git a/src/PlaywrightNative/URLPattern/UrlParserState.cs b/src/PlaywrightNative/URLPattern/UrlParserState.cs new file mode 100644 index 00000000..549add78 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/UrlParserState.cs @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace PlaywrightNative +{ + /// + /// States of the basic URL parser. URL pattern canonicalization starts the parser in some of them. + /// + internal enum UrlParserState + { + SchemeStart, + Scheme, + NoScheme, + SpecialRelativeOrAuthority, + PathOrAuthority, + Relative, + RelativeSlash, + SpecialAuthoritySlashes, + SpecialAuthorityIgnoreSlashes, + Authority, + Host, + Hostname, + Port, + File, + FileSlash, + FileHost, + PathStart, + Path, + OpaquePath, + Query, + Fragment, + } +} diff --git a/src/PlaywrightNative/URLPattern/UrlRecord.cs b/src/PlaywrightNative/URLPattern/UrlRecord.cs new file mode 100644 index 00000000..15d3df66 --- /dev/null +++ b/src/PlaywrightNative/URLPattern/UrlRecord.cs @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Dario Kondratiuk + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +using System.Collections.Generic; + +namespace PlaywrightNative +{ + /// + /// A WHATWG URL record (https://url.spec.whatwg.org/#concept-url). does not follow + /// the WHATWG rules, so URL pattern matching uses this record instead. + /// + internal sealed class UrlRecord + { + public string Scheme { get; set; } = string.Empty; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + /// + /// Gets or sets the serialized host, or null when the URL has no host. + /// + public string Host { get; set; } + + public int? Port { get; set; } + + public List Path { get; set; } = []; + + /// + /// Gets or sets the opaque path. It is null unless the URL has an opaque path. + /// + public string OpaquePath { get; set; } + + public string Query { get; set; } + + public string Fragment { get; set; } + + public bool IsSpecial => DefaultPort(Scheme) != null || Scheme == "file"; + + public bool HasOpaquePath => OpaquePath != null; + + public bool IncludesCredentials => Username.Length > 0 || Password.Length > 0; + + /// + /// Returns the default port of a special scheme, or null. + /// + public static int? DefaultPort(string scheme) => scheme switch + { + "ftp" => 21, + "http" or "ws" => 80, + "https" or "wss" => 443, + _ => null, + }; + + public static bool IsSpecialScheme(string scheme) => DefaultPort(scheme) != null || scheme == "file"; + + /// + /// Creates the dummy URL "https://dummy.invalid/" that canonicalization runs against. + /// + public static UrlRecord CreateDummy() => new() { Scheme = "https", Host = "dummy.invalid", Path = [string.Empty] }; + + /// + /// The URL path serializer (https://url.spec.whatwg.org/#url-path-serializer). + /// + public string SerializePath() => HasOpaquePath ? OpaquePath : (Path.Count == 0 ? string.Empty : "/" + string.Join('/', Path)); + + /// + /// Shortens the path (https://url.spec.whatwg.org/#shorten-a-urls-path). + /// + public void ShortenPath() + { + if (Scheme == "file" && Path.Count == 1 && UrlParser.IsNormalizedWindowsDriveLetter(Path[0])) + { + return; + } + + if (Path.Count > 0) + { + Path.RemoveAt(Path.Count - 1); + } + } + } +}