diff --git a/README.md b/README.md index abbbbd6b2..0d82f06cc 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,56 @@ auto match = pattern->match("https://example.com/books/123"); auto matched = pattern->test("https://example.com/books/123"); ``` +### URLPattern List (experimental) + +A URLPattern list is a set of pathname patterns compiled together, so that +finding the matching route is one lookup instead of a loop over +`url_pattern::exec`. It takes the same regex provider as `parse_url_pattern`, +and only routes that need regexp semantics (custom `(...)` groups, `?`, `+` +or `*` modifiers) ever reach it. Static, `:param` and `*` routes are matched +without a regex engine. + +```cpp +// Same provider as for parse_url_pattern; see the URLPattern section above. +std::vector routes = {"/", "/users/:id", "/users/me", + "/files/*", "/posts/(\\d+)"}; +auto list = ada::parse_url_pattern_list(routes); +if (!list) { return EXIT_FAILURE; } + +// Match a pathname, for example url.get_pathname() +auto m = list->match("/users/42"); +// m.route_index == 1 +// m.captures[0] is the ":id" value as a slice of the input: offset 7, length 2 +// list->group_names(1)[0] == "id" + +auto r = list->match("/posts/7"); +// r.route_index == 4 and r.regexp_route == true: matched through the provider +// r.regexp_groups[0] == "7", as returned by regex_search +``` + +Things to know: + +- `match` takes a pathname, not a full URL. Only the pathname is matched; + the other components are treated as wildcards. +- The most specific route wins: a literal segment beats `:param`, which beats + `*`, compared segment by segment from the left. Between equally specific + routes, the one added first wins. `/users/me` wins over `/users/:id` for + `/users/me` whatever the insertion order. This is the order used by routers + such as find-my-way and Express. +- Regexp routes take part in the same order. A regexp route that cannot beat + the compiled winner is not executed at all. +- `parse_url_pattern_list` also takes a base URL and `url_pattern_options` + (`ignore_case`), and an overload takes existing `ada::url_pattern` objects + and reuses their compiled pathname components. +- Inputs over 4096 bytes or 24 segments, and routes with more than 16 + segments, are handled by a slower path with the same result. A route may + declare up to 8 captures; beyond that only the first 8 are reported and + `captures_truncated` is set. + +The API is experimental. Whether a standard URLPatternList should use this +order or plain insertion order is still being discussed in the WHATWG +[urlpattern](https://github.com/whatwg/urlpattern/issues/166) repository. + ### C wrapper See the file `include/ada_c.h` for our C interface. We expect ASCII or UTF-8 strings. diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 16324e386..a84e2a5e4 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -18,6 +18,11 @@ target_link_libraries(urlpattern PRIVATE ada counters::counters) target_include_directories(urlpattern PUBLIC "$") target_include_directories(urlpattern PUBLIC "$") +add_executable(urlpattern_list urlpattern_list.cpp) +target_link_libraries(urlpattern_list PRIVATE ada counters::counters) +target_include_directories(urlpattern_list PUBLIC "$") +target_include_directories(urlpattern_list PUBLIC "$") + # Bench add_executable(wpt_bench wpt_bench.cpp) target_link_libraries(wpt_bench PRIVATE ada counters::counters) @@ -108,8 +113,9 @@ target_link_libraries(percent_decode PRIVATE benchmark::benchmark) target_link_libraries(bench_setters PRIVATE benchmark::benchmark) target_link_libraries(bench_search_params PRIVATE benchmark::benchmark) target_link_libraries(urlpattern PRIVATE benchmark::benchmark) +target_link_libraries(urlpattern_list PRIVATE benchmark::benchmark) -set(BENCHMARKS wpt_bench bench benchdata bbc_bench bench_ipv4 percent_encode percent_decode bench_setters bench_search_params urlpattern) +set(BENCHMARKS wpt_bench bench benchdata bbc_bench bench_ipv4 percent_encode percent_decode bench_setters bench_search_params urlpattern urlpattern_list) add_custom_target(run_all_benchmarks COMMAND ${CMAKE_COMMAND} -E echo "Running all benchmarks..." diff --git a/benchmarks/urlpattern_list.cpp b/benchmarks/urlpattern_list.cpp new file mode 100644 index 000000000..7d3fdaf6f --- /dev/null +++ b/benchmarks/urlpattern_list.cpp @@ -0,0 +1,302 @@ +// Benchmark: routing a pathname over ~100 routes, comparing the current +// URLPattern reality (a sequential url_pattern::exec loop) against the +// compiled ada::url_pattern_list. Run with ADA_BENCHMARKS=ON and +// ADA_USE_UNSAFE_STD_REGEX_PROVIDER=ON, in Release mode. +#include "benchmark_header.h" + +using regex_provider = ada::url_pattern_regex::std_regex_provider; +using list_type = ada::url_pattern_list; + +// ---- a realistic REST route table (static -> param -> wildcard, so that +// insertion-order first match and specificity order agree and the two +// implementations can be cross-checked for identical answers) --------------- + +static const std::vector& route_table() { + static const std::vector routes = [] { + std::vector r; + static const char* resources[] = { + "users", "orders", "products", "invoices", "teams", + "projects", "tickets", "sessions", "webhooks", "reports"}; + // 41 static routes. + r.push_back("/"); + r.push_back("/health"); + r.push_back("/metrics"); + r.push_back("/login"); + r.push_back("/logout"); + r.push_back("/settings/profile"); + for (const char* res : resources) { + r.push_back(std::string("/api/v1/") + res); + r.push_back(std::string("/api/v1/") + res + "/count"); + r.push_back(std::string("/admin/") + res); + } + r.push_back("/api/v1/users/me"); + r.push_back("/api/v1/users/me/preferences"); + r.push_back("/api/v2/users"); + r.push_back("/api/v2/orders"); + r.push_back("/api/v2/products"); + // 52 parameterized routes. + for (const char* res : resources) { + r.push_back(std::string("/api/v1/") + res + "/:id"); + r.push_back(std::string("/api/v1/") + res + "/:id/history"); + r.push_back(std::string("/admin/") + res + "/:id"); + } + r.push_back("/api/v1/users/:id/posts/:post_id"); + r.push_back("/api/v1/users/:id/posts/:post_id/comments"); + r.push_back("/api/v1/orders/:id/items/:item_id"); + r.push_back("/api/v1/teams/:team_id/members/:member_id"); + r.push_back("/api/v1/projects/:project_id/tickets/:ticket_id"); + r.push_back("/api/v2/users/:id"); + r.push_back("/api/v2/orders/:id"); + r.push_back("/blog/:year/:month/:slug"); + r.push_back("/docs/:section/:page"); + r.push_back("/orgs/:org/repos/:repo/issues/:number"); + r.push_back("/orgs/:org/repos/:repo/pulls/:number"); + r.push_back("/u/:username"); + r.push_back("/t/:tag"); + r.push_back("/search/:query"); + r.push_back("/shorturl/:code"); + r.push_back("/@:handle/status/:status_id"); + r.push_back("/w/:lang/wiki/:title"); + r.push_back("/cdn/:region/:bucket/:object"); + r.push_back("/v/:video_id"); + r.push_back("/c/:channel/videos"); + r.push_back("/api/v1/webhooks/:id/deliveries"); + r.push_back("/api/v1/reports/:id/export"); + r.push_back("/oauth/:provider/callback"); + // 7 wildcard routes. + r.push_back("/static/*"); + r.push_back("/assets/js/*"); + r.push_back("/assets/css/*"); + r.push_back("/files/*"); + r.push_back("/downloads/*"); + r.push_back("/proxy/api/*"); + r.push_back("/*"); + return r; + }(); + return routes; +} + +// The same table with one regexp route added, the way a real table grows: +// a "(\\d+)" route that the specificity order ranks alongside the ":id" +// routes. Every hit on the compiled fast path decides, at creation, whether +// this route can outrank it: no ":id" or static winner can be outranked by +// it, and only a "/*" miss under "/api/v1/invoices/" is a legitimate +// candidate, so std::regex runs for at most one URL of the stream instead +// of every one. +static const std::vector& route_table_with_regexp() { + static const std::vector routes = [] { + std::vector r = route_table(); + r.push_back("/api/v1/invoices/(\\d+)/pdf"); + return r; + }(); + return routes; +} + +// A deterministic stream of request pathnames: instantiated hits over the +// route table plus a share of misses (the final "/*" catches them; a match +// is still found, exercising the worst backtracking path of both sides). +// Both benchmarks iterate this same stream, so the comparison stays honest. +// The stream is kept short (32 URLs) so one iteration of the sequential +// url_pattern::exec loop stays well under CodSpeed's per-iteration budget; +// the ns/url counters normalize the stream length away. +static std::vector make_stream(bool static_only, bool param_only) { + std::vector u; + uint64_t x = 0x14C0FFEEull; // splitmix64 + auto next = [&x]() { + uint64_t z = (x += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + }; + auto token = [&next]() { + static const char alphabet[] = "abcdefghijklmnopqrstuvwxyz0123456789"; + std::string t; + size_t len = 3 + next() % 8; + for (size_t j = 0; j < len; j++) { + t += alphabet[next() % (sizeof(alphabet) - 1)]; + } + return t; + }; + const auto& routes = route_table(); + while (u.size() < 32) { + const std::string& pattern = routes[next() % routes.size()]; + const bool has_group = pattern.find(':') != std::string::npos || + pattern.find('*') != std::string::npos; + if ((static_only && has_group) || + (param_only && pattern.find(':') == std::string::npos)) { + continue; + } + std::string url; + size_t pos = 0; + while (pos < pattern.size()) { + if (pattern[pos] == ':') { + while (pos < pattern.size() && pattern[pos] != '/') { + pos++; + } + url += token(); + } else if (pattern[pos] == '*') { + pos++; + url += token(); + url += '/'; + url += token(); + } else { + url += pattern[pos++]; + } + } + if (!static_only && !param_only && next() % 5 == 0) { + url += "/zz"; // ~20% misses-by-mutation + } + u.push_back(std::move(url)); + } + return u; +} + +static const std::vector& url_stream() { + static const std::vector urls = make_stream(false, false); + return urls; +} + +static const std::vector& static_stream() { + static const std::vector urls = make_stream(true, false); + return urls; +} + +static const std::vector& param_stream() { + static const std::vector urls = make_stream(false, true); + return urls; +} + +static std::vector>& sequential_patterns() { + static std::vector> patterns = [] { + std::vector> p; + for (const std::string& route : route_table()) { + auto pattern = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = route}); + if (pattern) { + p.push_back(std::move(*pattern)); + } + } + return p; + }(); + return patterns; +} + +static list_type compile_list(const std::vector& routes) { + std::vector views(routes.begin(), routes.end()); + auto result = ada::parse_url_pattern_list(views); + if (!result) { + std::cerr << "parse_url_pattern_list failed" << std::endl; + std::abort(); + } + return std::move(*result); +} + +static const list_type& compiled_list() { + static const list_type list = compile_list(route_table()); + return list; +} + +static const list_type& compiled_list_with_regexp() { + static const list_type list = compile_list(route_table_with_regexp()); + return list; +} + +// First match of the sequential url_pattern::exec loop -- the routing loop +// the URLPattern API offers today. +static int32_t sequential_route(std::string_view url) { + auto& patterns = sequential_patterns(); + const ada::url_pattern_input input( + ada::url_pattern_init{.pathname = std::string(url)}); + for (size_t i = 0; i < patterns.size(); i++) { + auto result = patterns[i].exec(input); + if (result && result->has_value()) { + return static_cast(i); + } + } + return -1; +} + +static void add_counters(benchmark::State& state, size_t n_urls) { + state.counters["ns/url"] = benchmark::Counter( + static_cast(state.iterations()) * static_cast(n_urls), + benchmark::Counter::kIsRate | benchmark::Counter::kInvert); + state.counters["urls/s"] = benchmark::Counter( + static_cast(state.iterations()) * static_cast(n_urls), + benchmark::Counter::kIsRate); +} + +static void BasicBench_SequentialURLPatternExec(benchmark::State& state) { + const auto& urls = url_stream(); + volatile int64_t sum = 0; + for (auto _ : state) { + for (const std::string& url : urls) { + sum += sequential_route(url); + } + } + (void)sum; + add_counters(state, urls.size()); +} +BENCHMARK(BasicBench_SequentialURLPatternExec); + +static void list_bench(benchmark::State& state, const list_type& list, + const std::vector& urls) { + volatile int64_t sum = 0; + for (auto _ : state) { + for (const std::string& url : urls) { + sum += list.match(url).route_index; + } + } + (void)sum; + add_counters(state, urls.size()); +} + +static void BasicBench_URLPatternListMatch(benchmark::State& state) { + list_bench(state, compiled_list(), url_stream()); +} +BENCHMARK(BasicBench_URLPatternListMatch); + +// Static-route hits only and ":param"-route hits only, to see each path. +static void BasicBench_URLPatternListMatch_StaticHits(benchmark::State& state) { + list_bench(state, compiled_list(), static_stream()); +} +BENCHMARK(BasicBench_URLPatternListMatch_StaticHits); + +static void BasicBench_URLPatternListMatch_ParamHits(benchmark::State& state) { + list_bench(state, compiled_list(), param_stream()); +} +BENCHMARK(BasicBench_URLPatternListMatch_ParamHits); + +// The same stream over the table with one "(\\d+)" regexp route present: +// the cost of a regexp route in the table must not be a std::regex +// execution per request. +static void BasicBench_URLPatternListMatch_WithRegexpRoute( + benchmark::State& state) { + list_bench(state, compiled_list_with_regexp(), url_stream()); +} +BENCHMARK(BasicBench_URLPatternListMatch_WithRegexpRoute); + +int main(int argc, char** argv) { + // Cross-check: the route table is ordered static -> param -> wildcard, so + // insertion-order first match and specificity order must agree; any + // disagreement would invalidate the comparison. The regexp variant must + // answer the same stream identically (its extra route matches nothing in + // it). + size_t disagreements = 0; + const auto& list = compiled_list(); + const auto& with_regexp = compiled_list_with_regexp(); + for (const std::string& url : url_stream()) { + const int32_t expected = sequential_route(url); + if (expected != list.match(url).route_index || + expected != with_regexp.match(url).route_index) { + disagreements++; + } + } + benchmark::AddCustomContext("routes", std::to_string(route_table().size())); + benchmark::AddCustomContext("urls in stream", + std::to_string(url_stream().size())); + benchmark::AddCustomContext("sequential-vs-list disagreements", + std::to_string(disagreements)); + benchmark::Initialize(&argc, argv); + benchmark::RunSpecifiedBenchmarks(); + benchmark::Shutdown(); +} diff --git a/fuzz/build.sh b/fuzz/build.sh index 443c63376..e6a73484a 100755 --- a/fuzz/build.sh +++ b/fuzz/build.sh @@ -56,6 +56,18 @@ $CXX -DADA_USE_UNSAFE_STD_REGEX_PROVIDER=1 \ url_pattern.o \ -o $OUT/url_pattern +# url_pattern_list shares the std_regex_provider caveat above: testing only. +$CXX -DADA_USE_UNSAFE_STD_REGEX_PROVIDER=1 \ + $CFLAGS $CXXFLAGS \ + -std=c++20 \ + -I build/singleheader \ + -c fuzz/url_pattern_list.cc -o url_pattern_list.o + +$CXX -DADA_USE_UNSAFE_STD_REGEX_PROVIDER=1 \ + $CFLAGS $CXXFLAGS $LIB_FUZZING_ENGINE \ + url_pattern_list.o \ + -o $OUT/url_pattern_list + $CXX $CFLAGS $CXXFLAGS \ -std=c++20 \ -I build/singleheader \ diff --git a/fuzz/url_pattern_list.cc b/fuzz/url_pattern_list.cc new file mode 100644 index 000000000..26e2a3e05 --- /dev/null +++ b/fuzz/url_pattern_list.cc @@ -0,0 +1,382 @@ +#include + +#include +#include +#include +#include +#include + +// The amalgamated ada.cpp carries the route-set compiler +// (src/url_pattern_list_compiler.h), which is not part of the public +// headers; the reference below uses its classification to rank routes. +#include "ada.cpp" +#include "ada.h" + +using regex_provider = ada::url_pattern_regex::std_regex_provider; +using list_type = ada::url_pattern_list; +namespace compiler = ada::url_pattern_list_compiler; +using groups_type = std::vector>; + +// One route of the independent reference: the compiler's classification +// (for the priority order only) and the URLPattern object itself, whose +// pathname component -- ada's own engine, regex_search included -- decides +// whether the route matches and what its group values are. +struct reference_route { + compiler::route_info info{}; + ada::url_pattern pattern{}; +}; + +// Mirrors the classification url_pattern_list performs and builds the +// url_pattern oracle. Returns false when a pattern is rejected, in which +// case parse_url_pattern_list must fail too. +static bool build_reference(const std::vector& patterns, + const ada::url_pattern_options& options, + std::vector& out) { + for (const std::string& pattern : patterns) { + auto compile_options = ada::url_pattern_compile_component_options::PATHNAME; + auto part_list = ada::url_pattern_helpers::parse_pattern_string( + pattern, compile_options, + ada::url_pattern_helpers::canonicalize_pathname); + if (!part_list) { + return false; + } + reference_route route{}; + if (compiler::classify_parts(*part_list, route.info.segments, + route.info.group_names)) { + compiler::finalize_route(route.info); + } else { + compiler::approximate_kind_sequence(*part_list, route.info); + } + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = pattern}, nullptr, &options); + if (!parsed) { + return false; + } + route.pattern = std::move(*parsed); + out.push_back(std::move(route)); + } + return true; +} + +// The reference oracle: every route's URLPattern pathname component is +// executed on the raw input (fast_match is exactly what url_pattern::exec +// runs per component: regex_search for regexp components), and the winner +// is the best match under the documented priority rule. Any disagreement +// with url_pattern_list::match is a bug in the compiled tables. +static int32_t reference_match(const std::vector& routes, + std::string_view pathname, groups_type& groups) { + int32_t best_route = -1; + for (size_t i = 0; i < routes.size(); i++) { + const reference_route& route = routes[i]; + if (best_route >= 0 && + !compiler::route_outranks(route.info, i, + routes[static_cast(best_route)].info, + static_cast(best_route))) { + continue; + } + auto result = route.pattern.pathname_component.fast_match(pathname); + if (result) { + groups = std::move(*result); + best_route = static_cast(i); + } + } + return best_route; +} + +// Walk every field of a match result so nothing the public API hands out is +// left unread under the sanitizers. +static void exercise_match_result( + const ada::url_pattern_list_match_result& result) { + volatile uint64_t sink = 0; + sink += static_cast(result.route_index); + sink += result.capture_count; + sink += result.captures_truncated ? 1 : 0; + sink += result.regexp_route ? 1 : 0; + sink += result.has_match() ? 1 : 0; + for (const auto& capture : result.captures) { + sink += capture.offset; + sink += capture.length; + } + for (const auto& group : result.regexp_groups) { + sink += group.has_value() ? group->size() : 0; + } + (void)sink; +} + +static void fail(const char* what, const std::string& input, + const std::vector& patterns) { + printf("url_pattern_list %s on input '%s'\n", what, input.c_str()); + for (const std::string& pattern : patterns) { + printf(" pattern: '%s'\n", pattern.c_str()); + } + abort(); +} + +static void check_agreement(const list_type& list, + const std::vector& reference, + const std::vector& patterns, + const std::string& input) { + // The pathname is handed over as a view of an exactly sized heap buffer + // (not a null-terminated std::string), so that a read past its end is a + // heap-buffer-overflow under ASan rather than a silent read of the + // terminator. + const std::vector exact(input.begin(), input.end()); + const auto matched = list.match(std::string_view(exact.data(), exact.size())); + exercise_match_result(matched); + groups_type expected_groups; + const int32_t expected_route = + reference_match(reference, input, expected_groups); + if (matched.route_index != expected_route) { + printf("list=%d ref=%d\n", matched.route_index, expected_route); + fail("winner mismatch", input, patterns); + } + if (expected_route < 0) { + return; + } + const reference_route& winner = + reference[static_cast(expected_route)]; + const bool is_regexp = + winner.info.mode == ada::url_pattern_list_detail::route_mode::regexp; + if (matched.regexp_route != is_regexp) { + fail("capture form mismatch", input, patterns); + } + if (is_regexp) { + // Regexp winners: the provider's groups, verbatim. + if (matched.regexp_groups != expected_groups) { + fail("regexp group mismatch", input, patterns); + } + return; + } + // Subset winners: slices of the input equal to the oracle's group values, + // truncated at max_captures_per_route. + const size_t n_reported = + std::min(expected_groups.size(), + ada::url_pattern_list_limits::max_captures_per_route); + if (matched.capture_count != n_reported || + matched.captures_truncated != (expected_groups.size() > n_reported) || + !matched.regexp_groups.empty()) { + fail("capture count mismatch", input, patterns); + } + for (size_t k = 0; k < n_reported; k++) { + const auto& capture = matched.captures[k]; + if (capture.offset + capture.length > input.size() || + !expected_groups[k].has_value() || + input.compare(capture.offset, capture.length, *expected_groups[k]) != + 0) { + fail("capture slice mismatch", input, patterns); + } + } +} + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + FuzzedDataProvider fdp(data, size); + auto to_ascii = [](const std::string& source) -> std::string { + std::string result; + result.reserve(source.size()); + for (char c : source) { + result.push_back(static_cast(c) % 128); + } + return result; + }; + // Tokens from a canonical-safe alphabet, so structured patterns compile + // often and derived inputs need no canonicalization. Upper-case letters + // exercise the ignore_case folding paths. + auto token = [&fdp]() -> std::string { + static constexpr char alphabet[] = + "abcdefghijklmnopqrstuvwxyzABCDEF0123456789-_.~"; + const size_t length = fdp.ConsumeIntegralInRange(1, 12); + std::string result; + result.reserve(length); + for (size_t j = 0; j < length; j++) { + result += + alphabet[fdp.ConsumeIntegralInRange(0, sizeof(alphabet) - 2)]; + } + return result; + }; + + // --- strategy (i): construction over 1..64 derived pattern strings ------- + const size_t n_patterns = fdp.ConsumeIntegralInRange(1, 64); + std::vector patterns; + patterns.reserve(n_patterns); + for (size_t i = 0; i < n_patterns; i++) { + const int mode = fdp.ConsumeIntegralInRange(0, 6); + if (mode == 0) { + // Raw pattern bytes: URLPattern syntax errors are expected and must + // surface as a clean parse error, never as a crash. + patterns.push_back("/" + to_ascii(fdp.ConsumeRandomLengthString(40))); + } else if (mode == 1 && !patterns.empty()) { + // Duplicate an earlier pattern: the smaller index must win. + patterns.push_back( + patterns[fdp.ConsumeIntegralInRange(0, patterns.size() - 1)]); + } else { + // Structured pattern in (and just beyond) the safe subset. + std::string pattern; + const size_t depth = fdp.ConsumeIntegralInRange(1, 20); + int params = 0; + for (size_t d = 0; d < depth; d++) { + const int kind = fdp.ConsumeIntegralInRange(0, 11); + if (kind == 0 && d + 1 == depth) { + pattern += "/*"; + } else if (kind <= 3) { + pattern += "/:p" + std::to_string(params++); + } else if (kind == 4) { + // Witness-hostile literals: identical first/last windows. + pattern += "/aaaaaaaa"; + pattern += + static_cast('A' + fdp.ConsumeIntegralInRange(0, 25)); + pattern += "aaaaaaaa"; + } else if (kind == 5 && mode == 6) { + // Regexp-mode routes: custom groups, optional and mixed segments. + switch (fdp.ConsumeIntegralInRange(0, 3)) { + case 0: + pattern += "/(\\d+)"; + break; + case 1: + pattern += "/:q" + std::to_string(params++) + "?"; + break; + case 2: + pattern += "/x-(\\w+)"; + break; + default: + pattern += "/pre-:m" + std::to_string(params++); + break; + } + } else { + pattern += "/" + token(); + } + } + patterns.push_back(std::move(pattern)); + } + } + const ada::url_pattern_options options{.ignore_case = fdp.ConsumeBool()}; + std::vector views(patterns.begin(), patterns.end()); + auto list_result = + ada::parse_url_pattern_list(views, nullptr, &options); + + // --- strategy (ii): the independent reference must agree on + // constructibility, on every winner, and on every capture --------------- + std::vector reference; + const bool reference_ok = build_reference(patterns, options, reference); + if (list_result.has_value() != reference_ok) { + printf("parse/reference disagreement: parse=%d reference=%d\n", + list_result.has_value() ? 1 : 0, reference_ok ? 1 : 0); + abort(); + } + if (!list_result) { + return 0; + } + const list_type& list = *list_result; + + // Exercise the full public surface. + volatile uint64_t sink = list.size(); + sink += list.ignore_case() ? 1 : 0; + bool has_regexp_route = false; + for (size_t i = 0; i < list.size(); i++) { + sink += list.pattern(i).size(); + for (const std::string& name : list.group_names(i)) { + sink += name.size(); + } + has_regexp_route |= reference[i].info.mode == + ada::url_pattern_list_detail::route_mode::regexp; + } + (void)sink; + + const size_t n_inputs = fdp.ConsumeIntegralInRange(1, 8); + for (size_t u = 0; u < n_inputs; u++) { + std::string input; + const int strategy = fdp.ConsumeIntegralInRange(0, 9); + if (strategy >= 8 && !has_regexp_route) { + // --- strategy (iii): fast-path-gate crossings. Only exact for the + // regex-free subset, where long inputs cannot make std::regex + // pathological. --- + if (fdp.ConsumeBool()) { + // Segment counts crossing the 24-segment gate (23..27). + const size_t n_segments = fdp.ConsumeIntegralInRange(23, 27); + const std::string segment = token(); + for (size_t s = 0; s < n_segments; s++) { + input += "/"; + input += segment; + } + } else { + // Lengths crossing the 4096-byte gate (4080..4110). + const size_t target = fdp.ConsumeIntegralInRange(4080, 4110); + input = "/" + patterns[fdp.ConsumeIntegralInRange( + 0, patterns.size() - 1)]; + while (input.size() < target) { + input += "/"; + input += token(); + } + input.resize(target); + } + } else if (strategy == 7) { + // Raw input bytes (match() accepts arbitrary bytes). + input = fdp.ConsumeRandomLengthString(64); + } else { + // Instantiate a derived route (regexp routes: their literal prefix), + // then mutate. + const reference_route& base = + reference[fdp.ConsumeIntegralInRange(0, + reference.size() - 1)]; + if (base.info.segments.empty()) { + input = "/" + to_ascii(fdp.ConsumeRandomLengthString(40)); + } else { + for (const compiler::route_segment& segment : base.info.segments) { + if (segment.kind == compiler::segment_kind::literal) { + input += "/" + segment.text; + } else if (segment.kind == compiler::segment_kind::param) { + input += "/" + token(); + } else { + const size_t tail = fdp.ConsumeIntegralInRange(0, 3); + for (size_t t = 0; t < tail; t++) { + input += "/" + token(); + } + if (tail == 0) { + input += "/"; + } + } + } + } + switch (fdp.ConsumeIntegralInRange(0, 6)) { + case 0: + if (!input.empty()) { + input[fdp.ConsumeIntegralInRange(0, input.size() - 1)] = + static_cast('a' + fdp.ConsumeIntegralInRange(0, 25)); + } + break; + case 1: + input += '/'; + break; + case 2: + input.insert(fdp.ConsumeIntegralInRange(0, input.size()), + "/"); + break; + case 3: + input += "/" + token(); + break; + case 4: + if (const size_t slash = input.find_last_of('/'); + slash != std::string::npos && slash > 0) { + input.resize(slash); + } + break; + case 5: + // Flip the case of one byte (matters under ignore_case). + if (!input.empty()) { + char& c = + input[fdp.ConsumeIntegralInRange(0, input.size() - 1)]; + c = static_cast(static_cast(c) ^ 0x20); + } + break; + default: + break; + } + } + if (has_regexp_route && input.size() > 64) { + // Keep std::regex inputs short, as fuzz/url_pattern.cc does, to avoid + // catastrophic backtracking timeouts unrelated to url_pattern_list. + input.resize(64); + } + check_agreement(list, reference, patterns, input); + } + return 0; +} diff --git a/fuzz/url_pattern_list.options b/fuzz/url_pattern_list.options new file mode 100644 index 000000000..242847ced --- /dev/null +++ b/fuzz/url_pattern_list.options @@ -0,0 +1,5 @@ +[libfuzzer] +dict = url.dict +max_len = 512 +rss_limit_mb = 16000 +timeout = 60 diff --git a/include/ada.h b/include/ada.h index 613680115..e1eed1d41 100644 --- a/include/ada.h +++ b/include/ada.h @@ -58,6 +58,8 @@ #include "ada/url_pattern_helpers.h" #include "ada/url_pattern_helpers-inl.h" #include "ada/url_pattern_regex.h" +#include "ada/url_pattern_list.h" +#include "ada/url_pattern_list-inl.h" // Public API #include "ada/ada_version.h" diff --git a/include/ada/implementation-inl.h b/include/ada/implementation-inl.h index be0af7ab5..51e312a86 100644 --- a/include/ada/implementation-inl.h +++ b/include/ada/implementation-inl.h @@ -8,6 +8,7 @@ #include "ada/expected.h" #include "ada/implementation.h" +#include "ada/url_pattern_list.h" #include #include @@ -23,6 +24,94 @@ parse_url_pattern(std::variant&& input, return parser::parse_url_pattern_impl(std::move(input), base_url, options); } + +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span pathname_patterns, + const std::string_view* base_url, + const url_pattern_options* options) { + std::vector processed; + processed.reserve(pathname_patterns.size()); + for (const std::string_view pattern : pathname_patterns) { + if (base_url == nullptr) { + processed.emplace_back(pattern); + continue; + } + // With a base URL, each pattern is processed exactly as the pathname of + // a URLPatternInit would be: a relative pattern is resolved against the + // base URL's path (and an unparsable base URL is a type error). + url_pattern_init init{}; + init.pathname = std::string(pattern); + init.base_url = std::string(*base_url); + auto result = url_pattern_init::process( + init, url_pattern_init::process_type::pattern); + if (!result) { + return tl::unexpected(result.error()); + } + processed.push_back(std::move(result->pathname).value_or(std::string{})); + } + const bool ignore_case = options != nullptr && options->ignore_case; + auto list = url_pattern_list::create(std::move(processed), + ignore_case); + if (!list) { + return list; + } + // Routes outside the static/":param"/"*" subset are compiled as a + // URLPattern pathname component through the provider, with the same + // options parse_url_pattern would use (ignore_case included). + auto compile_options = url_pattern_compile_component_options::PATHNAME; + compile_options.ignore_case = ignore_case; + const url_pattern_list_detail::route_record* routes = + list->compiled_.template section( + list->compiled_.routes_offset); + for (size_t i = 0; i < list->patterns_.size(); i++) { + if (routes[i].regexp_component < 0) { + continue; + } + auto component = url_pattern_component::compile( + list->patterns_[i], url_pattern_helpers::canonicalize_pathname, + compile_options); + if (!component) { + return tl::unexpected(component.error()); + } + list->compiled_.group_names[i] = component->group_name_list; + list->regexp_components_.push_back(std::move(*component)); + } + return list; +} + +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span> patterns) { + std::vector texts; + texts.reserve(patterns.size()); + const bool ignore_case = !patterns.empty() && patterns[0].ignore_case(); + for (const url_pattern& pattern : patterns) { + if (pattern.ignore_case() != ignore_case) { + return tl::unexpected(errors::type_error); // one flag per list + } + texts.emplace_back(pattern.get_pathname()); + } + auto list = + url_pattern_list::create(std::move(texts), ignore_case); + if (!list) { + return list; + } + // Routes outside the subset reuse the pattern's already compiled pathname + // component: no second create_instance through the provider. + const url_pattern_list_detail::route_record* routes = + list->compiled_.template section( + list->compiled_.routes_offset); + for (size_t i = 0; i < patterns.size(); i++) { + if (routes[i].regexp_component < 0) { + continue; + } + list->compiled_.group_names[i] = + patterns[i].pathname_component.group_name_list; + list->regexp_components_.push_back(patterns[i].pathname_component); + } + return list; +} #endif // ADA_INCLUDE_URL_PATTERN } // namespace ada diff --git a/include/ada/implementation.h b/include/ada/implementation.h index c8b7c310b..b51ff8798 100644 --- a/include/ada/implementation.h +++ b/include/ada/implementation.h @@ -11,9 +11,10 @@ #ifndef ADA_IMPLEMENTATION_H #define ADA_IMPLEMENTATION_H +#include +#include #include #include -#include #include "ada/url.h" #include "ada/common_defs.h" @@ -141,6 +142,52 @@ ada_warn_unused tl::expected, errors> parse_url_pattern(std::variant&& input, const std::string_view* base_url = nullptr, const url_pattern_options* options = nullptr); + +template +class url_pattern_list; + +/** + * Compiles a set of URLPattern pathname patterns into an ada::url_pattern_list + * (experimental): one structure that answers "which route matches this + * pathname, and what are its group values?" without a loop over the + * patterns and without executing regular expressions for routes written in + * the static / ":param" / "*" subset. + * + * @tparam regex_provider The regex implementation, as for parse_url_pattern; + * it is used only for routes that need URLPattern regexp semantics. + * + * @param pathname_patterns URLPattern pathname patterns (valid UTF-8), for + * example "/users/:id". The index of a pattern is the route index + * reported by url_pattern_list::match; duplicates are allowed. + * @param base_url Optional pointer to a base URL string (valid UTF-8): each + * pattern is then processed as the pathname of a URLPatternInit with + * that base URL (relative patterns resolve against its path). + * @param options Optional pointer to configuration options (ignore_case), + * applied to every route as parse_url_pattern applies them. + * + * @return A `tl::expected` containing either the compiled list on success, + * or an error code when a pattern is not a valid URLPattern pathname + * pattern (errors::type_error, as the URLPattern constructor). + * + * @see https://github.com/whatwg/urlpattern/issues/166 + */ +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span pathname_patterns, + const std::string_view* base_url = nullptr, + const url_pattern_options* options = nullptr); + +/** + * Compiles the pathname components of existing url_pattern objects into an + * ada::url_pattern_list (experimental). Only the pathname component of each + * pattern is used; routes that need the regex provider reuse the pattern's + * already compiled pathname component. All patterns must share the same + * ignore_case setting (errors::type_error otherwise); the pathname is + * compiled as for a special-scheme URL. + */ +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span> patterns); #endif // ADA_INCLUDE_URL_PATTERN /** diff --git a/include/ada/parser-inl.h b/include/ada/parser-inl.h index b0e9949a3..7b5bb7763 100644 --- a/include/ada/parser-inl.h +++ b/include/ada/parser-inl.h @@ -202,6 +202,9 @@ tl::expected, errors> parse_url_pattern_impl( auto compile_options = url_pattern_compile_component_options::DEFAULT; if (options) { compile_options.ignore_case = options->ignore_case; + // Recorded on the pattern so that url_pattern::ignore_case() reports the + // option it was created with. + url_pattern_.ignore_case_ = options->ignore_case; } // TODO: Optimization opportunity: Simplify this if statement. diff --git a/include/ada/url_pattern_list-inl.h b/include/ada/url_pattern_list-inl.h new file mode 100644 index 000000000..bd6d1d758 --- /dev/null +++ b/include/ada/url_pattern_list-inl.h @@ -0,0 +1,656 @@ +/** + * @file url_pattern_list-inl.h + * @brief The url_pattern_list matcher (inline) and the class template's + * member definitions. + * + * The walk over the compiled tables lives here, as ada_really_inline + * functions, so that it inlines into url_pattern_list::match; the route-set + * compiler that builds the tables is in its own translation unit + * (src/url_pattern_list.cpp) and is not part of the public headers. + */ +#ifndef ADA_URL_PATTERN_LIST_INL_H +#define ADA_URL_PATTERN_LIST_INL_H + +#include "ada/common_defs.h" +#include "ada/url_pattern_list.h" +#include "ada/url_pattern-inl.h" +#include "ada/url_pattern_helpers.h" +#include "ada/url_pattern_helpers-inl.h" + +#include +#include +#include +#include +#include +#include +#include + +#if ADA_NEON +#include +#endif + +#if ADA_INCLUDE_URL_PATTERN +namespace ada::url_pattern_list_detail { + +// ---- byte helpers ---------------------------------------------------------- + +// Unaligned 8-byte little-endian load; all 8 bytes must be readable. The +// byte-assembly fallback keeps big-endian targets correct (the compiler +// lowers it to a single load plus byte swap). +ada_really_inline uint64_t load8_le(const char* p) noexcept { + if constexpr (std::endian::native == std::endian::little) { + uint64_t x; + std::memcpy(&x, p, 8); + return x; + } else { + uint64_t x = 0; + for (uint32_t j = 0; j < 8; j++) { + x |= static_cast(static_cast(p[j])) << (8 * j); + } + return x; + } +} + +// Bytes [0, n) of p packed little-endian, zero-padded; reads exactly n +// bytes (n < 8). This is the byte-compare path for short tails. +ada_really_inline uint64_t gather_le(const char* p, uint32_t n) noexcept { + uint64_t x = 0; + for (uint32_t j = 0; j < n; j++) { + x |= static_cast(static_cast(p[j])) << (8 * j); + } + return x; +} + +// Mask selecting the low n bytes, 1 <= n <= 7. +constexpr uint64_t low_bytes_mask(uint32_t n) noexcept { + return ~0ull >> (64 - 8 * n); +} + +// ASCII case folding of n bytes from src into dst (the compiler vectorizes +// this loop). Only ASCII letters change: canonical pathnames are ASCII, and +// this is what a case-insensitive regular expression does over them. +inline void ascii_fold(const char* src, uint32_t n, char* dst) noexcept { + for (uint32_t i = 0; i < n; i++) { + const uint8_t c = static_cast(src[i]); + dst[i] = static_cast(c | ((c >= 'A' && c <= 'Z') ? 0x20u : 0u)); + } +} + +// ---- witness machinery ----------------------------------------------------- +// Projection dispatch for wide trie nodes. A witness id names a feature of a +// byte string: +// 0..7 the byte at absolute offset id (0 when out of range) +// 8..15 the byte at offset id-8 counted from the end +// A projection is three witness bytes plus the length packed into one +// uint64; a perfect multiplier turns it into a slot index. Builder and +// matcher call the same accessors and the same packing, so a slot table +// cannot disagree with the gather that reads it. + +constexpr uint8_t length_byte(uint32_t len) noexcept { + return len < 255u ? static_cast(len) : static_cast(255); +} + +constexpr uint8_t witness_byte(const char* p, uint32_t len, + uint8_t id) noexcept { // ids 0..15 + uint32_t o = id & 7u; + bool in = o < len; + uint32_t pos = (id & 8u) ? len - 1u - o : o; // wraps when !in; masked then + pos = in ? pos : 0u; + uint8_t v = len ? static_cast(p[pos]) : static_cast(0); + return in ? v : static_cast(0); +} + +constexpr uint64_t pack_projection(uint8_t f0, uint8_t f1, uint8_t f2, + uint8_t f3) noexcept { + return (static_cast(f0) << 24) | (static_cast(f1) << 16) | + (static_cast(f2) << 8) | static_cast(f3); +} + +// Perfect-multiplier slot index; b <= 12 everywhere, so the shift is in +// range and the product needs no modulus. +constexpr uint64_t slot_of(uint64_t proj, uint64_t multiplier, + uint8_t b) noexcept { + return (proj * multiplier) >> (64 - b); +} + +// Fixed-shape projection of one string: always 3 witness bytes plus the +// length. Duplicated ids add no information but keep the gather uniform. +constexpr uint64_t project(const char* p, uint32_t len, + uint16_t plan) noexcept { + return pack_projection( + witness_byte(p, len, static_cast(plan & 15u)), + witness_byte(p, len, static_cast((plan >> 4) & 15u)), + witness_byte(p, len, static_cast((plan >> 8) & 15u)), + length_byte(len)); +} + +// ---- segment scan ---------------------------------------------------------- + +// Splits the pathname into segments in one pass, in place: only segment +// starts are recorded, with a sentinel soff[nseg] = ulen + 1 so that +// seg_len(i) == soff[i + 1] - soff[i] - 1. Returns the segment count, or 0 +// when the input has more than max_fast_path_segments segments (the caller +// falls back to the sequential matcher). Requires ulen >= 1 and +// url[0] == '/'. +// +// Two implementations with the same contract: the portable SWAR scan (8 +// bytes per step, an exact zero-byte test on x ^ '/'-fill with no false +// positives, bytes >= 0x80 included, and a byte-exact partial load for the +// tail, so the input is never over-read), and on AArch64 a NEON scan for +// inputs of 16 bytes or more (16 bytes per step, the '/' compare narrowed +// to one nibble per byte, the last block overlapping the previous one so +// the input is never over-read). The 1k+ pathname benchmark in the PR +// discussion is what keeps the NEON scan: it is faster at every length and +// 3-4x faster past 1 KB. +struct segment_emitter { + uint16_t* soff; + uint32_t nseg = 0; + uint32_t start = 1; + // One segment start per set bit of `lanes`; a lane is 1 << shift bits + // wide (8 for the SWAR masks, 4 for the NEON nibble masks). + ada_really_inline bool emit(uint64_t lanes, uint32_t base, + unsigned shift) noexcept { + using url_pattern_list_limits::max_fast_path_segments; + while (lanes) { + const uint32_t pos = + base + (static_cast(std::countr_zero(lanes)) >> shift); + if (nseg >= max_fast_path_segments) { + return false; + } + soff[nseg++] = static_cast(start); + start = pos + 1; + lanes &= lanes - 1; + } + return true; + } + ada_really_inline uint32_t finish(uint32_t ulen) noexcept { + using url_pattern_list_limits::max_fast_path_segments; + if (nseg >= max_fast_path_segments) { + return 0; + } + soff[nseg++] = static_cast(start); + soff[nseg] = static_cast(ulen + 1); // sentinel + return nseg; + } +}; + +ada_really_inline uint32_t scan_segments_swar(const char* url, uint32_t ulen, + uint16_t* soff) noexcept { + constexpr uint64_t slashes = 0x2F2F2F2F2F2F2F2Full; + constexpr uint64_t low7 = 0x7F7F7F7F7F7F7F7Full; + // Bit 7 of every byte of x that is zero, exactly: (b & 0x7F) + 0x7F has + // bit 7 set iff the low bits are non-zero, b itself has it set iff b is + // >= 0x80, and no lane can carry into its neighbour. + const auto zero_lanes = [](uint64_t x) noexcept { + return ~(((x & low7) + low7) | x | low7); + }; + segment_emitter out{soff}; + uint32_t i = 1; + for (; i + 8 <= ulen; i += 8) { + const uint64_t lanes = zero_lanes(load8_le(url + i) ^ slashes); + if (lanes != 0 && !out.emit(lanes, i, 3)) { + return 0; + } + } + if (i < ulen) { // tail of 1..7 bytes: zero padding is never a '/' + const uint64_t lanes = zero_lanes(gather_le(url + i, ulen - i) ^ slashes); + if (lanes != 0 && !out.emit(lanes, i, 3)) { + return 0; + } + } + return out.finish(ulen); +} + +#if ADA_NEON +ada_really_inline uint32_t scan_segments_neon(const char* url, uint32_t ulen, + uint16_t* soff) noexcept { + // Requires ulen >= 16. + const uint8x16_t slash = vdupq_n_u8('/'); + // One bit per byte lane that holds a '/': compare, narrow each 16-bit + // pair to its high nibble, keep bit 0 of every nibble. + const auto slash_lanes = [&](const char* p) noexcept { + const uint8x16_t v = vld1q_u8(reinterpret_cast(p)); + const uint64_t nibbles = + vget_lane_u64(vreinterpret_u64_u8(vshrn_n_u16( + vreinterpretq_u16_u8(vceqq_u8(v, slash)), 4)), + 0); + return nibbles & 0x1111111111111111ull; + }; + segment_emitter out{soff}; + uint32_t i = 0; + for (; i + 16 <= ulen; i += 16) { + uint64_t lanes = slash_lanes(url + i); + if (i == 0) { + lanes &= ~0xFull; // the leading '/' is not a separator + } + if (lanes != 0 && !out.emit(lanes, i, 2)) { + return 0; + } + } + if (i < ulen) { // overlapped last block: drop the lanes already scanned + const uint32_t off = ulen - 16; + const uint64_t lanes = slash_lanes(url + off) & (~0ull << ((i - off) * 4)); + if (lanes != 0 && !out.emit(lanes, off, 2)) { + return 0; + } + } + return out.finish(ulen); +} +#endif // ADA_NEON + +ada_really_inline uint32_t scan_segments(const char* url, uint32_t ulen, + uint16_t* soff) noexcept { +#if ADA_NEON + if (ulen >= 16) { + return scan_segments_neon(url, ulen, soff); + } +#endif + return scan_segments_swar(url, ulen, soff); +} + +// ---- verify / dispatch ----------------------------------------------------- + +// One input segment prepared for dispatch: its bytes, its length, and how +// many bytes are readable from p (to the end of the pathname). +struct segment_ref { + const char* p; + uint32_t len; + uint32_t avail; +}; + +// Segment-vs-edge compare. Keys of 8..16 bytes verify from two whole-word +// loads inside the segment; shorter keys from one masked load when the +// input has 8 readable bytes, else from a byte gather (short tails such as +// "42" or "me"); longer keys through memcmp against the blob. An empty key +// (a pattern such as "/users/") matches exactly the empty segment. +ada_really_inline bool verify_edge(const edge_record& e, const segment_ref& s, + const char* blob) noexcept { + const uint32_t len = s.len; + if (len != e.key_length) { + return false; + } + if (len == 0) { + return true; + } + if (len > 16) { + return std::memcmp(s.p, blob + e.key_offset, len) == 0; + } + if (len >= 8) { + return ((load8_le(s.p) ^ e.prefix) | + (load8_le(s.p + len - 8) ^ e.suffix)) == 0; + } + const uint64_t x = s.avail >= 8 ? (load8_le(s.p) & low_bytes_mask(len)) + : gather_le(s.p, len); + return x == e.prefix; +} + +// The typed view of the arena the walk reads; derived once per match. +struct table_view { + const node_record* nodes; + const hash_record* hashes; + const edge_record* edges; + const uint8_t* slots; + const char* blob; + const uint16_t* root_index; +}; + +ada_really_inline table_view view_of(const compiled_routes& r) noexcept { + return table_view{r.section(r.nodes_offset), + r.section(r.hashes_offset), + r.section(r.edges_offset), + r.section(r.slots_offset), + r.section(r.blob_offset), + r.section(r.root_index_offset)}; +} + +// Static-child dispatch: returns the child ordinal within the node or -1. +// Correctness never depends on the projection or the index: the candidate +// is always confirmed by a full segment compare, so a bad table can only +// cost time, never change an answer. +ada_really_inline int32_t dispatch_static(const table_view& t, + const node_record& nd, + const segment_ref& s) noexcept { + const edge_record* e = t.edges + nd.first_child; + switch (nd.dispatch) { + case 0: + return -1; + case 1: { // direct: up to 8 compares, each gated by the key length + for (uint32_t j = 0; j < nd.n_static; j++) { + if (verify_edge(e[j], s, t.blob)) { + return static_cast(j); + } + } + return -1; + } + case 2: { // projection: gather -> multiply -> slot -> verify + const hash_record& h = t.hashes[nd.hash_index]; + const uint64_t proj = project(s.p, s.len, h.witness_plan); + const uint8_t ord = + t.slots[h.slot_base + slot_of(proj, h.multiplier, h.slot_bits)]; + if (ord == 0xFF) { + return -1; + } + return verify_edge(e[ord], s, t.blob) ? static_cast(ord) : -1; + } + case 4: { // root first-byte index: jump to the run of children that + // share the segment's first byte (children are sorted by it) + if (s.len == 0) { + return -1; // no first byte; an indexed root has no empty key + } + const uint8_t b = static_cast(s.p[0]); + uint32_t j = t.root_index[b]; + if (j == 0xFFFF) { + return -1; + } + for (; j < nd.n_static && static_cast(e[j].prefix & 0xFFu) == b; + j++) { + if (verify_edge(e[j], s, t.blob)) { + return static_cast(j); + } + } + return -1; + } + default: { // linear demotion rung (also carries fanout > 254) + for (uint32_t j = 0; j < nd.n_static; j++) { + if (verify_edge(e[j], s, t.blob)) { + return static_cast(j); + } + } + return -1; + } + } +} + +// ---- the match residual ---------------------------------------------------- + +// Matches `pathname` against the compiled tables. When the input is out of +// the fast-path contract, returns with within_fast_path == false and no +// answer; the caller falls back to the sequential matcher. +ada_really_inline void match_compiled(const compiled_routes& r, const char* url, + uint32_t ulen, + engine_result& out) noexcept { + using url_pattern_list_limits::max_fast_path_pathname_length; + using url_pattern_list_limits::max_fast_path_segments; + out = engine_result{}; + if (ulen == 0 || url[0] != '/' || ulen > max_fast_path_pathname_length) { + out.within_fast_path = false; // the sequential fallback decides + return; + } + const table_view t = view_of(r); + const route_record* routes = r.section(r.routes_offset); + + // Segment scan; overflow means the input is beyond the fast path. + uint16_t soff[max_fast_path_segments + 1]; + const uint32_t nseg = scan_segments(url, ulen, soff); + if (nseg == 0) { + out.within_fast_path = false; + return; + } + const auto seg_len = [&](uint32_t i) { + return static_cast(soff[i + 1]) - soff[i] - 1u; + }; + const auto seg_ref = [&](uint32_t i) { + const uint32_t off = soff[i]; + return segment_ref{url + off, seg_len(i), ulen - off}; + }; + + // Trie walk with an explicit bounded backtrack stack. Every node offers + // the same three alternatives in the compiled priority order (static + // child, param child, wildcard); a backtrack record stores the resume + // point, and a record is pushed only when the node still has an untried + // alternative, so the stack never exceeds the walk depth. Edges encoded + // as -2 - route are pure-leaf shortcuts. + enum alternative : uint16_t { + alt_static = 0, + alt_param = 1, + alt_wild = 2, + alt_none = 3 + }; + struct backtrack { + int32_t node; + uint16_t i; + uint16_t resume; + }; + backtrack stack[max_fast_path_segments + 1]; + uint32_t sp = 0; + int32_t node = 0; + uint32_t i = 0; + uint32_t alt = alt_static; + int32_t route = -1; + uint32_t wild_from = 0; + bool wild = false; + for (;;) { + const node_record& nd = t.nodes[static_cast(node)]; + if (alt == alt_static) { + if (i == nseg) { + if (nd.terminal_route >= 0) { + route = nd.terminal_route; + break; + } + alt = alt_none; + } else { + const int32_t ord = dispatch_static(t, nd, seg_ref(i)); + if (ord >= 0) { + const int32_t nxt = t.edges[static_cast(nd.first_child) + + static_cast(ord)] + .node; + if (nxt >= 0) { + if (nd.has_alternative) { + stack[sp++] = {node, static_cast(i), alt_param}; + } + node = nxt; + i++; + continue; + } + // Pure-leaf shortcut. + if (i + 1 == nseg) { + route = -2 - nxt; + break; + } + alt = alt_param; // dead end: fall through to this node's fallbacks + } else { + alt = alt_param; + } + } + } + if (alt == alt_param) { + const int32_t pc = nd.param_child; + if (pc != -1 && seg_len(i) > 0) { + if (pc >= 0) { + if (nd.wild_route >= 0) { + stack[sp++] = {node, static_cast(i), alt_wild}; + } + node = pc; + i++; + alt = alt_static; + continue; + } + // Pure-leaf param shortcut. + if (i + 1 == nseg) { + route = -2 - pc; + break; + } + alt = alt_wild; // only the wildcard remains here + } else { + alt = alt_wild; + } + } + if (alt == alt_wild) { + if (nd.wild_route >= 0 && + wildcard_tail_ok(url + soff[i], ulen - soff[i])) { + route = nd.wild_route; + wild_from = i; + wild = true; + break; + } + // No alternative left at this node: resume from the backtrack stack + // (alt is overwritten by the popped record). + } + if (sp == 0) { + break; // nothing to resume: miss + } + --sp; + node = stack[sp].node; + i = stack[sp].i; + alt = stack[sp].resume; + } + + out.route = route; + if (route >= 0) { // capture extraction: params first, wildcard tail last + const route_record& rm = routes[static_cast(route)]; + uint32_t nc = 0; + for (uint32_t k = 0; k < rm.n_params; k++) { + const uint32_t p = rm.param_positions[k]; + out.captures[nc++] = {soff[p], seg_len(p)}; + } + if (wild) { + out.captures[nc++] = {soff[wild_from], ulen - soff[wild_from]}; + } + out.capture_count = nc; + } +} + +} // namespace ada::url_pattern_list_detail + +namespace ada { + +template +tl::expected, errors> +url_pattern_list::create( + std::vector&& pathname_patterns, bool ignore_case) { + url_pattern_list list{}; + list.patterns_ = std::move(pathname_patterns); + auto compiled = url_pattern_list_detail::compile_pathname_patterns( + list.patterns_, ignore_case); + if (!compiled) { + return tl::unexpected(compiled.error()); + } + list.compiled_ = std::move(*compiled); + return list; +} + +template +void url_pattern_list::consider_route( + uint32_t route_index, std::string_view pathname, std::string_view probe, + bool fold_input, url_pattern_list_detail::engine_result& best, + std::vector>& best_groups) const { + namespace detail = url_pattern_list_detail; + const detail::route_record* routes = + compiled_.section(compiled_.routes_offset); + const detail::route_record& candidate = routes[route_index]; + // Only test candidates that would outrank the current best. + if (best.route >= 0) { + const detail::route_record& current = + routes[static_cast(best.route)]; + if (!detail::outranks(candidate.kind_sequence, candidate.kind_length, + route_index, current.kind_sequence, + current.kind_length, + static_cast(best.route))) { + return; + } + } + if (candidate.mode == detail::route_mode::regexp) { + // A route whose certain segment shape does not fit the input cannot + // match at all: skip the provider. + if (!detail::match_regexp_shape(compiled_, route_index, probe, + fold_input)) { + return; + } + // URLPattern's own test/exec split: regex_match answers yes/no (this is + // what rejects a non-matching route cheaply; a regex_search miss can + // cost a scan over every start position), and regex_search then + // produces the group values exactly as url_pattern::exec does. + const url_pattern_component& component = + regexp_components_[static_cast(candidate.regexp_component)]; + if (!component.fast_test(pathname)) { + return; + } + auto groups = component.fast_match(pathname); + if (groups) { + best = detail::engine_result{}; + best.route = static_cast(route_index); + best_groups = std::move(*groups); + } + } else { + detail::engine_result scratch{}; + if (detail::match_route_sequential(compiled_, route_index, probe, + fold_input, scratch)) { + scratch.route = static_cast(route_index); + best = scratch; + best_groups.clear(); + } + } +} + +template +url_pattern_list_match_result url_pattern_list::match( + std::string_view pathname) const { + namespace detail = url_pattern_list_detail; + using url_pattern_list_limits::max_fast_path_pathname_length; + // With ignore_case the compiled literals are ASCII-folded, so the fast + // path probes a folded copy of the input (offsets are unchanged, so the + // captures still slice the original). Inputs beyond the fast-path length + // are folded on the fly by the sequential matcher instead. + char folded[max_fast_path_pathname_length]; + std::string_view probe = pathname; + bool fold_input = false; + if (compiled_.ignore_case) { + if (pathname.size() <= max_fast_path_pathname_length) { + detail::ascii_fold(pathname.data(), + static_cast(pathname.size()), folded); + probe = std::string_view(folded, pathname.size()); + } else { + fold_input = true; + } + } + detail::engine_result best{}; + std::vector> best_groups{}; + detail::match_compiled(compiled_, probe.data(), + static_cast(probe.size()), best); + const uint32_t* aux = compiled_.section(compiled_.aux_offset); + if (!best.within_fast_path) { + // The input exceeds a fast-path limit (length, segment count, or no + // leading '/'): the sequential fallback matches every route with + // identical priority semantics. + best = detail::engine_result{}; + for (size_t i = 0; i < patterns_.size(); i++) { + consider_route(static_cast(i), pathname, probe, fold_input, + best, best_groups); + } + } else if (best.route >= 0) { + // A fast-path winner is final except for the auxiliary routes recorded + // at creation as able to outrank it (usually none). + const detail::route_record& winner = + compiled_.section( + compiled_.routes_offset)[static_cast(best.route)]; + for (uint32_t k = 0; k < winner.challenger_count; k++) { + consider_route(aux[winner.challenger_first + k], pathname, probe, + fold_input, best, best_groups); + } + } else { + // A fast-path miss: any auxiliary route may still match. + for (uint32_t k = 0; k < compiled_.n_aux_all; k++) { + consider_route(aux[k], pathname, probe, fold_input, best, best_groups); + } + } + url_pattern_list_match_result result{}; + result.route_index = best.route; + if (best.route >= 0) { + const detail::route_record& winner = + compiled_.section( + compiled_.routes_offset)[static_cast(best.route)]; + if (winner.mode == detail::route_mode::regexp) { + result.regexp_route = true; + result.regexp_groups = std::move(best_groups); + } else { + result.capture_count = best.capture_count; + result.captures_truncated = best.captures_truncated; + result.captures = best.captures; + } + } + return result; +} + +} // namespace ada +#endif // ADA_INCLUDE_URL_PATTERN +#endif // ADA_URL_PATTERN_LIST_INL_H diff --git a/include/ada/url_pattern_list.h b/include/ada/url_pattern_list.h new file mode 100644 index 000000000..e076af255 --- /dev/null +++ b/include/ada/url_pattern_list.h @@ -0,0 +1,474 @@ +/** + * @file url_pattern_list.h + * @brief Compiled set-level matching over URLPattern pathname patterns. + * + * `ada::url_pattern_list` answers the question "which of my N routes matches + * this pathname, and what are the parameter values?" in a single walk + * instead of a loop of N `url_pattern::exec` calls. The whole route set is + * compiled once, by ada::parse_url_pattern_list, into a segment trie with + * per-node dispatch (direct compares for small fanouts, a first-byte index + * at the root, perfect-hash projection for wide nodes). Matching a pathname + * against the compiled set is allocation-free and does not execute any + * regular expression for routes written in the common static / ":param" / + * "*" subset; other routes are matched through the regex provider, exactly + * as ada::url_pattern does. + * + * This implements the route-set ("URLPatternList") use case discussed in + * https://github.com/whatwg/urlpattern/issues/166 for the pathname component. + * + * Correctness does not depend on the fast path: inputs or routes that exceed + * the fast-path limits (documented on the constants in + * `ada::url_pattern_list_limits`) are matched by a sequential fallback with + * identical priority semantics, never rejected and never answered wrongly. + * + * @see https://urlpattern.spec.whatwg.org/ + */ +#ifndef ADA_URL_PATTERN_LIST_H +#define ADA_URL_PATTERN_LIST_H + +#include "ada/common_defs.h" +#include "ada/errors.h" +#include "ada/expected.h" +#include "ada/url_pattern.h" + +#include +#include +#include +#include +#include +#include +#include + +#if ADA_INCLUDE_URL_PATTERN +namespace ada { + +/** + * The limits of the url_pattern_list fast path. None of them changes what a + * list matches: routes and inputs beyond a limit are matched by a sequential + * fallback with identical semantics. + * @namespace ada::url_pattern_list_limits + */ +namespace url_pattern_list_limits { + +/** + * Maximum number of captures (":param" groups plus one trailing "*" group) + * a route may declare and still be compiled into the fast path. Routes with + * more groups are matched by the sequential fallback; their match is still + * correct, but only the first `max_captures_per_route` captures are reported + * (see url_pattern_list_match_result::captures_truncated). + */ +inline constexpr uint32_t max_captures_per_route = 8; + +/** + * Maximum number of '/'-separated segments an input pathname may have and + * still be matched by the fast path. Longer inputs fall back to the + * sequential matcher and are still matched correctly. + */ +inline constexpr uint32_t max_fast_path_segments = 24; + +/** + * Maximum input pathname length (bytes) accepted by the fast path; segment + * offsets are tracked in 16-bit integers internally. Longer inputs fall back + * to the sequential matcher and are still matched correctly. + */ +inline constexpr uint32_t max_fast_path_pathname_length = 4096; + +/** + * Maximum number of pattern segments a route may have and still be compiled + * into the trie fast path. Deeper routes are matched by the sequential + * fallback with identical semantics. + */ +inline constexpr uint32_t max_trie_pattern_segments = 16; + +} // namespace url_pattern_list_limits + +/** + * The result of url_pattern_list::match. A winning route reports its group + * values in one of two forms, mirroring url_pattern_list::group_names(): + * + * - Routes in the static / ":param" / "*" subset report zero-allocation + * captures: (offset, length) slices of the pathname passed to match, in + * capture order (":param" groups left to right, then the "*" group). + * `regexp_route` is false and `regexp_groups` is empty. + * - Routes matched through the regex provider (patterns with custom regexp + * groups or "?" / "+" / "*" modifiers) report the group values the + * provider's regex_search returned, exactly as url_pattern::exec would for + * the pathname component: `regexp_route` is true, `regexp_groups[i]` is the + * value of group_names(route_index)[i] (nullopt for a group that did not + * participate), and capture_count is 0. + * + * Routes with more than url_pattern_list_limits::max_captures_per_route + * groups report only the first max_captures_per_route slices and set + * captures_truncated. + */ +struct url_pattern_list_match_result { + /** One capture: an (offset, length) slice of the matched pathname. */ + struct capture { + uint32_t offset = 0; + uint32_t length = 0; + }; + /** Index of the winning route in the creation order, or -1 for no match. */ + int32_t route_index = -1; + /** Number of valid entries in captures. */ + uint32_t capture_count = 0; + /** True when the route has more groups than could be reported as slices. */ + bool captures_truncated = false; + /** True when the winning route was matched through the regex provider; its + * group values are in regexp_groups. */ + bool regexp_route = false; + std::array + captures{}; + /** Group values of a regexp route, as returned by the provider. */ + std::vector> regexp_groups{}; + + [[nodiscard]] bool has_match() const noexcept { return route_index >= 0; } +}; + +/** + * @private + * The compiled tables url_pattern_list::match walks, and the inline matcher + * over them (url_pattern_list-inl.h). Nothing here is part of the supported + * API. The route-set compiler that produces these tables is not part of the + * public headers at all (src/url_pattern_list_compiler.h). + * @namespace ada::url_pattern_list_detail + */ +namespace url_pattern_list_detail { + +/** @private One trie node. Nodes with a projection dispatch keep their hash + * payload in a separate hash_record, so the common 0/1/direct nodes stay at + * 24 bytes. */ +struct node_record { + // First static edge (edges are laid out per node, contiguously). + int32_t first_child = 0; + // Child node index, -2 - route (pure-leaf shortcut), or -1. + int32_t param_child = -1; + // Route index of a "*" ending at this prefix, or -1. + int32_t wild_route = -1; + // Route ending exactly here, or -1. + int32_t terminal_route = -1; + uint16_t n_static = 0; + // 0 none, 1 direct compares, 2 projection, 3 linear scan, 4 first-byte + // index (root only). + uint8_t dispatch = 0; + // 1 if the node still has a param/wildcard alternative to backtrack to. + uint8_t has_alternative = 0; + // Index of the node's hash_record (dispatch == 2 only). + uint32_t hash_index = 0; +}; + +/** @private Projection-dispatch payload of a hashed node. */ +struct hash_record { + uint64_t multiplier = 0; + uint32_t slot_base = 0; + // Witness plan: 3 nibble ids | count << 12 | use_len << 14. + uint16_t witness_plan = 0; + // Slot table size == 1 << slot_bits. + uint8_t slot_bits = 0; + uint8_t reserved = 0; +}; + +/** @private One static edge; keys <= 16 bytes verify from the packed + * windows alone. */ +struct edge_record { + // Key bytes [0, min(8, len)), little-endian, zero-padded. + uint64_t prefix = 0; + // Key bytes [max(0, len - 8), len), little-endian, zero-padded. + uint64_t suffix = 0; + // Child node index; negative values encode -2 - route pure-leaf shortcuts. + int32_t node = -1; + // Full key in the blob (read only when key_length > 16). + uint32_t key_offset = 0; + uint16_t key_length = 0; +}; + +/** @private How one route of the set is matched. */ +enum class route_mode : uint8_t { + // Compiled into the trie fast path. + trie = 0, + // Safe static/":param"/"*" syntax beyond a fast-path build limit: matched + // by the sequential segment matcher. + sequential = 1, + // Needs URLPattern regexp semantics: matched through the regex provider. + regexp = 2, +}; + +/** @private Everything the matcher knows about one route after a hit. */ +struct route_record { + // Packed per-segment kinds, two bits per segment, most significant first; + // (kind_sequence, kind_length, route index) compared as a tuple is the + // specificity order. + uint64_t kind_sequence = 0; + // Sequential-mode routes: their segments in the segment table. + uint32_t segment_first = 0; + uint32_t segment_count = 0; + // Auxiliary routes that can outrank this route when it wins the fast + // path: a range of the aux table (usually empty). + uint32_t challenger_first = 0; + uint32_t challenger_count = 0; + // Ordinal among the regexp-mode routes, or -1. + int32_t regexp_component = -1; + uint8_t kind_length = 0; + route_mode mode = route_mode::sequential; + // Trie-mode routes: capture positions. + uint8_t n_params = 0; + uint8_t wild = 0; + std::array + param_positions{}; +}; + +/** @private One pattern segment of a sequential-mode route. */ +struct segment_record { + uint32_t text_offset = 0; + uint32_t text_length = 0; + // 0 literal, 1 ":param", 2 "*". + uint8_t kind = 0; +}; + +/** + * @private + * The compiled route set: one arena holding every table, addressed by + * section offsets, plus the per-route group names. Sections are 8-byte + * aligned; the blob is zero-padded by 8 bytes so short key windows can be + * loaded whole. + */ +struct compiled_routes { + std::vector arena{}; + std::vector> group_names{}; + uint32_t nodes_offset = 0; + uint32_t hashes_offset = 0; + uint32_t edges_offset = 0; + uint32_t slots_offset = 0; + uint32_t blob_offset = 0; + uint32_t routes_offset = 0; + uint32_t segments_offset = 0; + uint32_t aux_offset = 0; + uint32_t root_index_offset = 0; + uint32_t n_routes = 0; + // All auxiliary routes (regexp mode and sequential mode) in insertion + // order: the aux table's first n_aux_all entries; challenger ranges + // follow. + uint32_t n_aux_all = 0; + uint8_t ignore_case = 0; + + template + [[nodiscard]] const T* section(uint32_t offset) const noexcept { + return reinterpret_cast(arena.data() + offset); + } +}; + +/** @private Result of the fast-path engine or of the sequential matcher. */ +struct engine_result { + using capture = url_pattern_list_match_result::capture; + int32_t route = -1; + uint32_t capture_count = 0; + // False when the input exceeded a fast-path limit and the caller must run + // the sequential fallback over the whole route set. + bool within_fast_path = true; + bool captures_truncated = false; + std::array + captures{}; +}; + +/** + * @private + * Parses and compiles a set of pathname patterns (already base-URL + * processed). Routes that need the regex provider come back with + * route_record::regexp_component set to their ordinal in insertion order; + * the caller compiles those components and fills their group names. + */ +tl::expected compile_pathname_patterns( + std::span patterns, bool ignore_case); + +/** + * @private + * Sequential reference matcher for one non-regexp route of the compiled set; + * matches any input (no fast-path limits) with the same semantics as the + * compiled engine. With `fold_input`, input bytes are ASCII case-folded on + * the fly (the compiled literals are already folded). On a match, fills + * `result` (route index is NOT set) and returns true. + */ +bool match_route_sequential(const compiled_routes& tables, uint32_t route, + std::string_view pathname, bool fold_input, + engine_result& result) noexcept; + +/** + * @private + * True when the bytes [p, p + n) hold no line terminator. A "*" segment + * compiles to "(.*)" in the URLPattern regexp, and "." in an ECMAScript + * regular expression does not match LF or CR, so a wildcard may not capture + * past one. Deliberately not inlined into the walk (it costs the static and + * ":param" paths, which never run it), and deliberately defined in + * url_pattern_list.cpp rather than here: MSVC's ada_never_inline carries no + * inline linkage, so a definition in this header is emitted in every + * translation unit. + */ +bool wildcard_tail_ok(const char* p, uint32_t n) noexcept; + +/** + * @private + * Cheap pre-check for a regexp-mode route before running the provider: the + * part of the route's segment shape that is certain (see the compiler's + * approximate_kind_sequence) must fit the pathname -- its anchored literal + * segments, and its exact segment count when every group is a ":name" + * segment wildcard. False proves the regular expression cannot match. + */ +bool match_regexp_shape(const compiled_routes& tables, uint32_t route, + std::string_view pathname, bool fold_input) noexcept; + +/** + * @private + * True when route `a` (at insertion index `a_index`) outranks route `b` (at + * `b_index`): lexicographically smaller kind sequence, insertion order as the + * tiebreak. + */ +constexpr bool outranks(uint64_t a_sequence, uint32_t a_length, size_t a_index, + uint64_t b_sequence, uint32_t b_length, + size_t b_index) noexcept { + if (a_sequence != b_sequence) { + return a_sequence < b_sequence; + } + if (a_length != b_length) { + return a_length < b_length; + } + return a_index < b_index; +} + +} // namespace url_pattern_list_detail + +template +class url_pattern_list; + +/** + * @private + * url_pattern_list befriends these two specializations, so they have to be + * declared before it: a friend declaration that redeclares the function + * template instead is not matched by every compiler we support (Apple clang + * 15 rejects the resulting member access). The documented declarations are + * in implementation.h, next to parse_url_pattern, and carry the default + * arguments: this header includes url_pattern.h, which includes + * implementation.h, so those declarations are always seen first, and a + * function template may not gain default arguments in a later declaration. + */ +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span pathname_patterns, + const std::string_view* base_url, + const url_pattern_options* options); + +/** @private See above. */ +template +ada_warn_unused tl::expected, errors> +parse_url_pattern_list(std::span> patterns); + +/** + * @brief A compiled set of URLPattern pathname patterns with one-shot + * matching. + * + * Build once with ada::parse_url_pattern_list, then call match() per + * request. Matching within the fast-path limits is allocation-free and + * regex-free for routes written in the static / ":param" / "*" subset; other + * routes are matched through the regex provider (the same regex_search call + * url_pattern::exec makes) and still participate in the priority order. + * + * Match priority is specificity order, not registration order: routes are + * compared by their per-segment kind sequence (literal < ":param" < "*", + * lexicographically from the first segment), with insertion order breaking + * ties. This is the priority scheme of find-my-way (Fastify) and Express- + * style routers. Whether a standardized URLPatternList should instead use + * pure first-match-in-insertion-order semantics is an open question; the + * compiled representation supports either, and the priority rule is + * deliberately centralized in url_pattern_list_detail::outranks. + * + * Scope (v1): the pathname component only. Other URL components are treated + * as fully wildcarded; match() takes an already-extracted pathname (for + * example ada::url_aggregator::get_pathname()). Inputs are matched as given + * and are expected to be in canonical (percent-encoded) form; the pattern + * side is canonicalized by the URLPattern pattern parser at creation. The + * subset follows the URLPattern regexp it stands for: a ":param" segment + * ("[^/]+?") is one non-empty segment, and a "*" tail ("(.*)") matches any + * bytes except the line terminators LF and CR, which "." never matches. + * + * With url_pattern_options::ignore_case, regexp routes are compiled through + * the provider with the flag set (as url_pattern does), and the static / + * ":param" / "*" subset compares literal segments with ASCII case folding, + * which is what a case-insensitive regular expression does over the ASCII + * text of a canonical pathname. + * + * @tparam regex_provider The regex implementation used only for routes that + * need URLPattern regexp semantics. Must satisfy + * url_pattern_regex::regex_concept. + */ +template +class url_pattern_list { + public: + url_pattern_list() = default; + + /** + * Matches a pathname against the set and returns the winning route with + * its captures (see url_pattern_list_match_result). The fast path is + * allocation-free; inputs beyond the fast-path limits are matched by the + * sequential fallback with identical semantics. + */ + [[nodiscard]] url_pattern_list_match_result match( + std::string_view pathname) const; + + /** Number of routes in the set. */ + [[nodiscard]] size_t size() const noexcept { return patterns_.size(); } + + /** The pathname pattern string the route at `route_index` was created + * from (after base URL processing, if a base URL was given). */ + [[nodiscard]] std::string_view pattern(size_t route_index) const + ada_lifetime_bound { + return patterns_[route_index]; + } + + /** + * Capture group names of the route at `route_index`, in the order of + * url_pattern_list_match_result::captures / regexp_groups ("*" groups have + * numeric names, per URLPattern). + */ + [[nodiscard]] const std::vector& group_names( + size_t route_index) const ada_lifetime_bound { + return compiled_.group_names[route_index]; + } + + /** The ignore_case option the set was created with. */ + [[nodiscard]] bool ignore_case() const noexcept { + return compiled_.ignore_case != 0; + } + + friend tl::expected + parse_url_pattern_list( + std::span pathname_patterns, + const std::string_view* base_url, const url_pattern_options* options); + + friend tl::expected parse_url_pattern_list< + regex_provider>(std::span> patterns); + + private: + /** @private Compiles the (already processed) pattern strings; the regexp + * components are then supplied by the caller in insertion order. */ + static tl::expected create( + std::vector&& pathname_patterns, bool ignore_case); + + /** @private Tests one auxiliary route (sequential or regexp mode) and, + * when it matches and outranks `best`, replaces `best` with it. `probe` + * is the (possibly case-folded) pathname for the sequential matcher, + * `pathname` the original for the provider. */ + void consider_route( + uint32_t route_index, std::string_view pathname, std::string_view probe, + bool fold_input, url_pattern_list_detail::engine_result& best, + std::vector>& best_groups) const; + + /** @private The pattern strings, by route index. */ + std::vector patterns_{}; + /** @private The compiled tables. */ + url_pattern_list_detail::compiled_routes compiled_{}; + /** @private Compiled pathname components of the regexp-mode routes, in + * insertion order. */ + std::vector> regexp_components_{}; +}; + +} // namespace ada +#endif // ADA_INCLUDE_URL_PATTERN +#endif // ADA_URL_PATTERN_LIST_H diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c1a6e1d2c..f3a0512ce 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -7,7 +7,7 @@ target_include_directories(ada-include-source INTERFACE $/ada.cpp) target_link_libraries(ada-source INTERFACE ada-include-source) -add_library(ada ada.cpp unicode_percent_encode.cpp) +add_library(ada ada.cpp unicode_percent_encode.cpp url_pattern_list.cpp) target_compile_features(ada PUBLIC cxx_std_20) target_include_directories(ada PRIVATE $ ) target_include_directories(ada PUBLIC "$") @@ -64,6 +64,14 @@ endif() # inlining budget for setters matches main. target_compile_definitions(ada PRIVATE ADA_PERCENT_ENCODE_SIMD_SEPARATE_TU=1) +# Keep the URLPattern route-set compiler (the builder, src/url_pattern_list.cpp) +# in its own TU for the same reason: the extra code in the unity TU reshuffles +# GCC's unit-wide inlining budget and de-inlines url_aggregator setter hot +# paths (CodSpeed: SetHash -12%). The matcher itself is inline in the public +# header so it inlines into url_pattern_list::match. The amalgamated +# single-file build still includes the builder inline. +target_compile_definitions(ada PRIVATE ADA_URL_PATTERN_LIST_SEPARATE_TU=1) + if(ADA_INCLUDE_URL_PATTERN) target_compile_definitions(ada PRIVATE ADA_INCLUDE_URL_PATTERN=1) else() diff --git a/src/ada.cpp b/src/ada.cpp index 33ed25b2b..784274734 100644 --- a/src/ada.cpp +++ b/src/ada.cpp @@ -16,6 +16,9 @@ #include "url_pattern.cpp" #include "url_pattern_helpers.cpp" #include "url_pattern_regex.cpp" +#if !defined(ADA_URL_PATTERN_LIST_SEPARATE_TU) +#include "url_pattern_list.cpp" +#endif #endif // ADA_INCLUDE_URL_PATTERN #include "ada_c.cpp" diff --git a/src/url_pattern_list.cpp b/src/url_pattern_list.cpp new file mode 100644 index 000000000..d13e0ccdb --- /dev/null +++ b/src/url_pattern_list.cpp @@ -0,0 +1,1107 @@ +/** + * @file url_pattern_list.cpp + * @brief Route-set compiler backing ada::url_pattern_list. + * + * The provider-independent build side: classification of URLPattern part + * lists into pattern segments, the compiler (segment trie, per-node dispatch, + * auxiliary-route pruning), the arena packing, and the sequential reference + * matcher used beyond the fast-path limits. The matcher itself is inline in + * url_pattern_list-inl.h. + * + * Every dispatch decision is made at build time so that the per-request + * residual is a segment scan and a trie walk with bounded backtracking. + * Offline searches (witness plans, perfect multipliers) that fail demote the + * affected node to a linear scan: slower, never incorrect. + */ +#include "ada.h" +#include "url_pattern_list_compiler.h" + +#include +#include +#include +#include + +#if ADA_INCLUDE_URL_PATTERN + +namespace ada::url_pattern_list_compiler { + +namespace { + +using url_pattern_list_detail::compiled_routes; +using url_pattern_list_detail::edge_record; +using url_pattern_list_detail::hash_record; +using url_pattern_list_detail::node_record; +using url_pattern_list_detail::route_mode; +using url_pattern_list_detail::route_record; +using url_pattern_list_detail::segment_record; +using url_pattern_list_limits::max_captures_per_route; +using url_pattern_list_limits::max_trie_pattern_segments; + +// The tables under construction, one vector per section; packed into the +// single arena of compiled_routes at the end of the build. +struct builder_tables { + std::vector nodes{}; + std::vector hashes{}; + std::vector edges{}; + std::vector slots{}; + std::vector blob{}; + std::vector routes{}; + std::vector segments{}; + std::vector aux{}; + std::vector root_index{}; + uint32_t n_aux_all = 0; +}; + +// Witness plan for single-string tables: nibbles 0..2 hold witness ids +// (distinct ids first, remaining slots duplicating id 0 so the runtime +// gather is fixed-shape and branch-free); bits 12..14 are metadata. +constexpr uint16_t pack_witness_plan(const uint8_t* ids, uint32_t n_ids, + bool use_len) noexcept { + uint16_t w = 0; + for (uint32_t j = 0; j < 3; j++) { + w = static_cast( + w | static_cast((j < n_ids ? ids[j] : ids[0]) & 15u) + << (4 * j)); + } + w = static_cast(w | static_cast(n_ids << 12)); + if (use_len) { + w = static_cast(w | static_cast(1) << 14); + } + return w; +} + +// Bytes [from, min(from + 8, len)) of a key, packed little-endian and +// zero-padded; the build-time twin of the matcher's window load. +constexpr uint64_t pack_window_le(const char* p, uint32_t len, + uint32_t from) noexcept { + uint64_t x = 0; + for (uint32_t j = 0; j < 8 && from + j < len; j++) { + x |= static_cast(static_cast(p[from + j])) << (8 * j); + } + return x; +} + +// Deterministic RNG for the multiplier search (splitmix64). +struct splitmix64 { + uint64_t state; + constexpr explicit splitmix64(uint64_t seed) noexcept : state(seed) {} + constexpr uint64_t next() noexcept { + uint64_t z = (state += 0x9E3779B97F4A7C15ull); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBull; + return z ^ (z >> 31); + } +}; + +struct transient_node { // build-time trie (views into route segment storage) + std::vector> kids{}; + int32_t param = -1; + int32_t wild_route = -1; + int32_t terminal = -1; +}; + +constexpr unsigned ceil_log2(size_t n) noexcept { + unsigned b = 0; + while ((size_t{1} << b) < n) { + b++; + } + return b; +} + +// Starting table size for n keys: load factor <= ~1/4, loosened as n grows +// so the multiplier search still lands inside the budget. +constexpr unsigned initial_slot_bits(size_t n) noexcept { + unsigned headroom = 1; + if (n > 64) { + headroom = 3; + } else if (n > 8) { + headroom = 2; + } + return ceil_log2(n) + headroom; +} + +bool find_multiplier(const std::vector& proj, unsigned b0, + uint64_t& multiplier_out, uint8_t& bits_out) { + const size_t n = proj.size(); + for (unsigned b = b0; b <= 12; b++) { + std::vector stamp(size_t{1} << b, 0); + splitmix64 rng(0x14C0FFEEull + b); + for (uint32_t t = 1; t <= 4000; t++) { + const uint64_t m = rng.next() | 1; + bool ok = true; + for (size_t i = 0; i < n; i++) { + const uint64_t s = (proj[i] * m) >> (64 - b); + if (stamp[s] == t) { + ok = false; + break; + } + stamp[s] = t; + } + if (ok) { + multiplier_out = m; + bits_out = static_cast(b); + return true; + } + } + } + return false; +} + +// Emits a perfect-hash slot table for `proj` at the end of `slots`. Returns +// false if the search failed, in which case nothing was appended and the +// caller demotes that table to a linear scan. +bool emit_slot_table(const std::vector& proj, + std::vector& slots, uint64_t& multiplier_out, + uint8_t& bits_out, uint32_t& base_out) { + if (!find_multiplier(proj, initial_slot_bits(proj.size()), multiplier_out, + bits_out)) { + return false; + } + base_out = static_cast(slots.size()); + slots.resize(slots.size() + (size_t{1} << bits_out), 0xFF); + for (size_t i = 0; i < proj.size(); i++) { + slots[base_out + + url_pattern_list_detail::slot_of(proj[i], multiplier_out, bits_out)] = + static_cast(i); + } + return true; +} + +// Advances `c` to the next k-combination of {0..n-1} in lexicographic +// order; false once the last one has been visited. +template +constexpr bool next_combination(std::array& c, uint32_t k, + uint32_t n) noexcept { + int32_t j = static_cast(k) - 1; + while (j >= 0 && + c[static_cast(j)] == n - k + static_cast(j)) { + j--; + } + if (j < 0) { + return false; + } + c[static_cast(j)]++; + for (uint32_t t = static_cast(j) + 1; t < k; t++) { + c[t] = c[t - 1] + 1; + } + return true; +} + +// Witness-subset search over the given id alphabet, cost-ordered: fewest +// distinct witness ids first (the length is always packed -- it is free). +// The first injective subset wins. +bool find_witness_plan( + const std::vector>& kids, + const uint8_t* alphabet, uint32_t n_alpha, uint32_t max_reads, + uint16_t& plan_out) { + const size_t n = kids.size(); + auto injective = [&](uint16_t wp) { // sort + adjacent compare + std::vector proj(n); + for (size_t i = 0; i < n; i++) { + proj[i] = url_pattern_list_detail::project( + kids[i].first.data(), static_cast(kids[i].first.size()), + wp); + } + std::sort(proj.begin(), proj.end()); + for (size_t i = 1; i < n; i++) { + if (proj[i] == proj[i - 1]) { + return false; + } + } + return true; + }; + for (uint32_t k = 1; k <= max_reads && k <= n_alpha; k++) { + std::array c{0, 1, 2}; + do { + uint8_t ids[3] = {0, 0, 0}; + for (uint32_t j = 0; j < k; j++) { + ids[j] = alphabet[c[j]]; + } + const uint16_t wp = pack_witness_plan(ids, k, true); + if (injective(wp)) { + plan_out = wp; + return true; + } + } while (next_combination(c, k, n_alpha)); + } + return false; +} + +// ---- build stages ---------------------------------------------------------- + +// Segment trie over the trie-mode routes. Priority is resolved here, at +// build, into each node's fixed decision order (static children -> param -> +// wildcard); a DFS in that order returns the match with the +// lexicographically minimal kind sequence. Duplicate patterns collapse onto +// one slot: an occupied slot is never overwritten, so the smallest route +// index survives. +std::vector build_trie(const std::vector& routes) { + std::vector t(1); + for (size_t i = 0; i < routes.size(); i++) { + const route_info& rt = routes[i]; + if (rt.mode != route_mode::trie) { + continue; + } + int32_t cur = 0; + for (const route_segment& ps : rt.segments) { + if (ps.kind == segment_kind::literal) { + int32_t nxt = -1; + for (auto& kv : t[static_cast(cur)].kids) { + if (kv.first == ps.text) { + nxt = kv.second; + break; + } + } + if (nxt < 0) { + nxt = static_cast(t.size()); + t[static_cast(cur)].kids.emplace_back(ps.text, nxt); + t.emplace_back(); + } + cur = nxt; + } else if (ps.kind == segment_kind::param) { + if (t[static_cast(cur)].param < 0) { + t[static_cast(cur)].param = static_cast(t.size()); + t.emplace_back(); + } + cur = t[static_cast(cur)].param; + } else { // wildcard (classification guarantees final) + if (t[static_cast(cur)].wild_route < 0) { + t[static_cast(cur)].wild_route = static_cast(i); + } + cur = -1; + break; + } + } + if (cur >= 0 && t[static_cast(cur)].terminal < 0) { + t[static_cast(cur)].terminal = static_cast(i); + } + } + // The root's children are sorted by first byte so that the first-byte + // index can address each run of same-byte children as one contiguous + // block of edges. + std::stable_sort( + t[0].kids.begin(), t[0].kids.end(), [](const auto& a, const auto& b) { + const int ka = a.first.empty() ? -1 : static_cast(a.first[0]); + const int kb = b.first.empty() ? -1 : static_cast(b.first[0]); + return ka < kb; + }); + return t; +} + +// Per-route records: priority data, capture positions (so extracting params +// at match time is a fixed walk over param_positions), regexp ordinals. +void compile_route_records(builder_tables& r, + const std::vector& routes) { + r.routes.resize(routes.size()); + int32_t n_regexp = 0; + for (size_t i = 0; i < routes.size(); i++) { + const route_info& rt = routes[i]; + route_record& rm = r.routes[i]; + rm.kind_sequence = rt.kind_sequence; + rm.kind_length = rt.kind_length; + rm.mode = rt.mode; + rm.wild = rt.has_wildcard ? 1 : 0; + if (rt.mode == route_mode::regexp) { + rm.regexp_component = n_regexp++; + continue; + } + if (rt.mode != route_mode::trie) { + continue; // capture positions are only read for trie-answered routes + } + for (size_t s = 0; s < rt.segments.size(); s++) { + if (rt.segments[s].kind == segment_kind::param) { + // Classification guarantees n_params <= max_captures_per_route for + // trie-mode routes. + rm.param_positions[rm.n_params++] = static_cast(s); + } + } + } +} + +// BFS layout: assign final node ids breadth-first so each node's static +// children occupy a contiguous block of edges[]. Returns the transient-node +// id per final id. +std::vector layout_nodes(builder_tables& r, + const std::vector& t) { + std::vector order; // transient id per final id + order.push_back(0); + r.nodes.resize(1); + for (size_t head = 0; head < order.size(); head++) { + const transient_node& tn = t[static_cast(order[head])]; + node_record nd{}; + nd.wild_route = tn.wild_route; + nd.terminal_route = tn.terminal; + nd.first_child = static_cast(r.edges.size()); + nd.n_static = static_cast(tn.kids.size()); + const size_t id_base = order.size(); // children take the next BFS ids + for (size_t ci = 0; ci < tn.kids.size(); ci++) { + const std::string_view k = tn.kids[ci].first; + edge_record e{}; + e.key_length = static_cast(k.size()); + e.key_offset = static_cast(r.blob.size()); + r.blob.insert(r.blob.end(), k.begin(), k.end()); + e.prefix = pack_window_le(k.data(), static_cast(k.size()), 0); + e.suffix = pack_window_le( + k.data(), static_cast(k.size()), + k.size() >= 8 ? static_cast(k.size()) - 8 : 0); + e.node = static_cast(id_base + ci); + r.edges.push_back(e); + } + for (size_t ci = 0; ci < tn.kids.size(); ci++) { + order.push_back(tn.kids[ci].second); + } + if (tn.param >= 0) { + nd.param_child = static_cast(order.size()); + order.push_back(tn.param); + } + nd.has_alternative = (nd.param_child >= 0 || nd.wild_route >= 0) ? 1 : 0; + r.nodes.resize(order.size()); + r.nodes[head] = nd; + } + return order; +} + +// Root first-byte index: a 256-entry table from the first byte of the +// input's first segment to the first root child starting with it (the +// children are sorted by first byte). Built for a root with at least three +// children; a run of same-byte children longer than max_direct_children, or +// an empty key, falls back to the regular ladder. Returns true when the +// index was emitted. +bool build_root_index(builder_tables& r, const transient_node& root) { + const size_t nk = root.kids.size(); + if (nk < 3) { + return false; + } + std::array index{}; + index.fill(0xFFFF); + std::array run{}; + for (size_t ci = 0; ci < nk; ci++) { + if (root.kids[ci].first.empty()) { + return false; // an empty key has no first byte: regular ladder + } + const uint8_t b = static_cast(root.kids[ci].first[0]); + if (index[b] == 0xFFFF) { + index[b] = static_cast(ci); + } + if (++run[b] > max_direct_children) { + return false; + } + } + r.root_index.assign(index.begin(), index.end()); + return true; +} + +// Per-node dispatch ladder, chosen by static child count: 0 none, up to +// max_direct_children direct compares, then projection over a restricted +// alphabet (escalating to the full alphabet); linear demotion when a search +// fails or when the fanout exceeds max_dispatch_table_entries. The root +// gets the first-byte index when its fanout allows. Never incorrect, only +// slower. +void compile_dispatch(builder_tables& r, const std::vector& t, + const std::vector& order) { + for (size_t ni = 0; ni < r.nodes.size(); ni++) { + node_record& nd = r.nodes[ni]; + const transient_node& tn = t[static_cast(order[ni])]; + const size_t nk = tn.kids.size(); + if (nk == 0) { + nd.dispatch = 0; + continue; + } + if (ni == 0 && build_root_index(r, tn)) { + nd.dispatch = 4; + continue; + } + if (nk <= max_direct_children) { + nd.dispatch = 1; + continue; + } + if (nk > max_dispatch_table_entries) { + nd.dispatch = 3; // slot ordinals are 8-bit; linear scan stays correct + continue; + } + uint16_t wp = 0; + bool found = false; + if (nk <= 16) { + constexpr uint8_t alpha_small[2] = {0, 8}; // s[0], s[len - 1] + found = find_witness_plan(tn.kids, alpha_small, 2, 2, wp); + } + if (!found) { // full alphabet (also the escalation path for <= 16) + uint8_t alpha_full[16] = {0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15}; + found = find_witness_plan(tn.kids, alpha_full, 16, 3, wp); + } + hash_record h{}; + if (found) { + std::vector proj(nk); + for (size_t i = 0; i < nk; i++) { + proj[i] = url_pattern_list_detail::project( + tn.kids[i].first.data(), + static_cast(tn.kids[i].first.size()), wp); + } + found = emit_slot_table(proj, r.slots, h.multiplier, h.slot_bits, + h.slot_base); + } + if (!found) { + nd.dispatch = 3; // demoted: correct, slower + continue; + } + nd.dispatch = 2; + h.witness_plan = wp; + nd.hash_index = static_cast(r.hashes.size()); + r.hashes.push_back(h); + } +} + +// Pure-leaf edge encoding, last: a child that is a pure leaf (terminal +// route only) is encoded as -2 - route directly in the edge, so the walk +// finishes or dead-ends there without loading the leaf node. -1 keeps +// meaning "absent" for param_child. +void encode_leaf_shortcuts(builder_tables& r) { + auto pure_leaf = [&](int32_t ni) { + const node_record& nd = r.nodes[static_cast(ni)]; + return nd.n_static == 0 && nd.param_child < 0 && nd.wild_route < 0 && + nd.terminal_route >= 0; + }; + for (auto& e : r.edges) { + if (pure_leaf(e.node)) { + e.node = -2 - r.nodes[static_cast(e.node)].terminal_route; + } + } + for (auto& nd : r.nodes) { + if (nd.param_child >= 0 && pure_leaf(nd.param_child)) { + nd.param_child = + -2 - r.nodes[static_cast(nd.param_child)].terminal_route; + } + } +} + +// Segment table: every non-regexp route's segments (kind plus literal text +// in the blob), so the sequential fallback beyond the fast-path limits can +// match any route without the compiler's types; for regexp routes, the +// anchored literal prefix the matcher checks before running the provider. +void build_segment_table(builder_tables& r, + const std::vector& routes) { + for (size_t i = 0; i < routes.size(); i++) { + const route_info& rt = routes[i]; + route_record& rm = r.routes[i]; + rm.segment_first = static_cast(r.segments.size()); + rm.segment_count = static_cast(rt.segments.size()); + for (const route_segment& s : rt.segments) { + segment_record sr{}; + sr.kind = static_cast(s.kind); + if (s.kind == segment_kind::literal) { + sr.text_offset = static_cast(r.blob.size()); + sr.text_length = static_cast(s.text.size()); + r.blob.insert(r.blob.end(), s.text.begin(), s.text.end()); + } + r.segments.push_back(sr); + } + } +} + +// Could some pathname match both a fast-path route `w` (full segment +// information) and route `c`? Conservative: true unless a literal conflict +// or a segment-count conflict proves otherwise. For a regexp-mode `c`, +// segments are what approximate_kind_sequence could certify (see there); +// has_wildcard means the input needs at least segments.size() segments +// rather than exactly that many. +bool co_matchable(const route_info& w, const route_info& c) noexcept { + const bool c_regexp = c.mode == route_mode::regexp; + const size_t wn = w.segments.size(); + const size_t cn = c.segments.size(); + constexpr size_t unbounded = ~size_t{0}; + // Segment counts each route accepts: [min, max]. + const size_t w_max = w.has_wildcard ? unbounded : wn; + const size_t c_max = c.has_wildcard ? unbounded : cn; + if ((wn > cn ? wn : cn) > (w_max < c_max ? w_max : c_max)) { + return false; + } + // Positions both routes constrain (a safe wildcard segment constrains + // nothing; a regexp route's segments are all constraining). + const size_t w_fixed = w.has_wildcard ? wn - 1 : wn; + const size_t c_fixed = (!c_regexp && c.has_wildcard) ? cn - 1 : cn; + const size_t upto = w_fixed < c_fixed ? w_fixed : c_fixed; + for (size_t j = 0; j < upto; j++) { + const route_segment& ws = w.segments[j]; + const route_segment& cs = c.segments[j]; + if (ws.kind == segment_kind::literal && cs.kind == segment_kind::literal) { + if (ws.text != cs.text) { + return false; + } + } else if (ws.kind == segment_kind::literal && + cs.kind == segment_kind::param) { + if (ws.text.empty()) { + return false; // params never bind an empty segment + } + } else if (ws.kind == segment_kind::param && + cs.kind == segment_kind::literal) { + if (cs.text.empty()) { + return false; + } + } + } + return true; +} + +// Auxiliary routes: those the trie cannot answer for (regexp mode and +// sequential mode), in insertion order, followed by one challenger range per +// trie route: the auxiliary routes that outrank it AND could match the same +// input. After a fast-path hit only the winner's challengers are tested +// (usually none). +void build_aux_table(builder_tables& r, const std::vector& routes) { + std::vector aux_all; + for (size_t i = 0; i < routes.size(); i++) { + if (routes[i].mode != route_mode::trie) { + aux_all.push_back(static_cast(i)); + } + } + r.aux = aux_all; + r.n_aux_all = static_cast(aux_all.size()); + for (size_t w = 0; w < routes.size(); w++) { + const route_info& winner = routes[w]; + if (winner.mode != route_mode::trie) { + continue; // never a fast-path winner + } + route_record& rm = r.routes[w]; + rm.challenger_first = static_cast(r.aux.size()); + for (const uint32_t c : aux_all) { + if (route_outranks(routes[c], c, winner, w) && + co_matchable(winner, routes[c])) { + r.aux.push_back(c); + } + } + rm.challenger_count = + static_cast(r.aux.size()) - rm.challenger_first; + } +} + +// Packs every table into one arena, each section 8-byte aligned, and +// records the section offsets. The blob gets 8 bytes of zero padding so +// short key windows can always be loaded whole. +compiled_routes pack_arena(builder_tables& r) { + compiled_routes out{}; + r.blob.insert(r.blob.end(), 8, '\0'); + size_t total = 0; + const auto reserve = [&](size_t bytes) { + const size_t offset = total; + total += (bytes + 7) & ~size_t{7}; + return static_cast(offset); + }; + const auto bytes_of = [](const auto& v) { return v.size() * sizeof(v[0]); }; + out.nodes_offset = reserve(bytes_of(r.nodes)); + out.hashes_offset = reserve(bytes_of(r.hashes)); + out.edges_offset = reserve(bytes_of(r.edges)); + out.slots_offset = reserve(bytes_of(r.slots)); + out.blob_offset = reserve(bytes_of(r.blob)); + out.routes_offset = reserve(bytes_of(r.routes)); + out.segments_offset = reserve(bytes_of(r.segments)); + out.aux_offset = reserve(bytes_of(r.aux)); + out.root_index_offset = reserve(bytes_of(r.root_index)); + out.arena.assign(total, 0); + const auto place = [&](uint32_t offset, const auto& v) { + if (!v.empty()) { + std::memcpy(out.arena.data() + offset, v.data(), v.size() * sizeof(v[0])); + } + }; + place(out.nodes_offset, r.nodes); + place(out.hashes_offset, r.hashes); + place(out.edges_offset, r.edges); + place(out.slots_offset, r.slots); + place(out.blob_offset, r.blob); + place(out.routes_offset, r.routes); + place(out.segments_offset, r.segments); + place(out.aux_offset, r.aux); + place(out.root_index_offset, r.root_index); + out.n_routes = static_cast(r.routes.size()); + out.n_aux_all = r.n_aux_all; + return out; +} + +// Fills route.kind_sequence / kind_length from route.segments. +void compute_kind_sequence(route_info& route) noexcept { + uint64_t seq = 0; + const size_t n = route.segments.size(); + for (size_t j = 0; j < n && j < 32; j++) { + seq |= static_cast(route.segments[j].kind) << (62 - 2 * j); + } + route.kind_sequence = seq; + route.kind_length = static_cast(n < 255 ? n : 255); +} + +} // namespace + +// ---- compiler entry points ------------------------------------------------- + +bool classify_parts(const std::vector& parts, + std::vector& segments, + std::vector& group_names) { + segments.clear(); + group_names.clear(); + bool started = false; // leading '/' consumed + bool group_open = false; // the open segment slot is a group + bool wildcard_seen = false; // a "*" segment was consumed (must stay last) + std::string literal; + const auto close_segment = [&]() { + if (!group_open) { + segments.push_back(route_segment{segment_kind::literal, literal}); + } + literal.clear(); + group_open = false; + }; + const auto process_text = [&](std::string_view text) { + for (const char c : text) { + if (wildcard_seen) { + return false; // nothing may follow a "*" segment + } + if (c == '/') { + if (!started) { + started = true; + } else { + close_segment(); + } + } else { + if (!started || group_open) { + return false; // text before '/', or suffix text after a group + } + literal += c; + } + } + return true; + }; + for (const url_pattern_part& part : parts) { + if (part.type == url_pattern_part_type::FIXED_TEXT && + part.modifier == url_pattern_part_modifier::none) { + if (!process_text(part.prefix) || !process_text(part.value) || + !process_text(part.suffix)) { + return false; + } + } else if ((part.type == url_pattern_part_type::SEGMENT_WILDCARD || + part.type == url_pattern_part_type::FULL_WILDCARD) && + part.modifier == url_pattern_part_modifier::none && + part.suffix.empty()) { + if (wildcard_seen) { + return false; + } + if (part.prefix == "/") { + if (!started) { + started = true; + } else { + close_segment(); + } + } else if (part.prefix.empty()) { + // The group must occupy a whole, freshly opened segment slot. + if (!started || group_open || !literal.empty()) { + return false; + } + } else { + return false; // group not aligned on a '/' boundary + } + const bool is_wildcard = + part.type == url_pattern_part_type::FULL_WILDCARD; + segments.push_back(route_segment{ + is_wildcard ? segment_kind::wildcard : segment_kind::param, + part.name}); + group_names.push_back(part.name); + group_open = true; + wildcard_seen = is_wildcard; + } else { + return false; // regexp group or a "?" / "+" / "*" modifier + } + } + if (!started) { + // Only the empty pattern (zero parts) is representable without a leading + // '/': it matches exactly the empty pathname. + return parts.empty(); + } + close_segment(); + return true; +} + +void finalize_route(route_info& route) noexcept { + compute_kind_sequence(route); + route.all_literal = true; + route.has_wildcard = false; + size_t n_params = 0; + for (const route_segment& s : route.segments) { + if (s.kind != segment_kind::literal) { + route.all_literal = false; + if (s.kind == segment_kind::param) { + n_params++; + } else { + route.has_wildcard = true; + } + } + } + const size_t n_captures = n_params + (route.has_wildcard ? 1 : 0); + const bool trie_safe = !route.segments.empty() && + route.segments.size() <= max_trie_pattern_segments && + n_captures <= max_captures_per_route; + route.mode = trie_safe ? route_mode::trie : route_mode::sequential; +} + +void approximate_kind_sequence(const std::vector& parts, + route_info& route) { + std::vector kinds; + // The route's segment shape, as far as it is known: literal segments carry + // their text, opaque ones (a ":name" group, alone or mixed with text) are + // params. Exact while every group is a ":name" segment wildcard, which + // cannot match '/': then the segment count and every literal position are + // certain. A custom "(...)" group, a "*" or a modifier can span segments, + // so from there on nothing is known and only the anchored literal prefix + // before it is kept. + std::vector shape; + std::string literal; + bool started = false; + bool open = false; + bool leading_slash = false; // the pattern is anchored at a '/' + bool exact = true; // no part can span a segment boundary + bool tail_known = true; // segments after the last closed one are known + uint8_t current = 0; + // Closes the open segment; `complete` is true only when a '/' of fixed + // text closed it, i.e. the segment is exactly what was accumulated. + const auto close = [&](bool complete) { + if (open) { + kinds.push_back(current); + if (complete || exact) { + shape.push_back(current == 0 + ? route_segment{segment_kind::literal, literal} + : route_segment{segment_kind::param, {}}); + } else { + tail_known = false; + } + open = false; + current = 0; + } + literal.clear(); + }; + const auto process_text = [&](std::string_view text) { + for (const char c : text) { + if (c == '/') { + if (started) { + close(true); + } else { + leading_slash = true; + } + started = true; + open = true; + current = 0; + } else { + if (!started) { + started = true; + } + if (!open) { + open = true; + current = 0; + } + literal += c; + } + } + }; + const auto add_group = [&](uint8_t kind) { + if (!open) { + started = true; + open = true; + current = 0; + } + current = current < kind ? kind : current; + }; + for (const url_pattern_part& part : parts) { + if (part.type == url_pattern_part_type::FULL_WILDCARD || + part.modifier != url_pattern_part_modifier::none) { + // A greedy or modified group can span segments: treat the rest of the + // route as a wildcard tail and stop. A "/" prefix still closes the + // previous segment as a whole one. + exact = false; + close(part.prefix == "/"); + tail_known = false; + kinds.push_back(2); + break; + } + if (part.type == url_pattern_part_type::FIXED_TEXT) { + process_text(part.value); + } else { // SEGMENT_WILDCARD or REGEXP, modifier none + if (part.type == url_pattern_part_type::REGEXP) { + exact = false; // a custom group may match '/' + } + process_text(part.prefix); + add_group(1); + process_text(part.suffix); + } + } + close(exact); + uint64_t seq = 0; + for (size_t j = 0; j < kinds.size() && j < 32; j++) { + seq |= static_cast(kinds[j]) << (62 - 2 * j); + } + route.kind_sequence = seq; + route.kind_length = + static_cast(kinds.size() < 255 ? kinds.size() : 255); + route.mode = route_mode::regexp; + route.all_literal = false; + route.segments.clear(); + if (!leading_slash) { + // Not anchored at '/': nothing about the segments is known. + route.has_wildcard = true; + return; + } + // has_wildcard here means "the tail is unconstrained": the input needs at + // least segments.size() segments; otherwise exactly that many. + route.has_wildcard = !(exact && tail_known); + if (route.has_wildcard) { + // Keep only the anchored literal prefix. + size_t n_prefix = 0; + while (n_prefix < shape.size() && + shape[n_prefix].kind == segment_kind::literal) { + n_prefix++; + } + shape.resize(n_prefix); + } + route.segments = std::move(shape); +} + +compiled_routes compile_route_set(std::vector& routes) { + builder_tables r{}; + const std::vector t = build_trie(routes); + compile_route_records(r, routes); + const std::vector order = layout_nodes(r, t); + compile_dispatch(r, t, order); + encode_leaf_shortcuts(r); // last: must not confuse the stages above + build_segment_table(r, routes); + build_aux_table(r, routes); + compiled_routes out = pack_arena(r); + out.group_names.resize(routes.size()); + for (size_t i = 0; i < routes.size(); i++) { + if (routes[i].mode != route_mode::regexp) { + out.group_names[i] = routes[i].group_names; + } + } + return out; +} + +} // namespace ada::url_pattern_list_compiler + +namespace ada::url_pattern_list_detail { + +tl::expected compile_pathname_patterns( + std::span patterns, bool ignore_case) { + namespace compiler = url_pattern_list_compiler; + std::vector routes; + routes.reserve(patterns.size()); + for (const std::string& pattern : patterns) { + // The pattern side goes through ada's own URLPattern machinery: the + // pattern parser tokenizes and canonicalizes exactly as a URLPattern + // pathname component would. + auto options = url_pattern_compile_component_options::PATHNAME; + auto part_list = url_pattern_helpers::parse_pattern_string( + pattern, options, url_pattern_helpers::canonicalize_pathname); + if (!part_list) { + return tl::unexpected(part_list.error()); + } + compiler::route_info route{}; + if (compiler::classify_parts(*part_list, route.segments, + route.group_names)) { + compiler::finalize_route(route); + } else { + // The pattern needs URLPattern regexp semantics: the caller compiles + // it as a pathname component through the provider; it participates + // in the priority order via its approximated kind sequence. + compiler::approximate_kind_sequence(*part_list, route); + } + if (ignore_case) { + // The compiled literals are ASCII-folded; the matcher folds the input + // the same way. + for (compiler::route_segment& s : route.segments) { + if (s.kind == compiler::segment_kind::literal) { + ascii_fold(s.text.data(), static_cast(s.text.size()), + s.text.data()); + } + } + } + routes.push_back(std::move(route)); + } + compiled_routes compiled = compiler::compile_route_set(routes); + compiled.ignore_case = ignore_case ? 1 : 0; + return compiled; +} + +namespace { + +// Literal-segment compare against the blob, folding the input on request +// (the blob text is already folded). +bool literal_equals(std::string_view s, const char* text, uint32_t length, + bool fold_input) noexcept { + if (s.size() != length) { + return false; + } + if (!fold_input) { + return s.size() == 0 || std::memcmp(s.data(), text, s.size()) == 0; + } + for (size_t i = 0; i < s.size(); i++) { + const uint8_t c = static_cast(s[i]); + const char folded = + static_cast(c | ((c >= 'A' && c <= 'Z') ? 0x20u : 0u)); + if (folded != text[i]) { + return false; + } + } + return true; +} + +} // namespace + +// Kept out of line on purpose: inlined into the walk, this loop cost the +// static and ":param" paths, which never run it, about 5 ns through register +// allocation; as a call it costs only wildcard hits. It lives in this +// translation unit rather than the header because MSVC's ada_never_inline +// expands to __declspec(noinline) with no inline linkage, so a definition in +// the header is emitted in every translation unit and the link fails. +// +// On AArch64 whole 16-byte blocks go through NEON (a running byte minimum); +// the rest is SWAR, 8 bytes per step: "some byte is below 0x20" ("hasless" +// of Bit Twiddling Hacks) is exact as a yes/no answer, because a borrow can +// only leave a lane that itself qualifies. Only a tail holding a control +// byte gets the exact byte check. +bool wildcard_tail_ok(const char* p, uint32_t n) noexcept { + constexpr uint64_t spaces = 0x2020202020202020ull; + constexpr uint64_t highs = 0x8080808080808080ull; + const auto below_space = [](uint64_t x) noexcept { + return (x - spaces) & ~x & highs; + }; + uint64_t control = 0; + uint32_t i = 0; +#if ADA_NEON + if (n >= 16) { + uint8x16_t lowest = vdupq_n_u8(0xFF); + for (; i + 16 <= n; i += 16) { + lowest = + vminq_u8(lowest, vld1q_u8(reinterpret_cast(p + i))); + } + control = vminvq_u8(lowest) < 0x20 ? 1 : 0; + } +#endif + for (; i + 8 <= n; i += 8) { + control |= below_space(load8_le(p + i)); + } + if (i < n) { // 1..7 bytes, padded with spaces + control |= below_space(gather_le(p + i, n - i) | + (spaces & ~low_bytes_mask(n - i))); + } + if (control == 0) { + return true; + } + for (uint32_t j = 0; j < n; j++) { + if (p[j] == '\n' || p[j] == '\r') { + return false; + } + } + return true; +} + +bool match_regexp_shape(const compiled_routes& r, uint32_t route, + std::string_view pathname, bool fold_input) noexcept { + const route_record& rt = r.section(r.routes_offset)[route]; + const segment_record* segs = + r.section(r.segments_offset) + rt.segment_first; + const char* blob = r.section(r.blob_offset); + if (rt.segment_count == 0 && rt.wild != 0) { + return true; // nothing is known about the route's shape + } + if (pathname.empty() || pathname[0] != '/') { + return false; // a known shape always starts with '/' + } + size_t seg_start = 1; + for (uint32_t si = 0; si < rt.segment_count; si++) { + if (seg_start > pathname.size()) { + return false; // fewer segments than the shape + } + const size_t slash = pathname.find('/', seg_start); + const size_t seg_end = + slash == std::string_view::npos ? pathname.size() : slash; + const std::string_view s(pathname.data() + seg_start, seg_end - seg_start); + if (segs[si].kind == 0) { // literal + if (!literal_equals(s, blob + segs[si].text_offset, segs[si].text_length, + fold_input)) { + return false; + } + } else if (s.empty()) { + return false; // a ":name" group, mixed or not, never binds "" + } + seg_start = seg_end + 1; + } + // An exact shape admits no further segments. + return rt.wild != 0 || seg_start == pathname.size() + 1; +} + +bool match_route_sequential(const compiled_routes& r, uint32_t route, + std::string_view pathname, bool fold_input, + engine_result& result) noexcept { + using url_pattern_list_limits::max_captures_per_route; + result.capture_count = 0; + result.captures_truncated = false; + const route_record& rt = r.section(r.routes_offset)[route]; + const segment_record* segs = + r.section(r.segments_offset) + rt.segment_first; + const char* blob = r.section(r.blob_offset); + const size_t n_pattern = rt.segment_count; + if (n_pattern == 0) { + return pathname.empty(); // the empty pattern matches only "" + } + if (pathname.empty() || pathname[0] != '/') { + return false; + } + const bool wild = rt.wild != 0; // wildcard is always the last segment + const size_t n_fixed = wild ? n_pattern - 1 : n_pattern; + uint32_t total_captures = 0; + const auto add_capture = [&](size_t offset, size_t length) { + if (total_captures < max_captures_per_route) { + result.captures[total_captures] = {static_cast(offset), + static_cast(length)}; + } else { + result.captures_truncated = true; + } + total_captures++; + }; + size_t seg_start = 1; + for (size_t si = 0; si < n_fixed; si++) { + if (seg_start > pathname.size()) { + return false; // the input has fewer segments than the pattern + } + const size_t slash = pathname.find('/', seg_start); + const size_t seg_end = + slash == std::string_view::npos ? pathname.size() : slash; + // seg_start <= pathname.size() was checked above, so this is in range + // (spelled without substr to keep the function provably non-throwing). + const std::string_view s(pathname.data() + seg_start, seg_end - seg_start); + if (segs[si].kind == 0) { // literal + if (!literal_equals(s, blob + segs[si].text_offset, segs[si].text_length, + fold_input)) { + return false; + } + } else { // param: binds one non-empty segment + if (s.empty()) { + return false; + } + add_capture(seg_start, s.size()); + } + seg_start = seg_end + 1; + } + if (wild) { + if (seg_start > pathname.size()) { + return false; // the wildcard still needs its (possibly empty) segment + } + // "(.*)" does not match a line terminator (see wildcard_tail_ok). + if (pathname.find_first_of("\n\r", seg_start) != std::string_view::npos) { + return false; + } + add_capture(seg_start, pathname.size() - seg_start); + } else if (seg_start != pathname.size() + 1) { + return false; // the input has more segments than the pattern + } + result.capture_count = total_captures < max_captures_per_route + ? total_captures + : max_captures_per_route; + return true; +} + +} // namespace ada::url_pattern_list_detail +#endif // ADA_INCLUDE_URL_PATTERN diff --git a/src/url_pattern_list_compiler.h b/src/url_pattern_list_compiler.h new file mode 100644 index 000000000..8499191bd --- /dev/null +++ b/src/url_pattern_list_compiler.h @@ -0,0 +1,140 @@ +/** + * @file url_pattern_list_compiler.h + * @brief The url_pattern_list route-set compiler: pattern classification and + * the build-time table construction behind ada::parse_url_pattern_list. + * + * This header is NOT part of the public API and is not included from ada.h: + * only src/url_pattern_list.cpp (and, through the amalgamated ada.cpp, the + * url_pattern_list fuzzer) include it. The matcher-visible table layout it + * produces is declared in include/ada/url_pattern_list.h. + */ +#ifndef ADA_URL_PATTERN_LIST_COMPILER_H +#define ADA_URL_PATTERN_LIST_COMPILER_H + +#include "ada/common_defs.h" +#include "ada/url_pattern.h" +#include "ada/url_pattern_list.h" + +#include +#include +#include + +#if ADA_INCLUDE_URL_PATTERN +namespace ada::url_pattern_list_compiler { + +/** + * Maximum number of entries a perfect-hash dispatch table may index (slot + * ordinals are 8-bit, with 0xFF reserved for "empty"). Nodes with a larger + * static fanout are demoted to a linear scan: slower, never incorrect. + */ +inline constexpr uint32_t max_dispatch_table_entries = 254; + +/** + * Static fanout up to which a node compares its children directly, in + * turn; wider nodes dispatch through a projection table. Measured on a + * synthetic node hit uniformly: direct compares beat the projection by + * about 1 ns for keys of varied length up to about 12 children, while for + * keys of one length (which the length gate cannot reject) the projection + * wins by 4-5 ns at every fanout and by more on misses, because the direct + * loop's exit is unpredictable. Eight keeps the first case and bounds the + * second. + */ +inline constexpr uint32_t max_direct_children = 8; + +/** + * The kind of one compiled pattern segment. The numeric values define match + * priority: at the first differing segment, a literal beats a ":param" and a + * ":param" beats a "*" (find-my-way / Express-compatible specificity order). + */ +enum class segment_kind : uint8_t { + literal = 0, + param = 1, + wildcard = 2, +}; + +/** + * One '/'-separated segment of a compiled pattern: a literal text (already + * canonicalized by the URLPattern pattern parser), a ":param" (text = group + * name), or a trailing "*" wildcard. + */ +struct route_segment { + segment_kind kind = segment_kind::literal; + std::string text{}; +}; + +/** + * Everything the compiler knows about one route. `kind_sequence` packs the + * per-segment kinds two bits per segment, most significant first, so that + * comparing (kind_sequence, kind_length, route index) as a tuple is exactly + * the documented specificity order. For regexp-mode routes, `segments` + * holds only the anchored literal prefix (the leading segments that are + * whole fixed text, closed by a '/'), which the auxiliary-route pruning + * uses to rule out routes that can never match the same input. + */ +struct route_info { + std::vector segments{}; + // Capture group names, in capture order (params left to right, then the + // wildcard group). Regexp-mode routes: filled from the compiled component. + std::vector group_names{}; + uint64_t kind_sequence = 0; + uint8_t kind_length = 0; + url_pattern_list_detail::route_mode mode = + url_pattern_list_detail::route_mode::sequential; + bool has_wildcard = false; + bool all_literal = false; +}; + +/** + * Classifies a URLPattern part list into '/'-separated pattern segments. + * Returns true when the pattern is expressible in the static/":param"/"*" + * subset (every segment wholly a literal, a param, or a final wildcard); + * `segments` and `group_names` are only valid on success. + */ +bool classify_parts(const std::vector& parts, + std::vector& segments, + std::vector& group_names); + +/** + * Computes kind sequence and flags from route.segments and decides the + * route's mode: trie when every fast-path build limit is met, sequential + * otherwise (the empty pattern, more than max_trie_pattern_segments + * segments, or more than max_captures_per_route capture groups). + */ +void finalize_route(route_info& route) noexcept; + +/** + * Best-effort kind sequence for a route that needs regexp matching, so it can + * participate in the specificity order: custom "(...)" groups count as params, + * full wildcards and modified ("?", "+", "*") groups terminate the sequence as + * a wildcard tail. This mapping is an approximation and is called out as an + * open question in the class documentation. Also records the route's + * anchored literal prefix in route.segments and sets mode = regexp. + */ +void approximate_kind_sequence(const std::vector& parts, + route_info& route); + +/** + * True when route `a` (at insertion index `a_index`) outranks route `b`. + */ +constexpr bool route_outranks(const route_info& a, size_t a_index, + const route_info& b, size_t b_index) noexcept { + return url_pattern_list_detail::outranks(a.kind_sequence, a.kind_length, + a_index, b.kind_sequence, + b.kind_length, b_index); +} + +/** + * Compiles the route set: builds the trie and its dispatch plans over the + * trie-mode routes, the segment table, the auxiliary-route table with its + * per-route challenger ranges, and packs everything into one arena. Never + * fails: any node whose offline search fails is demoted to a slower but + * correct plan. + * Literal texts are used as given (the caller has already case-folded them + * when ignore_case is set). + */ +url_pattern_list_detail::compiled_routes compile_route_set( + std::vector& routes); + +} // namespace ada::url_pattern_list_compiler +#endif // ADA_INCLUDE_URL_PATTERN +#endif // ADA_URL_PATTERN_LIST_COMPILER_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8bcf303ae..bb65e9325 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,7 @@ else() add_gtest_test(wpt_url_tests wpt_url_tests.cpp) if(ADA_INCLUDE_URL_PATTERN) add_gtest_test(wpt_urlpattern_tests wpt_urlpattern_tests.cpp) + add_gtest_test(url_pattern_list_tests url_pattern_list_tests.cpp) endif() add_gtest_test(url_components url_components.cpp) add_gtest_test(basic_tests basic_tests.cpp) diff --git a/tests/url_pattern_list_tests.cpp b/tests/url_pattern_list_tests.cpp new file mode 100644 index 000000000..e5ebe50ce --- /dev/null +++ b/tests/url_pattern_list_tests.cpp @@ -0,0 +1,1888 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" + +#include "ada.h" + +using regex_provider = ada::url_pattern_regex::std_regex_provider; +using list_type = ada::url_pattern_list; + +namespace { + +std::string capture_text(std::string_view path, + const ada::url_pattern_list_match_result& m, + size_t index) { + return std::string( + path.substr(m.captures[index].offset, m.captures[index].length)); +} + +tl::expected parse_list( + const std::vector& patterns, + const ada::url_pattern_options* options = nullptr) { + return ada::parse_url_pattern_list(patterns, nullptr, + options); +} + +list_type make_list(const std::vector& patterns, + const ada::url_pattern_options* options = nullptr) { + auto result = parse_list(patterns, options); + EXPECT_TRUE(result.has_value()); + if (!result.has_value()) { + return list_type{}; + } + return std::move(*result); +} + +} // namespace + +TEST(url_pattern_list, basic_static_param_wildcard) { + auto list = make_list({ + "/", // 0 + "/about", // 1 + "/users/:id", // 2 + "/users/me", // 3 + "/files/*", // 4 + "/api/v1/:a/:b", // 5 + "/users/:id/posts", // 6 + }); + ASSERT_EQ(list.size(), 7u); + EXPECT_EQ(list.match("/").route_index, 0); + EXPECT_EQ(list.match("/about").route_index, 1); + EXPECT_EQ(list.match("/users/42").route_index, 2); + EXPECT_EQ(list.match("/users/me").route_index, 3); + EXPECT_EQ(list.match("/files/a/b/c").route_index, 4); + EXPECT_EQ(list.match("/api/v1/x/y").route_index, 5); + EXPECT_EQ(list.match("/users/9/posts").route_index, 6); + EXPECT_EQ(list.match("/nope").route_index, -1); + EXPECT_EQ(list.match("/users").route_index, -1); + EXPECT_EQ(list.match("").route_index, -1); + EXPECT_EQ(list.match("no-slash").route_index, -1); + + auto m = list.match("/users/42"); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/users/42", m, 0), "42"); + ASSERT_EQ(list.group_names(2).size(), 1u); + EXPECT_EQ(list.group_names(2)[0], "id"); + + auto two = list.match("/api/v1/x/y"); + ASSERT_EQ(two.capture_count, 2u); + EXPECT_EQ(capture_text("/api/v1/x/y", two, 0), "x"); + EXPECT_EQ(capture_text("/api/v1/x/y", two, 1), "y"); + + auto wild = list.match("/files/a/b/c"); + ASSERT_EQ(wild.capture_count, 1u); + EXPECT_EQ(capture_text("/files/a/b/c", wild, 0), "a/b/c"); + // URLPattern numbers unnamed "*" groups. + ASSERT_EQ(list.group_names(4).size(), 1u); + EXPECT_EQ(list.group_names(4)[0], "0"); +} + +TEST(url_pattern_list, wildcard_boundary_semantics) { + auto list = make_list({"/files/*"}); + // "/files/*" compiles to ^/files/(.*)$: the '/' is required, the tail may + // be empty. + EXPECT_EQ(list.match("/files").route_index, -1); + auto m = list.match("/files/"); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(m.captures[0].length, 0u); +} + +TEST(url_pattern_list, specificity_priority) { + // literal < ":param" < "*" at the first differing segment. + auto list = make_list({ + "/*", // 0 + "/:x", // 1 + "/a", // 2 + "/a/*", // 3 + "/a/:y", // 4 + "/a/b", // 5 + "/:x/b", // 6 + "/a/:y/c", // 7 + "/w/*", // 8 + "/:p/x/y", // 9 + }); + EXPECT_EQ(list.match("/a").route_index, 2); // static beats param, wild + EXPECT_EQ(list.match("/z").route_index, 1); // param beats wildcard + EXPECT_EQ(list.match("/a/b").route_index, 5); // static/static wins + // "/a/*" ([0,2]) vs "/:x/b" ([1,0]): the static first segment dominates. + EXPECT_EQ(list.match("/a/q").route_index, 4); // param beats wildcard + EXPECT_EQ(list.match("/q/b").route_index, 6); + // Deeper: "/a/:y/c" ([0,1,0]) beats "/a/*" ([0,2]) at position 1. + EXPECT_EQ(list.match("/a/q/c").route_index, 7); + EXPECT_EQ(list.match("/a/q/d").route_index, 3); + // "/w/*" ([0,2]) beats "/:p/x/y" ([1,0,0]) at position 0: this is the + // wildcard-outranks-a-param-shape case. + EXPECT_EQ(list.match("/w/x/y").route_index, 8); + EXPECT_EQ(list.match("/v/x/y").route_index, 9); +} + +TEST(url_pattern_list, insertion_order_breaks_ties) { + auto list = make_list({ + "/users/:a", // 0 + "/users/:b", // 1 identical kind sequence: 0 wins + "/users/me", // 2 + "/users/me", // 3 duplicate static: 2 wins + }); + EXPECT_EQ(list.match("/users/q").route_index, 0); + EXPECT_EQ(list.match("/users/me").route_index, 2); +} + +TEST(url_pattern_list, root_trailing_slash_empty_segments) { + auto list = make_list({ + "/", // 0 + "/users/", // 1 (trailing empty segment) + "/users", // 2 + "/a//b", // 3 (interior empty segment) + "/:x", // 4 + }); + EXPECT_EQ(list.match("/").route_index, 0); // params cannot bind "" + EXPECT_EQ(list.match("/users/").route_index, 1); + EXPECT_EQ(list.match("/users").route_index, 2); + EXPECT_EQ(list.match("/a//b").route_index, 3); + EXPECT_EQ(list.match("/a/b").route_index, -1); + EXPECT_EQ(list.match("/q").route_index, 4); + EXPECT_EQ(list.match("//").route_index, -1); +} + +TEST(url_pattern_list, empty_list_and_empty_pattern) { + auto empty = make_list({}); + EXPECT_EQ(empty.size(), 0u); + EXPECT_EQ(empty.match("/anything").route_index, -1); + + // The empty pattern matches exactly the empty pathname. + auto list = make_list({"", "/x"}); + EXPECT_EQ(list.match("").route_index, 0); + EXPECT_EQ(list.match("/x").route_index, 1); + EXPECT_EQ(list.match("/").route_index, -1); +} + +TEST(url_pattern_list, agrees_with_url_pattern_on_boundary_cases) { + // Pin the static/":param"/"*" subset semantics to ada::url_pattern itself: + // for every (pattern, input) pair, a single-route list must match exactly + // when the URLPattern pathname component matches. + const std::vector patterns = { + "/files/*", "/users/:id", "/users/", "/a//b", "/", "/*", "/:x/:y", + }; + const std::vector inputs = { + "/files", "/files/", "/files/x/y", "/users", "/users/", + "/users/x", "/users//", "/a//b", "/a/b", "/", + "//", "/x/y", "/x/", "/files/x/y/", + }; + for (const std::string_view pattern : patterns) { + auto list = make_list({pattern}); + auto url_pattern = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = std::string(pattern)}); + ASSERT_TRUE(url_pattern.has_value()) << pattern; + for (const std::string_view input : inputs) { + auto expected = url_pattern->test( + ada::url_pattern_init{.pathname = std::string(input)}); + ASSERT_TRUE(expected.has_value()) << pattern << " " << input; + EXPECT_EQ(list.match(input).has_match(), *expected) + << "pattern=" << pattern << " input=" << input; + } + } +} + +TEST(url_pattern_list, invalid_pattern_is_type_error) { + auto result = parse_list({"/users/(unclosed"}); + ASSERT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), ada::errors::type_error); +} + +TEST(url_pattern_list, pattern_canonicalization_percent_encoding) { + // The pattern side goes through ada's URLPattern canonicalization: literal + // text is percent-encoded exactly as a URLPattern pathname component + // would be, so patterns match canonical pathnames. + auto list = make_list({"/caf\xC3\xA9", "/a b/:id"}); + EXPECT_EQ(list.match("/caf%C3%A9").route_index, 0); + // The input side is matched as given (canonical form expected). + EXPECT_EQ(list.match("/caf\xC3\xA9").route_index, -1); + auto m = list.match("/a%20b/7"); + EXPECT_EQ(m.route_index, 1); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/a%20b/7", m, 0), "7"); + // The canonicalized pattern is observable through ada::url_pattern too. + auto pattern = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = "/caf\xC3\xA9"}); + ASSERT_TRUE(pattern.has_value()); + EXPECT_EQ(pattern->get_pathname(), "/caf%C3%A9"); +} + +TEST(url_pattern_list, out_of_fast_path_inputs) { + auto list = make_list({ + "/files/*", // 0 + "/deep/:x", // 1 + "/", // 2 + }); + // More than 24 segments: must still match the wildcard route, with the + // full tail captured. + std::string deep = "/files"; + for (int i = 0; i < 40; i++) { + deep += "/segment"; + } + auto m = list.match(deep); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text(deep, m, 0), deep.substr(7)); + + // Longer than 4096 bytes: same answer, same captures. + std::string longu = "/files/" + std::string(8000, 'x'); + auto ml = list.match(longu); + EXPECT_EQ(ml.route_index, 0); + ASSERT_EQ(ml.capture_count, 1u); + EXPECT_EQ(ml.captures[0].length, 8000u); + + // A 30-segment miss stays a miss. + std::string miss = "/deep"; + for (int i = 0; i < 30; i++) { + miss += "/s"; + } + EXPECT_EQ(list.match(miss).route_index, -1); +} + +TEST(url_pattern_list, many_params_route_is_correct_and_truncates) { + auto list = make_list({ + "/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j", // 0: ten params (> 8) + "/one/:b/:c/:d/:e/:f/:g/:h/:i/:j", // 1: nine captures, outranks 0 + }); + auto m = list.match("/1/2/3/4/5/6/7/8/9/10"); + EXPECT_EQ(m.route_index, 0); + EXPECT_EQ(m.capture_count, 8u); + EXPECT_TRUE(m.captures_truncated); + EXPECT_EQ(capture_text("/1/2/3/4/5/6/7/8/9/10", m, 0), "1"); + EXPECT_EQ(capture_text("/1/2/3/4/5/6/7/8/9/10", m, 7), "8"); + auto n = list.match("/one/2/3/4/5/6/7/8/9/10"); + EXPECT_EQ(n.route_index, 1); + EXPECT_EQ(n.capture_count, 8u); + EXPECT_TRUE(n.captures_truncated); + EXPECT_EQ(capture_text("/one/2/3/4/5/6/7/8/9/10", n, 0), "2"); + // An input with fewer segments than the pattern: the sequential matcher + // must reject after binding what is there. + EXPECT_EQ(list.match("/1/2/3").route_index, -1); +} + +TEST(url_pattern_list, large_fanout_falls_back_to_linear_dispatch) { + // More children under one node than the 8-bit slot tables can index: the + // node demotes to a linear scan and stays correct. + std::vector storage; + storage.reserve(300); + for (int i = 0; i < 300; i++) { + storage.push_back("/fan/route" + std::to_string(i)); + } + std::vector patterns(storage.begin(), storage.end()); + auto list = make_list(patterns); + EXPECT_EQ(list.match("/fan/route0").route_index, 0); + EXPECT_EQ(list.match("/fan/route123").route_index, 123); + EXPECT_EQ(list.match("/fan/route299").route_index, 299); + EXPECT_EQ(list.match("/fan/route300").route_index, -1); +} + +TEST(url_pattern_list, deep_pattern_falls_back_sequential) { + auto list = make_list({ + "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r", // 0: 18 segments (> 16) + "/a/:x/c", // 1 + }); + EXPECT_EQ(list.match("/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r").route_index, 0); + EXPECT_EQ(list.match("/a/z/c").route_index, 1); + // The deep route outranks "/a/:x/c" (all-literal kind sequence), so the + // sequential matcher tests it on this input and must reject it for having + // more pattern segments than the input has. + EXPECT_EQ(list.match("/a/b/c").route_index, 1); + EXPECT_EQ(list.match("/a/b/c/d").route_index, -1); +} + +TEST(url_pattern_list, regexp_routes_compose_with_priority) { + auto list = make_list({ + "/users/(\\d+)", // 0: regexp, kind ~ [literal, param] + "/users/:name", // 1: same kind sequence; 0 wins on insertion order + "/users/admin", // 2: static outranks both + "/*", // 3 + }); + EXPECT_EQ(list.match("/users/123").route_index, 0); + EXPECT_EQ(list.match("/users/bob").route_index, 1); + EXPECT_EQ(list.match("/users/admin").route_index, 2); + EXPECT_EQ(list.match("/other").route_index, 3); + // Group values for regexp routes come back from the provider's + // regex_search, aligned with group_names. + auto m = list.match("/users/123"); + EXPECT_TRUE(m.regexp_route); + EXPECT_EQ(m.capture_count, 0u); + ASSERT_EQ(list.group_names(0).size(), 1u); + ASSERT_EQ(m.regexp_groups.size(), 1u); + EXPECT_EQ(m.regexp_groups[0], std::optional("123")); + // Subset routes report slices and no regexp groups. + auto n = list.match("/users/bob"); + EXPECT_FALSE(n.regexp_route); + EXPECT_TRUE(n.regexp_groups.empty()); + ASSERT_EQ(n.capture_count, 1u); + EXPECT_EQ(capture_text("/users/bob", n, 0), "bob"); +} + +TEST(url_pattern_list, regexp_routes_compose_in_both_directions) { + { + // The regexp route is inserted first and wins its priority class. + auto list = make_list({"/users/(\\d+)", "/users/:name"}); + EXPECT_EQ(list.match("/users/123").route_index, 0); + EXPECT_EQ(list.match("/users/x").route_index, 1); + } + { + // The safe route is inserted first and wins the tie instead. + auto list = make_list({"/users/:name", "/users/(\\d+)"}); + EXPECT_EQ(list.match("/users/123").route_index, 0); + EXPECT_EQ(list.match("/users/x").route_index, 0); + } + { + // A static regexp-free answer still outranks an earlier regexp route, + // and a regexp-only match is found when nothing else matches. + auto list = make_list({"/x-(\\d+)", "/x-7"}); + EXPECT_EQ(list.match("/x-7").route_index, 1); + EXPECT_EQ(list.match("/x-42").route_index, 0); + } +} + +// --------------------------------------------------------------------------- +// Randomized differential test: url_pattern_list::match against a naive, +// engine-independent reference matcher over generated route sets and +// generated + mutated pathnames. Extra seeds can be supplied locally via +// the ADA_URL_PATTERN_LIST_SEEDS environment variable (comma-separated). + +namespace { + +struct reference_segment { + int kind; // 0 literal, 1 param, 2 wildcard + std::string text; +}; + +struct reference_route { + std::vector segments; + bool wildcard = false; +}; + +// Parses the safe pattern subset the generator emits ("/lit/:param/*"). +reference_route reference_parse(std::string_view pattern) { + reference_route route; + size_t pos = 1; // skip the leading '/' + while (pos <= pattern.size()) { + size_t next = pattern.find('/', pos); + if (next == std::string_view::npos) { + next = pattern.size(); + } + std::string_view token = pattern.substr(pos, next - pos); + if (token == "*") { + route.segments.push_back({2, "*"}); + route.wildcard = true; + } else if (!token.empty() && token[0] == ':') { + route.segments.push_back({1, std::string(token.substr(1))}); + } else { + route.segments.push_back({0, std::string(token)}); + } + pos = next + 1; + } + return route; +} + +bool reference_match_one(const reference_route& route, std::string_view path, + std::vector& captures) { + captures.clear(); + if (path.empty() || path[0] != '/') { + return false; + } + std::vector segments; + size_t pos = 1; + while (pos <= path.size()) { + size_t next = path.find('/', pos); + if (next == std::string_view::npos) { + next = path.size(); + } + segments.push_back(path.substr(pos, next - pos)); + pos = next + 1; + } + const size_t n_pattern = route.segments.size(); + const size_t n_fixed = route.wildcard ? n_pattern - 1 : n_pattern; + if (route.wildcard ? segments.size() < n_pattern + : segments.size() != n_pattern) { + return false; + } + for (size_t i = 0; i < n_fixed; i++) { + const reference_segment& ps = route.segments[i]; + if (ps.kind == 0) { + if (segments[i] != ps.text) { + return false; + } + } else { + if (segments[i].empty()) { + return false; + } + captures.emplace_back(segments[i]); + } + } + if (route.wildcard) { + const char* tail_begin = segments[n_fixed].data(); + captures.emplace_back( + tail_begin, + static_cast(path.data() + path.size() - tail_begin)); + } + return true; +} + +// The documented priority rule, computed naively: per-segment kind sequence +// compared lexicographically, insertion index as the tiebreak. +int reference_best(const std::vector& routes, + std::string_view path, std::vector& captures) { + int best = -1; + std::vector best_kinds; + std::vector scratch; + for (size_t i = 0; i < routes.size(); i++) { + if (!reference_match_one(routes[i], path, scratch)) { + continue; + } + std::vector kinds; + kinds.reserve(routes[i].segments.size()); + for (const auto& s : routes[i].segments) { + kinds.push_back(s.kind); + } + if (best < 0 || + std::lexicographical_compare(kinds.begin(), kinds.end(), + best_kinds.begin(), best_kinds.end())) { + best = static_cast(i); + best_kinds = std::move(kinds); + captures = scratch; + } + } + return best; +} + +void run_differential(uint64_t seed) { + std::mt19937_64 rng(seed); + const auto pick = [&](uint64_t n) { return rng() % n; }; + static const char* vocabulary[] = { + "api", + "v1", + "v2", + "users", + "posts", + "comments", + "orders", + "items", + "admin", + "auth", + "login", + "settings", + "files", + "static", + "img", + "js", + "a", + "b", + "long-segment-name-for-keys", + }; + constexpr size_t vocabulary_size = sizeof(vocabulary) / sizeof(vocabulary[0]); + + // ~200 generated routes over a small vocabulary, so prefixes collide and + // every dispatch rung of the trie populates. + std::vector storage; + std::vector reference_routes; + const size_t n_routes = 200; + for (size_t i = 0; i < n_routes; i++) { + std::string pattern; + const size_t depth = 1 + pick(6); + int params = 0; + for (size_t d = 0; d < depth; d++) { + const bool last = d + 1 == depth; + const uint64_t kind = pick(10); + if (last && kind < 2) { + pattern += "/*"; + } else if (kind < 5 && params < 4) { + pattern += "/:p" + std::to_string(params++); + } else { + pattern += '/'; + pattern += vocabulary[pick(vocabulary_size)]; + } + } + storage.push_back(std::move(pattern)); + reference_routes.push_back(reference_parse(storage.back())); + } + std::vector patterns(storage.begin(), storage.end()); + auto result = parse_list(patterns); + ASSERT_TRUE(result.has_value()); + const auto& list = *result; + + // The same route set as individual ada::url_pattern objects: on a sample of + // inputs, every route's url_pattern::test must agree with the reference + // matcher, tying the reference (and through it the winner check below) to + // URLPattern semantics rather than to this test's own parser. + std::vector> url_patterns; + url_patterns.reserve(n_routes); + for (const std::string& pattern : storage) { + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = pattern}); + ASSERT_TRUE(parsed.has_value()) << pattern; + url_patterns.push_back(std::move(*parsed)); + } + + const auto random_token = [&]() { + static const char alphabet[] = "abcdefghijklmnopqrstuvwxyz0123456789-_"; + const size_t len = 1 + pick(10); + std::string token; + for (size_t j = 0; j < len; j++) { + token += alphabet[pick(sizeof(alphabet) - 1)]; + } + return token; + }; + + std::vector reference_captures; + const size_t n_urls = 20000; + for (size_t u = 0; u < n_urls; u++) { + // Instantiate a random route, then mutate. + const reference_route& base = reference_routes[pick(n_routes)]; + std::string path; + for (const auto& seg : base.segments) { + if (seg.kind == 0) { + path += '/'; + path += seg.text; + } else if (seg.kind == 1) { + path += '/'; + path += random_token(); + } else { + const uint64_t tail = pick(4); + for (uint64_t t = 0; t < tail; t++) { + path += '/'; + path += random_token(); + } + if (tail == 0) { + path += '/'; + if (pick(2)) { + path += random_token(); + } + } + } + } + if (path.empty()) { + path = "/"; + } + switch (pick(8)) { + case 0: // flip one byte + if (!path.empty()) { + path[pick(path.size())] = + static_cast('a' + static_cast(pick(26))); + } + break; + case 1: // add a trailing slash + path += '/'; + break; + case 2: // drop the last segment + if (path.find_last_of('/') > 0) { + path.resize(path.find_last_of('/')); + } + break; + case 3: // append a segment + path += '/'; + path += random_token(); + break; + case 4: // empty out one segment + path.insert(pick(path.size()), "/"); + break; + case 5: // occasionally exceed the fast-path segment limit + if (pick(10) == 0) { + for (int t = 0; t < 30; t++) { + path += "/s"; + } + } + break; + default: + break; // keep the instantiated path + } + + const int expected = + reference_best(reference_routes, path, reference_captures); + const auto matched = list.match(path); + ASSERT_EQ(matched.route_index, expected) + << "seed=" << seed << " path=" << path; + if (u % 50 == 0) { + // Sampled cross-check: per-route url_pattern::test agreement. Inputs + // generated here are already in canonical form, so testing them as a + // pathname init is an identity transformation. + std::vector scratch; + for (size_t i = 0; i < n_routes; i++) { + auto tested = + url_patterns[i].test(ada::url_pattern_init{.pathname = path}); + ASSERT_TRUE(tested.has_value()) + << "seed=" << seed << " path=" << path << " route=" << i; + ASSERT_EQ(*tested, + reference_match_one(reference_routes[i], path, scratch)) + << "seed=" << seed << " path=" << path << " route=" << i + << " pattern=" << storage[i]; + } + } + if (expected >= 0) { + const size_t n_reported = std::min( + reference_captures.size(), + ada::url_pattern_list_limits::max_captures_per_route); + ASSERT_EQ(matched.capture_count, n_reported) + << "seed=" << seed << " path=" << path; + for (size_t c = 0; c < n_reported; c++) { + ASSERT_EQ(capture_text(path, matched, c), reference_captures[c]) + << "seed=" << seed << " path=" << path << " capture=" << c; + } + } + } +} + +} // namespace + +TEST(url_pattern_list, differential_against_reference) { + run_differential(0xADA0001ull); + if (const char* extra = std::getenv("ADA_URL_PATTERN_LIST_SEEDS")) { + std::string_view seeds(extra); + while (!seeds.empty()) { + const size_t comma = seeds.find(','); + const std::string one(seeds.substr(0, comma)); + run_differential(std::strtoull(one.c_str(), nullptr, 0)); + if (comma == std::string_view::npos) { + break; + } + seeds.remove_prefix(comma + 1); + } + } +} + +// --------------------------------------------------------------------------- +// Data-driven semantics table: (pattern, input, expected match, expected +// captures) triplets in the style of ada's WPT tables, covering every syntax +// feature the static/":param"/"*" subset supports, its boundaries into +// regexp mode, and pattern-side canonicalization interactions. Rows with +// crosscheck == true are additionally verified against ada::url_pattern::test +// so the table can never drift from URLPattern semantics (rows whose input is +// not in canonical form are excluded: url_pattern canonicalizes the tested +// pathname, url_pattern_list matches it as given). + +namespace { + +struct semantics_case { + const char* pattern; + const char* input; + bool matches; + // Expected capture count, or -1 for regexp-mode routes (whose group values + // come back as regexp_groups rather than slices). + int capture_count; + // The expected capture values, joined with '|' (exactly capture_count + // entries; empty entries denote empty captures). + const char* captures; + bool crosscheck = true; +}; + +constexpr semantics_case semantics_table[] = { + // --- root and plain statics --- + {"/", "/", true, 0, ""}, + {"/", "", false, -1, "", false}, + {"/", "//", false, 0, ""}, + {"/about", "/about", true, 0, ""}, + {"/about", "/about/", false, 0, ""}, + {"/about", "/abut", false, 0, ""}, + {"/about", "/abouT", false, 0, ""}, + {"/about", "/about%20", false, 0, ""}, + {"/a/b/c", "/a/b/c", true, 0, ""}, + {"/a/b/c", "/a/b", false, 0, ""}, + {"/a/b/c", "/a/b/c/d", false, 0, ""}, + {"/A/B", "/A/B", true, 0, ""}, + {"/A/B", "/a/b", false, 0, ""}, + // --- trailing and interior empty segments --- + {"/users/", "/users/", true, 0, ""}, + {"/users/", "/users", false, 0, ""}, + {"/users/", "/users//", false, 0, ""}, + {"/a//b", "/a//b", true, 0, ""}, + {"/a//b", "/a/b", false, 0, ""}, + {"//", "//", true, 0, ""}, + {"//", "/", false, 0, ""}, + // --- static keys around the 8/16-byte verify windows --- + {"/abcdefgh", "/abcdefgh", true, 0, ""}, // exactly 8 + {"/abcdefgh", "/abcdefgX", false, 0, ""}, + {"/abcdefghijklmnop", "/abcdefghijklmnop", true, 0, ""}, // exactly 16 + {"/abcdefghijklmnop", "/abcdefghijklmnoX", false, 0, ""}, + {"/abcdefghijklmnopq", "/abcdefghijklmnopq", true, 0, ""}, // 17: blob + {"/abcdefghijklmnopq", "/abcdefghXjklmnopq", false, 0, ""}, + {"/segment-longer-than-sixteen-bytes/x", + "/segment-longer-than-sixteen-bytes/x", true, 0, ""}, + {"/segment-longer-than-sixteen-bytes/x", + "/segment-longer-than-sixteen-bytef/x", false, 0, ""}, + // --- single params --- + {"/users/:id", "/users/42", true, 1, "42"}, + {"/users/:id", "/users/", false, 0, ""}, + {"/users/:id", "/users", false, 0, ""}, + {"/users/:id", "/users/42/x", false, 0, ""}, + {"/users/:id", "/Users/42", false, 0, ""}, + {"/users/:id", "/users/a.b-c_d", true, 1, "a.b-c_d"}, + {"/users/:id", "/users/%20", true, 1, "%20"}, + {"/users/:id", "/users/x%2Fy", true, 1, "x%2Fy"}, + {"/:solo", "/anything", true, 1, "anything"}, + {"/:solo", "/", false, 0, ""}, + {"/:solo", "/a/b", false, 0, ""}, + // --- multiple params and mixed shapes --- + {"/:a/:b/:c", "/1/2/3", true, 3, "1|2|3"}, + {"/:a/:b/:c", "/1/2", false, 0, ""}, + {"/:a/:b/:c", "/1//3", false, 0, ""}, + {"/a/:b/c/:d/e", "/a/1/c/2/e", true, 2, "1|2"}, + {"/a/:b/c/:d/e", "/a/1/x/2/e", false, 0, ""}, + {"/a/:b/c/:d/e", "/a/1/c/2/x", false, 0, ""}, + {"/api/v1/:res/:id", "/api/v1/users/7", true, 2, "users|7"}, + {"/api/v1/:res/:id", "/api/v2/users/7", false, 0, ""}, + // --- wildcards --- + {"/files/*", "/files/a", true, 1, "a"}, + {"/files/*", "/files/a/b/c", true, 1, "a/b/c"}, + {"/files/*", "/files/", true, 1, ""}, + {"/files/*", "/files", false, 0, ""}, + {"/files/*", "/filesx", false, 0, ""}, + {"/files/*", "/files/a/", true, 1, "a/"}, + {"/*", "/", true, 1, ""}, + {"/*", "/a", true, 1, "a"}, + {"/*", "/a/b/c", true, 1, "a/b/c"}, + {"/:x/*", "/a/b/c", true, 2, "a|b/c"}, + {"/:x/*", "/a/", true, 2, "a|"}, + {"/:x/*", "/a", false, 0, ""}, + {"/a/:b/*", "/a/b/c/d", true, 2, "b|c/d"}, + // --- pattern-side canonicalization --- + {"/a b", "/a%20b", true, 0, ""}, + {"/a b", "/a b", false, 0, "", false}, // input matched as given + {"/caf\xC3\xA9", "/caf%C3%A9", true, 0, ""}, + {"/caf\xC3\xA9", "/caf\xC3\xA9", false, 0, "", false}, + {"/%41", "/%41", true, 0, ""}, + {"/%41", "/A", false, 0, ""}, + {"/", "/%3Cx%3E", true, 0, ""}, + {"/\"q\"", "/%22q%22", true, 0, ""}, + {"/'quote'", "/'quote'", true, 0, ""}, + {"/~tilde", "/~tilde", true, 0, ""}, + {"/x%3ay", "/x%3ay", true, 0, "", false}, + {"/a b/:id", "/a%20b/7", true, 1, "7"}, + {"/caf\xC3\xA9/*", "/caf%C3%A9/x/y", true, 1, "x/y"}, + // --- the empty pattern --- + {"", "", true, 0, "", false}, + {"", "/", false, 0, "", false}, + // --- braces resolving into the safe subset --- + {"/{:id}", "/q", true, 1, "q"}, + {"/{:id}", "/", false, 0, ""}, + // --- subset boundaries: these compile through the regex provider --- + {"/users/(\\d+)", "/users/123", true, -1, ""}, + {"/users/(\\d+)", "/users/12a", false, -1, ""}, + {"/users/(\\d+)", "/users/", false, -1, ""}, + {"/:id?", "/x", true, -1, ""}, + {"/:id?", "/", false, -1, ""}, + {"/:id?", "", true, -1, "", false}, + {"/:id+", "/a/b", true, -1, ""}, + {"/:id*", "/a/b/c", true, -1, ""}, + {"/a/*/b", "/a/x/b", true, -1, ""}, + {"/a/*/b", "/a/x/y/b", true, -1, ""}, + {"/a/*/b", "/a/b", false, -1, ""}, + {"/*.js", "/app.js", true, -1, ""}, + {"/*.js", "/app.css", false, -1, ""}, + {"/*x", "/ax", true, -1, ""}, + {"/*x", "/a", false, -1, ""}, + {"x", "x", true, -1, "", false}, + {"x", "/x", false, -1, "", false}, + {"/foo-:id", "/foo-7", true, -1, ""}, + {"/foo-:id", "/foo-", false, -1, ""}, + {"/foo-:id", "/foo", false, -1, ""}, + {"/x{:id}", "/xq", true, -1, ""}, + {"/x{:id}", "/x", false, -1, ""}, + {"/{a:id}", "/aq", true, -1, ""}, + {"/{a:id}", "/q", false, -1, ""}, + {"/:a{:b}", "/pq", true, -1, ""}, + {"/:a{:b}", "/p", false, -1, ""}, + {"/{ab}?", "/ab", true, -1, ""}, + {"/{ab}?", "/", true, -1, ""}, + {"/{ab}?", "/abab", false, -1, ""}, + {"/{:id-}", "/x-", true, -1, ""}, + {"/{:id-}", "/x", false, -1, ""}, + {"/:id.json", "/report.json", true, -1, ""}, + {"/:id.json", "/report.csv", false, -1, ""}, + {"/*/*", "/a/b", true, -1, ""}, + {"/*/*", "/a", false, -1, ""}, + {":id", "x", true, -1, "", false}, + {":id", "/x", false, -1, "", false}, + {"{:id}", "x", true, -1, "", false}, + {"{:id}", "/x", false, -1, "", false}, + {"(\\d+)", "123", true, -1, "", false}, + {"(\\d+)", "x", false, -1, "", false}, + // --- specificity inside a single-route list is trivial, but a static + // pattern that looks like a param must stay literal after escaping --- + {"/\\:id", "/:id", true, 0, ""}, + {"/\\:id", "/x", false, 0, ""}, + {"/\\*", "/*", true, 0, ""}, + {"/\\*", "/x", false, 0, ""}, +}; + +} // namespace + +TEST(url_pattern_list, data_driven_semantics_table) { + std::map lists; + std::map> url_patterns; + for (const semantics_case& c : semantics_table) { + const std::string pattern(c.pattern); + auto it = lists.find(pattern); + if (it == lists.end()) { + auto created = parse_list({c.pattern}); + ASSERT_TRUE(created.has_value()) << "pattern=" << c.pattern; + it = lists.emplace(pattern, std::move(*created)).first; + } + const auto m = it->second.match(c.input); + EXPECT_EQ(m.has_match(), c.matches) + << "pattern=" << c.pattern << " input=" << c.input; + if (c.matches) { + // Rows without slice expectations are the regexp-mode routes: they + // report the provider's groups, subset rows report slices. + EXPECT_EQ(m.regexp_route, c.capture_count < 0) + << "pattern=" << c.pattern << " input=" << c.input; + } + if (c.matches && c.capture_count >= 0) { + ASSERT_EQ(m.capture_count, static_cast(c.capture_count)) + << "pattern=" << c.pattern << " input=" << c.input; + std::string_view expected(c.captures); + for (int k = 0; k < c.capture_count; k++) { + const size_t bar = expected.find('|'); + const std::string_view value = expected.substr(0, bar); + EXPECT_EQ(capture_text(c.input, m, static_cast(k)), value) + << "pattern=" << c.pattern << " input=" << c.input + << " capture=" << k; + expected = bar == std::string_view::npos ? std::string_view{} + : expected.substr(bar + 1); + } + } + if (c.crosscheck) { + auto up = url_patterns.find(pattern); + if (up == url_patterns.end()) { + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = pattern}); + ASSERT_TRUE(parsed.has_value()) << "pattern=" << c.pattern; + up = url_patterns.emplace(pattern, std::move(*parsed)).first; + } + auto tested = up->second.test( + ada::url_pattern_init{.pathname = std::string(c.input)}); + ASSERT_TRUE(tested.has_value()) + << "pattern=" << c.pattern << " input=" << c.input; + EXPECT_EQ(*tested, c.matches) + << "url_pattern disagrees: pattern=" << c.pattern + << " input=" << c.input; + } + } +} + +// --------------------------------------------------------------------------- +// Targeted engine tests: each one drives a specific build-time or match-time +// rung (dispatch ladders, offline-search failures, demotions, gates) that the +// happy path never reaches. + +TEST(url_pattern_list, witness_exhaustion_demotes_node) { + // Nine 17-byte segments that differ only at byte 8: no witness plan over + // the first/last 8 bytes plus the length can tell them apart, so the + // offline search fails and the node dispatch demotes to a linear scan + // (they also share a first byte, so the root index is not used). Matching + // must not care. + auto list = make_list({ + "/aaaaaaaaBaaaaaaaa", // 0 + "/aaaaaaaaCaaaaaaaa", // 1 + "/aaaaaaaaDaaaaaaaa", // 2 + "/aaaaaaaaEaaaaaaab", // 3 + "/aaaaaaaaFaaaaaaab", // 4 + "/aaaaaaaaGaaaaaaab", // 5 + "/aaaaaaaaHaaaaaaac", // 6 + "/aaaaaaaaIaaaaaaac", // 7 + "/aaaaaaaaJaaaaaaac", // 8 + }); + EXPECT_EQ(list.match("/aaaaaaaaHaaaaaaac").route_index, 6); + EXPECT_EQ(list.match("/aaaaaaaaJaaaaaaac").route_index, 8); + EXPECT_EQ(list.match("/aaaaaaaaJaaaaaaab").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaBaaaaaaaa").route_index, 0); + EXPECT_EQ(list.match("/aaaaaaaaCaaaaaaaa").route_index, 1); + EXPECT_EQ(list.match("/aaaaaaaaDaaaaaaaa").route_index, 2); + EXPECT_EQ(list.match("/aaaaaaaaEaaaaaaaa").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaBaaaaaaaZ").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaBaaaaaaa").route_index, -1); +} + +TEST(url_pattern_list, witness_hostile_statics_before_params) { + // The same 17-byte statics as leading segments of ":param" routes: + // captures and misses stay exact through the direct-compare node. + auto list = make_list({ + "/aaaaaaaaBaaaaaaaa/:id", // 0 + "/aaaaaaaaCaaaaaaaa/:id", // 1 + "/aaaaaaaaDaaaaaaaa/:id", // 2 + }); + auto m = list.match("/aaaaaaaaCaaaaaaaa/42"); + EXPECT_EQ(m.route_index, 1); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/aaaaaaaaCaaaaaaaa/42", m, 0), "42"); + EXPECT_EQ(list.match("/aaaaaaaaEaaaaaaaa/42").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaCaaaaaaaa/").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaCaaaaaaaa").route_index, -1); +} + +TEST(url_pattern_list, witness_hostile_statics_share_a_first_byte) { + // Routes 0 and 1 collide in every addressable byte while routes 2-6 differ + // in one interior byte each; all seven share the first byte 'a' or 'b', so + // the root index maps a run of children to direct compares. + auto list = make_list({ + "/aaaaaaaaBaaaaaaaa/:id", // 0 + "/aaaaaaaaCaaaaaaaa/:id", // 1 (indistinguishable from 0 by witnesses) + "/baaaaaaaXaaaaaaaa/:id", // 2 (byte 0 column) + "/acaaaaaaXaaaaaaaa/:id", // 3 (byte 1 column) + "/aadaaaaaXaaaaaaaa/:id", // 4 (byte 2 column) + "/aaaeaaaaXaaaaaaaa/:id", // 5 (byte 3 column) + "/aaaafaaaXaaaaaaaa/:id", // 6 (byte 4 column) + }); + EXPECT_EQ(list.match("/aaaaaaaaBaaaaaaaa/1").route_index, 0); + EXPECT_EQ(list.match("/aaaaaaaaCaaaaaaaa/2").route_index, 1); + EXPECT_EQ(list.match("/baaaaaaaXaaaaaaaa/3").route_index, 2); + EXPECT_EQ(list.match("/aaaafaaaXaaaaaaaa/4").route_index, 6); + EXPECT_EQ(list.match("/zaaaaaaaXaaaaaaaa/3").route_index, -1); + EXPECT_EQ(list.match("/aaaaaaaaBaaaaaaaa").route_index, -1); +} + +TEST(url_pattern_list, single_character_statics_before_params) { + // Single-character statics under the root index, each followed by a + // param. + auto list = make_list({ + "/a/:id", // 0 + "/b/:id", // 1 + "/c/:id", // 2 + }); + auto m = list.match("/b/7"); + EXPECT_EQ(m.route_index, 1); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/b/7", m, 0), "7"); + EXPECT_EQ(list.match("/d/7").route_index, -1); + EXPECT_EQ(list.match("/b/").route_index, -1); +} + +TEST(url_pattern_list, many_static_segments_before_a_param) { + // Nine static segments and a trailing param: a deep chain of one-child + // nodes ending in a pure-leaf param shortcut. + auto list = make_list({ + "/a1/a2/a3/a4/a5/a6/a7/a8/a9/:id", // 0 + "/b1/b2/b3/b4/b5/b6/b7/b8/b9/:id", // 1 + "/c1/c2/c3/c4/c5/c6/c7/c8/c9/:id", // 2 + }); + auto m = list.match("/b1/b2/b3/b4/b5/b6/b7/b8/b9/id42"); + EXPECT_EQ(m.route_index, 1); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/b1/b2/b3/b4/b5/b6/b7/b8/b9/id42", m, 0), "id42"); + EXPECT_EQ(list.match("/b1/b2/b3/b4/b5/b6/b7/b8/x9/id42").route_index, -1); + EXPECT_EQ(list.match("/b1/b2/b3/b4/b5/b6/b7/b8/b9").route_index, -1); +} + +TEST(url_pattern_list, root_fanout_beyond_dispatch_table_capacity) { + // 260 param routes under distinct first segments: more root children than + // 8-bit slot ordinals can index (and more than max_direct_children per + // first byte), so the root runs a linear scan. + std::vector storage; + storage.reserve(260); + for (int i = 0; i < 260; i++) { + storage.push_back("/m" + std::to_string(i) + "/:id"); + } + std::vector patterns(storage.begin(), storage.end()); + auto list = make_list(patterns); + auto m = list.match("/m0/x"); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/m0/x", m, 0), "x"); + EXPECT_EQ(list.match("/m259/y").route_index, 259); + EXPECT_EQ(list.match("/m260/z").route_index, -1); + EXPECT_EQ(list.match("/m131/").route_index, -1); +} + +TEST(url_pattern_list, slot_tables_beyond_64_keys) { + // More than 64 keys in one dispatch table (80 root children sharing the + // first byte 'k', so the root index is not used): the multiplier search + // starts with the loosened load factor. + std::vector storage; + storage.reserve(80); + for (int i = 0; i < 80; i++) { + storage.push_back("/k" + std::to_string(i / 10) + std::to_string(i % 10)); + } + std::vector patterns(storage.begin(), storage.end()); + auto list = make_list(patterns); + EXPECT_EQ(list.match("/k00").route_index, 0); + EXPECT_EQ(list.match("/k42").route_index, 42); + EXPECT_EQ(list.match("/k79").route_index, 79); + EXPECT_EQ(list.match("/k80").route_index, -1); + EXPECT_EQ(list.match("/k7").route_index, -1); +} + +TEST(url_pattern_list, huge_static_key) { + // A 70000-byte static segment: the key lives in the blob (memcmp path) + // and the 70001-byte input is beyond the fast-path length limit, so the + // route answers through the sequential fallback. + const std::string huge = "/" + std::string(70000, 'a'); + auto list = make_list({huge, "/ok"}); + EXPECT_EQ(list.match(huge).route_index, 0); + EXPECT_EQ(list.match("/ok").route_index, 1); + EXPECT_EQ(list.match(huge + "b").route_index, -1); + EXPECT_EQ(list.match("/" + std::string(69999, 'a')).route_index, -1); +} + +TEST(url_pattern_list, segment_count_gate_boundaries) { + auto list = make_list({ + "/w/*", // 0 + "/x/:y", // 1 + }); + const auto with_segments = [](int n) { + std::string path = "/w"; + for (int i = 1; i < n; i++) { + path += "/s"; + } + return path; + }; + // 23 and 24 segments stay on the fast path; 25 is the exact-overflow case + // of the segment scan (detected only after the loop); 26+ overflow inside + // the scan. All must keep matching identically through the fallback. + for (int n : {23, 24, 25, 26, 30}) { + auto m = list.match(with_segments(n)); + EXPECT_EQ(m.route_index, 0) << n; + ASSERT_EQ(m.capture_count, 1u) << n; + EXPECT_EQ(capture_text(with_segments(n), m, 0), with_segments(n).substr(3)) + << n; + } + // 17 to 24 segments are within the fast path but deeper than any pattern. + std::string deep17 = "/x"; + for (int i = 1; i < 17; i++) { + deep17 += "/s"; + } + EXPECT_EQ(list.match(deep17).route_index, -1); + EXPECT_EQ(list.match("/x/hit").route_index, 1); +} + +TEST(url_pattern_list, param_then_static_routes_against_static_then_param) { + // Routes with a leading param and routes with a leading static that + // conflict at position 1 ("mm" vs "qq"): winners must be exactly the + // specificity-order winners, with backtracking from the static child to + // the param child. + auto list = make_list({ + "/:x/mm/a1", // 0 + "/:x/mm/a2", // 1 + "/:x/mm/a3", // 2 + "/pp/qq/:z", // 3 (outranks 0-2 whenever both could match) + }); + EXPECT_EQ(list.match("/pp/mm/a1").route_index, 0); + EXPECT_EQ(list.match("/zz/mm/a3").route_index, 2); + EXPECT_EQ(list.match("/pp/qq/tail").route_index, 3); + EXPECT_EQ(list.match("/pp/qq/a1").route_index, 3); + EXPECT_EQ(list.match("/pp/mm/a4").route_index, -1); + EXPECT_EQ(list.match("/pp/rr/a1").route_index, -1); +} + +TEST(url_pattern_list, overlapping_param_routes_keep_specificity_order) { + // Three route shapes of three segments that can all match one pathname + // ("q" at position 1 is shared): the specificity order must decide, with + // the walk backtracking through every alternative. + auto list = make_list({ + "/p/q/:z", // 0 (kinds [0,0,1]) + "/p/:y/r", // 1 (kinds [0,1,0]) + "/:x/q/s1", // 2 (kinds [1,0,0]) + "/:x/q/s2", // 3 + "/:x/q/s3", // 4 + }); + auto m = list.match("/p/q/s1"); + EXPECT_EQ(m.route_index, 0); // overlaps route 2; route 0 outranks it + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/p/q/s1", m, 0), "s1"); + EXPECT_EQ(list.match("/w/q/s2").route_index, 3); + EXPECT_EQ(list.match("/p/w/r").route_index, 1); + EXPECT_EQ(list.match("/p/q/r").route_index, 0); // beats "/p/:y/r" too + EXPECT_EQ(list.match("/w/q/s4").route_index, -1); +} + +TEST(url_pattern_list, kind_sequences_beyond_the_packed_prefix) { + // Kind sequences pack at most 32 segments and kind lengths saturate at + // 255: patterns beyond both caps must still build and match exactly. + std::string longpat; + for (int i = 0; i < 260; i++) { + longpat += "/x"; + } + std::string repat; + for (int i = 0; i < 259; i++) { + repat += "/a"; + } + repat += "/(\\d+)"; + auto list = make_list({longpat, repat}); + EXPECT_EQ(list.match(longpat).route_index, 0); + EXPECT_EQ(list.match(longpat + "/x").route_index, -1); + std::string reinput; + for (int i = 0; i < 259; i++) { + reinput += "/a"; + } + reinput += "/77"; + EXPECT_EQ(list.match(reinput).route_index, 1); + EXPECT_EQ(list.match(reinput + "x").route_index, -1); +} + +TEST(url_pattern_list, sequential_wildcard_still_needs_its_segment) { + // Nine params plus "*" is ten captures: beyond the fast-path capture + // limit, so the route is matched sequentially. The wildcard still demands + // a tenth (possibly empty) segment. + auto list = make_list({"/:a/:b/:c/:d/:e/:f/:g/:h/:i/*"}); + EXPECT_EQ(list.match("/1/2/3/4/5/6/7/8/9").route_index, -1); + auto m = list.match("/1/2/3/4/5/6/7/8/9/"); + EXPECT_EQ(m.route_index, 0); + EXPECT_EQ(m.capture_count, 8u); + EXPECT_TRUE(m.captures_truncated); + auto n = list.match("/1/2/3/4/5/6/7/8/9/tail/more"); + EXPECT_EQ(n.route_index, 0); + EXPECT_EQ(n.capture_count, 8u); + EXPECT_TRUE(n.captures_truncated); + EXPECT_EQ(capture_text("/1/2/3/4/5/6/7/8/9/tail/more", n, 7), "8"); +} + +TEST(url_pattern_list, create_errors) { + // Not a valid URLPattern pathname pattern at all. + EXPECT_FALSE(parse_list({"/users/(unclosed"}).has_value()); + // A '?' modifier cannot follow plain text. + EXPECT_FALSE(parse_list({"/a?b"}).has_value()); + // Duplicate group name within one pattern is a URLPattern type error. + auto dup = parse_list({"/:id/:id"}); + ASSERT_FALSE(dup.has_value()); + EXPECT_EQ(dup.error(), ada::errors::type_error); + // Tokenizes fine but the generated regex is rejected by the provider + // (invalid interval), which create must surface as the same error. + auto bad_regex = parse_list({"/(a{2,1})"}); + ASSERT_FALSE(bad_regex.has_value()); + EXPECT_EQ(bad_regex.error(), ada::errors::type_error); + // One bad pattern anywhere fails the whole list. + EXPECT_FALSE(parse_list({"/fine", "/also/:ok", "/(a{2,1})"}).has_value()); +} + +TEST(url_pattern_list, group_names_and_capture_alignment) { + auto list = make_list({ + "/u/:id", // 0 + "/v/:id", // 1: same group name in another route is fine + "/w/:id/:id2", // 2 + "/f/*", // 3: unnamed wildcard gets a numeric name + "/:a/mid/:b/*", // 4: params first, then the wildcard + "/users/(\\d+)", // 5: regexp route, numbered group + "/@:handle/x-(\\w+)", // 6: named and numbered groups mixed + }); + EXPECT_EQ(list.group_names(0), std::vector{"id"}); + EXPECT_EQ(list.group_names(1), std::vector{"id"}); + EXPECT_EQ(list.group_names(2), (std::vector{"id", "id2"})); + EXPECT_EQ(list.group_names(3), std::vector{"0"}); + EXPECT_EQ(list.group_names(4), (std::vector{"a", "b", "0"})); + EXPECT_EQ(list.group_names(5), std::vector{"0"}); + EXPECT_EQ(list.group_names(6), (std::vector{"handle", "0"})); + + // Captures align with group_names order: params left to right, then "*". + const std::string path = "/left/mid/right/t1/t2"; + auto m = list.match(path); + EXPECT_EQ(m.route_index, 4); + ASSERT_EQ(m.capture_count, 3u); + EXPECT_EQ(capture_text(path, m, 0), "left"); + EXPECT_EQ(capture_text(path, m, 1), "right"); + EXPECT_EQ(capture_text(path, m, 2), "t1/t2"); + + // Regexp routes surface their names and the provider's group values, in + // the same order. + auto r = list.match("/users/123"); + EXPECT_EQ(r.route_index, 5); + EXPECT_EQ(r.capture_count, 0u); + EXPECT_TRUE(r.regexp_route); + ASSERT_EQ(r.regexp_groups.size(), 1u); + EXPECT_EQ(r.regexp_groups[0], std::optional("123")); + auto h = list.match("/@bob/x-hello"); + EXPECT_EQ(h.route_index, 6); + ASSERT_EQ(h.regexp_groups.size(), 2u); + EXPECT_EQ(h.regexp_groups[0], std::optional("bob")); + EXPECT_EQ(h.regexp_groups[1], std::optional("hello")); +} + +// --------------------------------------------------------------------------- +// Provider, options and input-shape tests: the list must use the regex +// provider exactly as url_pattern does (create_instance with ignore_case, +// regex_search for group values), accept url_pattern_options and +// url_pattern objects, and prune the auxiliary routes it does not need. + +namespace { + +// A provider wrapper that counts its calls and records the ignore_case flag +// it was given, delegating to std_regex_provider. +struct counting_provider { + using regex_type = regex_provider::regex_type; + static inline size_t create_instance_calls = 0; + static inline size_t regex_search_calls = 0; + static inline size_t regex_match_calls = 0; + static inline bool last_ignore_case = false; + static void reset() { + create_instance_calls = 0; + regex_search_calls = 0; + regex_match_calls = 0; + last_ignore_case = false; + } + static std::optional create_instance(std::string_view pattern, + bool ignore_case) { + create_instance_calls++; + last_ignore_case = ignore_case; + return regex_provider::create_instance(pattern, ignore_case); + } + static std::optional>> regex_search( + std::string_view input, const regex_type& pattern) { + regex_search_calls++; + return regex_provider::regex_search(input, pattern); + } + static bool regex_match(std::string_view input, const regex_type& pattern) { + regex_match_calls++; + return regex_provider::regex_match(input, pattern); + } +}; +static_assert(ada::url_pattern_regex::regex_concept); + +using counting_list = ada::url_pattern_list; + +tl::expected parse_counting( + const std::vector& patterns, + const ada::url_pattern_options* options = nullptr) { + return ada::parse_url_pattern_list(patterns, nullptr, + options); +} + +std::optional group(const char* s) { return std::string(s); } + +} // namespace + +TEST(url_pattern_list, custom_provider_is_used_for_regexp_routes) { + counting_provider::reset(); + auto list = parse_counting({ + "/users/(\\d+)", // 0: regexp, compiled through the provider + "/users/:id", // 1: subset, no provider involvement + "/files/*", // 2: subset + "/@:handle/(\\w+)" // 3: regexp + }); + ASSERT_TRUE(list.has_value()); + // One create_instance per regexp route; subset routes never touch the + // provider. + EXPECT_EQ(counting_provider::create_instance_calls, 2u); + EXPECT_FALSE(counting_provider::last_ignore_case); + + // A regexp winner is tested with regex_match and its groups then come + // from regex_search (as url_pattern::exec obtains them). + counting_provider::reset(); + auto m = list->match("/users/123"); + EXPECT_EQ(m.route_index, 0); // outranks "/users/:id" on insertion order + EXPECT_TRUE(m.regexp_route); + ASSERT_EQ(m.regexp_groups.size(), 1u); + EXPECT_EQ(m.regexp_groups[0], group("123")); + EXPECT_EQ(counting_provider::regex_search_calls, 1u); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); + + // A fast-path miss scans the auxiliary routes, but a route whose literal + // prefix ("users") cannot fit the input is skipped before the provider. + counting_provider::reset(); + auto h = list->match("/@bob/hello"); + EXPECT_EQ(h.route_index, 3); + ASSERT_EQ(h.regexp_groups.size(), 2u); + EXPECT_EQ(h.regexp_groups[0], group("bob")); + EXPECT_EQ(h.regexp_groups[1], group("hello")); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); + EXPECT_EQ(counting_provider::regex_search_calls, 1u); + + // A subset winner that nothing outranks never runs the provider. + counting_provider::reset(); + auto f = list->match("/files/a/b"); + EXPECT_EQ(f.route_index, 2); + EXPECT_FALSE(f.regexp_route); + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + EXPECT_EQ(counting_provider::regex_match_calls, 0u); +} + +TEST(url_pattern_list, regexp_groups_report_unmatched_optional_groups) { + auto list = make_list({"/users/:id?"}); + auto with = list.match("/users/7"); + EXPECT_EQ(with.route_index, 0); + EXPECT_TRUE(with.regexp_route); + ASSERT_EQ(with.regexp_groups.size(), 1u); + EXPECT_EQ(with.regexp_groups[0], group("7")); + auto without = list.match("/users"); + EXPECT_EQ(without.route_index, 0); + ASSERT_EQ(without.regexp_groups.size(), 1u); + EXPECT_EQ(without.regexp_groups[0], std::nullopt); + EXPECT_EQ(list.group_names(0), std::vector{"id"}); +} + +TEST(url_pattern_list, auxiliary_routes_are_pruned_after_a_fast_path_hit) { + // A regexp route that outranks a compiled winner (same kind sequence, + // earlier insertion, compatible literal prefix) must still be tested; one + // that cannot outrank the winner, or cannot match the same input, must + // not cost a regex execution. + { + counting_provider::reset(); + auto list = parse_counting({ + "/users/(\\d+)", // 0: outranks route 1 on insertion order + "/users/:id", // 1 + "/posts/(\\d+)", // 2: literal "posts" can never match "/users/..." + }); + ASSERT_TRUE(list.has_value()); + counting_provider::reset(); + auto digits = list->match("/users/123"); + EXPECT_EQ(digits.route_index, 0); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); // route 0 only + EXPECT_EQ(counting_provider::regex_search_calls, 1u); + counting_provider::reset(); + auto name = list->match("/users/bob"); + EXPECT_EQ(name.route_index, 1); + ASSERT_EQ(name.capture_count, 1u); + EXPECT_EQ(capture_text("/users/bob", name, 0), "bob"); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); // route 0 only + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + counting_provider::reset(); + auto post = list->match("/posts/9"); + EXPECT_EQ(post.route_index, 2); // fast-path miss: aux routes scanned + EXPECT_EQ(counting_provider::regex_match_calls, 1u); // route 2 only + EXPECT_EQ(counting_provider::regex_search_calls, 1u); + } + { + counting_provider::reset(); + auto list = parse_counting({ + "/users/:id", // 0 + "/users/(\\d+)", // 1: same kind sequence, later: never outranks 0 + "/(\\d+)/edit", // 2: kinds [param, literal]: outranked by 0 + }); + ASSERT_TRUE(list.has_value()); + counting_provider::reset(); + auto m = list->match("/users/123"); + EXPECT_EQ(m.route_index, 0); + EXPECT_FALSE(m.regexp_route); + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + EXPECT_EQ(counting_provider::regex_match_calls, 0u); + EXPECT_EQ(list->match("/7/edit").route_index, 2); + } + { + // A static winner is outranked by nothing: no regex ever runs after a + // static hit, whatever the regexp routes look like. + counting_provider::reset(); + auto list = parse_counting({"/(.*)", "/health", "/users/(\\d+)"}); + ASSERT_TRUE(list.has_value()); + counting_provider::reset(); + EXPECT_EQ(list->match("/health").route_index, 1); + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + EXPECT_EQ(list->match("/other").route_index, 0); + } +} + +TEST(url_pattern_list, ignore_case_option_subset_routes) { + const ada::url_pattern_options options{.ignore_case = true}; + auto list = make_list( + { + "/Users/:id", // 0 + "/About", // 1 + "/Files/*", // 2 + "/API/v1/:a/:b", // 3 + }, + &options); + EXPECT_TRUE(list.ignore_case()); + for (const char* input : {"/users/42", "/USERS/42", "/Users/42"}) { + auto m = list.match(input); + EXPECT_EQ(m.route_index, 0) << input; + ASSERT_EQ(m.capture_count, 1u) << input; + // Captures slice the original input, not a folded copy. + EXPECT_EQ(capture_text(input, m, 0), "42") << input; + } + EXPECT_EQ(list.match("/about").route_index, 1); + EXPECT_EQ(list.match("/ABOUT").route_index, 1); + EXPECT_EQ(list.match("/abut").route_index, -1); + auto w = list.match("/FILES/A/B"); + EXPECT_EQ(w.route_index, 2); + ASSERT_EQ(w.capture_count, 1u); + EXPECT_EQ(capture_text("/FILES/A/B", w, 0), "A/B"); + auto d = list.match("/api/V1/X/y"); + EXPECT_EQ(d.route_index, 3); + ASSERT_EQ(d.capture_count, 2u); + EXPECT_EQ(capture_text("/api/V1/X/y", d, 0), "X"); + EXPECT_EQ(capture_text("/api/V1/X/y", d, 1), "y"); + // Case-sensitive by default. + auto strict = make_list({"/Users/:id"}); + EXPECT_FALSE(strict.ignore_case()); + EXPECT_EQ(strict.match("/users/42").route_index, -1); + EXPECT_EQ(strict.match("/Users/42").route_index, 0); + // Beyond the fast-path length the sequential fallback folds on the fly. + std::string longu = "/FILES/" + std::string(5000, 'X'); + auto l = list.match(longu); + EXPECT_EQ(l.route_index, 2); + ASSERT_EQ(l.capture_count, 1u); + EXPECT_EQ(l.captures[0].length, 5000u); +} + +TEST(url_pattern_list, ignore_case_agrees_with_url_pattern) { + const ada::url_pattern_options options{.ignore_case = true}; + const std::vector patterns = { + "/Users/:id", "/About", "/Files/*", "/a/B/c", "/x-(\\d+)/Y", "/Q/:p?"}; + const std::vector inputs = { + "/users/1", "/USERS/1", "/about", "/ABOUT/", "/files/X", "/A/b/C", + "/a/b/c/", "/x-7/y", "/X-7/Y", "/x-a/y", "/q", "/Q/z"}; + for (const std::string_view pattern : patterns) { + auto list = make_list({pattern}, &options); + auto url_pattern = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = std::string(pattern)}, nullptr, + &options); + ASSERT_TRUE(url_pattern.has_value()) << pattern; + for (const std::string_view input : inputs) { + auto expected = url_pattern->test( + ada::url_pattern_init{.pathname = std::string(input)}); + ASSERT_TRUE(expected.has_value()) << pattern << " " << input; + EXPECT_EQ(list.match(input).has_match(), *expected) + << "pattern=" << pattern << " input=" << input; + } + } +} + +TEST(url_pattern_list, ignore_case_reaches_the_provider) { + const ada::url_pattern_options options{.ignore_case = true}; + counting_provider::reset(); + auto list = parse_counting({"/Docs/(\\d+)", "/users/:id"}, &options); + ASSERT_TRUE(list.has_value()); + EXPECT_EQ(counting_provider::create_instance_calls, 1u); + EXPECT_TRUE(counting_provider::last_ignore_case); + auto m = list->match("/DOCS/12"); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.regexp_groups.size(), 1u); + EXPECT_EQ(m.regexp_groups[0], group("12")); + // Duplicate patterns under folding collapse onto the smaller index. + auto dup = make_list({"/Users/:id", "/users/:id"}, &options); + EXPECT_EQ(dup.match("/USERS/1").route_index, 0); +} + +TEST(url_pattern_list, parse_url_pattern_list_free_function) { + const std::vector patterns = {"/", "/users/:id", + "/files/*"}; + auto list = ada::parse_url_pattern_list(patterns); + ASSERT_TRUE(list.has_value()); + EXPECT_EQ(list->size(), 3u); + EXPECT_EQ(list->match("/users/1").route_index, 1); + EXPECT_EQ(list->pattern(1), "/users/:id"); + // Errors surface exactly as from the URLPattern constructor. + auto bad = ada::parse_url_pattern_list( + std::vector{"/fine", "/(a{2,1})"}); + ASSERT_FALSE(bad.has_value()); + EXPECT_EQ(bad.error(), ada::errors::type_error); + // An empty span is an empty list. + auto empty = ada::parse_url_pattern_list( + std::span{}); + ASSERT_TRUE(empty.has_value()); + EXPECT_EQ(empty->size(), 0u); +} + +TEST(url_pattern_list, parse_url_pattern_list_with_base_url) { + // With a base URL, each pattern is processed as the pathname of a + // URLPatternInit: relative patterns resolve against the base's path. + const std::string_view base = "https://example.com/app/index.html"; + const std::vector patterns = {"users/:id", "/abs", + "./rel/*"}; + auto list = ada::parse_url_pattern_list(patterns, &base); + ASSERT_TRUE(list.has_value()); + EXPECT_EQ(list->pattern(0), "/app/users/:id"); + EXPECT_EQ(list->pattern(1), "/abs"); + EXPECT_EQ(list->pattern(2), "/app/./rel/*"); + auto m = list->match("/app/users/7"); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/app/users/7", m, 0), "7"); + EXPECT_EQ(list->match("/abs").route_index, 1); + EXPECT_EQ(list->match("/users/7").route_index, -1); + // The same processing url_pattern applies to a relative pattern string. + auto up = ada::parse_url_pattern("users/:id", &base); + ASSERT_TRUE(up.has_value()); + EXPECT_EQ(up->get_pathname(), list->pattern(0)); + // An unparsable base URL is a type error, as for parse_url_pattern. + const std::string_view bad_base = "not a url"; + auto bad = ada::parse_url_pattern_list(patterns, &bad_base); + ASSERT_FALSE(bad.has_value()); + EXPECT_EQ(bad.error(), ada::errors::type_error); +} + +TEST(url_pattern_list, url_pattern_objects_as_input) { + std::vector> patterns; + for (const char* pathname : + {"/", "/users/:id", "/users/(\\d+)", "/files/*", "/:a/x-(\\w+)"}) { + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = pathname}); + ASSERT_TRUE(parsed.has_value()) << pathname; + patterns.push_back(std::move(*parsed)); + } + auto list = ada::parse_url_pattern_list( + std::span>(patterns)); + ASSERT_TRUE(list.has_value()); + ASSERT_EQ(list->size(), 5u); + for (size_t i = 0; i < patterns.size(); i++) { + EXPECT_EQ(list->pattern(i), patterns[i].get_pathname()); + } + EXPECT_EQ(list->match("/").route_index, 0); + auto m = list->match("/users/bob"); + EXPECT_EQ(m.route_index, 1); + ASSERT_EQ(m.capture_count, 1u); + EXPECT_EQ(capture_text("/users/bob", m, 0), "bob"); + // Regexp routes reuse the pattern's compiled pathname component and + // report its groups. + auto d = list->match("/users/42"); + EXPECT_EQ(d.route_index, 1); // "/users/:id" wins the tie on insertion + auto w = list->match("/left/x-right"); + EXPECT_EQ(w.route_index, 4); + EXPECT_TRUE(w.regexp_route); + EXPECT_EQ(list->group_names(4), (std::vector{"a", "0"})); + ASSERT_EQ(w.regexp_groups.size(), 2u); + EXPECT_EQ(w.regexp_groups[0], group("left")); + EXPECT_EQ(w.regexp_groups[1], group("right")); + // The url_pattern's own exec agrees on the groups. + auto exec = + patterns[4].exec(ada::url_pattern_init{.pathname = "/left/x-right"}); + ASSERT_TRUE(exec.has_value() && exec->has_value()); + EXPECT_EQ((*exec)->pathname.groups.at("a"), group("left")); + EXPECT_EQ((*exec)->pathname.groups.at("0"), group("right")); +} + +TEST(url_pattern_list, url_pattern_objects_carry_ignore_case) { + const ada::url_pattern_options options{.ignore_case = true}; + std::vector> patterns; + for (const char* pathname : {"/Users/:id", "/Docs/(\\d+)"}) { + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = pathname}, nullptr, &options); + ASSERT_TRUE(parsed.has_value()) << pathname; + patterns.push_back(std::move(*parsed)); + } + auto list = ada::parse_url_pattern_list( + std::span>(patterns)); + ASSERT_TRUE(list.has_value()); + EXPECT_TRUE(list->ignore_case()); + EXPECT_EQ(list->match("/users/1").route_index, 0); + auto d = list->match("/docs/9"); + EXPECT_EQ(d.route_index, 1); + ASSERT_EQ(d.regexp_groups.size(), 1u); + EXPECT_EQ(d.regexp_groups[0], group("9")); + // Mixed flags cannot share one list. + auto strict = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = "/x"}); + ASSERT_TRUE(strict.has_value()); + patterns.push_back(std::move(*strict)); + auto mixed = ada::parse_url_pattern_list( + std::span>(patterns)); + ASSERT_FALSE(mixed.has_value()); + EXPECT_EQ(mixed.error(), ada::errors::type_error); +} + +// --------------------------------------------------------------------------- +// Matcher-internal sweeps: the SWAR segment scan, short-tail compares and +// the root first-byte index. + +namespace { + +std::vector naive_segment_starts(std::string_view url) { + std::vector starts{1}; + for (size_t i = 1; i < url.size(); i++) { + if (url[i] == '/') { + starts.push_back(static_cast(i + 1)); + } + } + return starts; +} + +} // namespace + +TEST(url_pattern_list, segment_scan_sweep) { + // Both scans (the portable SWAR one, and on AArch64 the NEON one that + // scan_segments selects from 16 bytes on) against a naive scanner. + namespace detail = ada::url_pattern_list_detail; + using ada::url_pattern_list_limits::max_fast_path_segments; + std::mt19937_64 rng(0x5CA7ull); + // Filler bytes include values >= 0x80, the byte that differs from '/' + // only in the high bit (which must never read as a separator), and the + // two line terminators, which the scan treats as ordinary bytes. + const unsigned char fillers[] = {'a', 'z', '0', 0x80, 0xAF, + 0xFF, 0x2E, 0x30, '\n', '\r'}; + for (uint32_t len = 1; len <= 70; len++) { + for (int variant = 0; variant < 12; variant++) { + std::string url(len, 'a'); + url[0] = '/'; + for (uint32_t i = 1; i < len; i++) { + url[i] = static_cast(fillers[rng() % 10]); + } + // Slash placements: none, every position, first/last, random. + if (variant == 1) { + for (uint32_t i = 1; i < len; i++) { + url[i] = '/'; + } + } else if (variant == 2 && len > 1) { + url[1] = '/'; + } else if (variant == 3 && len > 1) { + url[len - 1] = '/'; + } else if (variant >= 4) { + const uint32_t n_slashes = static_cast(rng() % 6); + for (uint32_t k = 0; k < n_slashes && len > 1; k++) { + url[1 + rng() % (len - 1)] = '/'; + } + } + const std::vector expected = naive_segment_starts(url); + for (int which = 0; which < 2; which++) { + uint16_t soff[max_fast_path_segments + 1]; + const uint32_t nseg = + which == 0 + ? detail::scan_segments(url.data(), + static_cast(url.size()), soff) + : detail::scan_segments_swar( + url.data(), static_cast(url.size()), soff); + if (expected.size() > max_fast_path_segments) { + EXPECT_EQ(nseg, 0u) << "len=" << len << " variant=" << variant; + continue; + } + ASSERT_EQ(nseg, expected.size()) + << "len=" << len << " variant=" << variant << " which=" << which; + for (uint32_t i = 0; i < nseg; i++) { + EXPECT_EQ(soff[i], expected[i]) + << "len=" << len << " variant=" << variant << " i=" << i; + } + EXPECT_EQ(soff[nseg], len + 1); + } + } + } +} + +TEST(url_pattern_list, short_tail_segments_compare_exactly) { + // Final segments of 1..7 bytes are compared without loading past the end + // of the input; a mismatch in the last byte, an extra byte, or a missing + // byte must all be rejected. + std::vector storage; + for (uint32_t len = 1; len <= 7; len++) { + storage.push_back("/t/" + std::string(len, 'x')); // 0,2,...: static + storage.push_back("/p/:id/" + std::string(len, 'y')); // after a param + } + std::vector patterns(storage.begin(), storage.end()); + auto list = make_list(patterns); + for (uint32_t len = 1; len <= 7; len++) { + const int32_t route = static_cast((len - 1) * 2); + const std::string hit = "/t/" + std::string(len, 'x'); + EXPECT_EQ(list.match(hit).route_index, route) << len; + std::string last_differs = hit; + last_differs.back() = 'X'; + EXPECT_EQ(list.match(last_differs).route_index, -1) << len; + EXPECT_EQ(list.match(hit + "x").route_index, len < 7 ? route + 2 : -1) + << len; + EXPECT_EQ(list.match(hit + "/").route_index, -1) << len; + const std::string after_param = "/p/42/" + std::string(len, 'y'); + auto m = list.match(after_param); + EXPECT_EQ(m.route_index, route + 1) << len; + ASSERT_EQ(m.capture_count, 1u) << len; + EXPECT_EQ(capture_text(after_param, m, 0), "42") << len; + std::string wrong = after_param; + wrong.back() = 'Y'; + EXPECT_EQ(list.match(wrong).route_index, -1) << len; + } + // Short non-final segments (8 readable bytes available) take the masked + // whole-word compare; they must reject on every byte too. + auto mid = make_list({"/ab/cd/efgh/i", "/ab/cX/efgh/i"}); + EXPECT_EQ(mid.match("/ab/cd/efgh/i").route_index, 0); + EXPECT_EQ(mid.match("/ab/cX/efgh/i").route_index, 1); + EXPECT_EQ(mid.match("/ab/cdd/efgh/i").route_index, -1); + EXPECT_EQ(mid.match("/aX/cd/efgh/i").route_index, -1); +} + +TEST(url_pattern_list, root_first_byte_index) { + // Routes differing only after byte 0, several sharing a first byte, and + // first bytes with no route at all. + auto list = make_list({ + "/users", // 0 + "/uploads", // 1 + "/u", // 2 + "/user", // 3 + "/posts", // 4 + "/p", // 5 + "/health", // 6 + "/users/:id", // 7 + "/x/*", // 8 + }); + EXPECT_EQ(list.match("/users").route_index, 0); + EXPECT_EQ(list.match("/uploads").route_index, 1); + EXPECT_EQ(list.match("/u").route_index, 2); + EXPECT_EQ(list.match("/user").route_index, 3); + EXPECT_EQ(list.match("/posts").route_index, 4); + EXPECT_EQ(list.match("/p").route_index, 5); + EXPECT_EQ(list.match("/health").route_index, 6); + EXPECT_EQ(list.match("/users/9").route_index, 7); + EXPECT_EQ(list.match("/x/a/b").route_index, 8); + EXPECT_EQ(list.match("/uu").route_index, -1); + EXPECT_EQ(list.match("/zzz").route_index, -1); + EXPECT_EQ(list.match("/").route_index, -1); + EXPECT_EQ(list.match("/upload").route_index, -1); + EXPECT_EQ(list.match("/User").route_index, -1); + // More than max_direct_children children sharing one first byte: the + // index is not used and the root falls back to the projection ladder. + std::vector storage; + for (int i = 0; i < 20; i++) { + storage.push_back("/same" + std::to_string(i)); + } + storage.push_back("/other"); + std::vector patterns(storage.begin(), storage.end()); + auto wide = make_list(patterns); + EXPECT_EQ(wide.match("/same7").route_index, 7); + EXPECT_EQ(wide.match("/same19").route_index, 19); + EXPECT_EQ(wide.match("/other").route_index, 20); + EXPECT_EQ(wide.match("/same20").route_index, -1); +} + +TEST(url_pattern_list, direct_compare_fanout_up_to_eight) { + // A non-root node with up to 8 static children compares them directly; + // nine children switch to projection. Both must answer identically. + for (int fanout : {3, 8, 9, 16}) { + std::vector storage; + for (int i = 0; i < fanout; i++) { + storage.push_back("/api/child" + std::to_string(i)); + } + storage.push_back("/api/:rest"); + std::vector patterns(storage.begin(), storage.end()); + auto list = make_list(patterns); + for (int i = 0; i < fanout; i++) { + EXPECT_EQ(list.match("/api/child" + std::to_string(i)).route_index, i) + << fanout; + } + EXPECT_EQ(list.match("/api/child" + std::to_string(fanout)).route_index, + fanout) + << fanout; // the param route + EXPECT_EQ(list.match("/api/child0/x").route_index, -1) << fanout; + } +} + +TEST(url_pattern_list, regexp_route_shape_check_precedes_the_provider) { + // A regexp route made only of fixed text and ":name" groups has an exact + // segment count and exact literal positions: inputs that cannot fit it + // are rejected before any provider call, even on the "/*" hits that it + // outranks. + counting_provider::reset(); + auto list = parse_counting({"/@:handle/status/:sid", "/*"}); + ASSERT_TRUE(list.has_value()); + counting_provider::reset(); + EXPECT_EQ(list->match("/a/b").route_index, 1); // 2 segments: never + EXPECT_EQ(list->match("/a/b/c/d").route_index, 1); + EXPECT_EQ(list->match("/a/nope/c").route_index, 1); // literal mismatch + EXPECT_EQ(list->match("/a/status/").route_index, 1); // empty ":sid" + EXPECT_EQ(counting_provider::regex_match_calls, 0u); + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + // The shape fits but the regex does not: one regex_match, no search. + counting_provider::reset(); + EXPECT_EQ(list->match("/x/status/y").route_index, 1); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); + EXPECT_EQ(counting_provider::regex_search_calls, 0u); + // A hit: regex_match then regex_search for the groups. + counting_provider::reset(); + auto m = list->match("/@bob/status/77"); + EXPECT_EQ(m.route_index, 0); + ASSERT_EQ(m.regexp_groups.size(), 2u); + EXPECT_EQ(m.regexp_groups[0], group("bob")); + EXPECT_EQ(m.regexp_groups[1], group("77")); + EXPECT_EQ(counting_provider::regex_match_calls, 1u); + EXPECT_EQ(counting_provider::regex_search_calls, 1u); + // A custom group may span segments, so only the literal prefix before it + // is trusted: "/a/x/y/edit" must still reach the provider. + auto custom = make_list({"/a/(.*)/edit", "/*"}); + EXPECT_EQ(custom.match("/a/x/y/edit").route_index, 0); + EXPECT_EQ(custom.match("/b/x/edit").route_index, 1); + // Not anchored at '/': nothing is assumed about the shape. + auto loose = make_list({"(.*)", "/*"}); + EXPECT_EQ(loose.match("/anything/at/all").route_index, 0); +} + +TEST(url_pattern_list, wildcard_does_not_match_line_terminators) { + // "*" stands for "(.*)" in the URLPattern regexp, and "." does not match + // a line terminator, while ":param" ("[^/]+?") does. Canonical pathnames + // contain neither LF nor CR; this pins the rule for raw inputs. + const std::vector patterns = {"/files/*", "/:name", "/*"}; + auto list = make_list(patterns); + std::vector> objects; + for (std::string_view pattern : patterns) { + auto parsed = ada::parse_url_pattern( + ada::url_pattern_init{.pathname = std::string(pattern)}); + ASSERT_TRUE(parsed.has_value()); + objects.push_back(std::move(*parsed)); + } + const auto any_url_pattern_matches = [&](std::string_view input) { + for (const auto& object : objects) { + if (object.pathname_component.fast_match(input)) { + return true; + } + } + return false; + }; + EXPECT_EQ(list.match("/files/a/b").route_index, 0); + EXPECT_EQ(list.match("/files/").route_index, 0); + EXPECT_EQ(list.match("/files/a\nb").route_index, -1); + EXPECT_EQ(list.match("/files/\r").route_index, -1); + EXPECT_EQ(list.match("/a\r/b").route_index, -1); + EXPECT_EQ(list.match("/x\ny").route_index, 1); // "[^/]+?" matches LF + EXPECT_EQ(list.match("/%0A").route_index, 1); // the canonical form + for (std::string_view input : + {"/files/a/b", "/files/", "/files/a\nb", "/files/\r", "/a\r/b", "/x\ny", + "/\n", "/%0A", "/files/a%0Ab"}) { + EXPECT_EQ(list.match(input).has_match(), any_url_pattern_matches(input)) + << input; + } + // The same rule in the sequential matcher (a 17-segment route is beyond + // the trie limit). + std::string deep; + for (int i = 0; i < 16; i++) { + deep += "/s" + std::to_string(i); + } + const std::string deep_wildcard = deep + "/*"; + auto sequential = make_list({deep_wildcard}); + EXPECT_EQ(sequential.match(deep + "/x/y").route_index, 0); + EXPECT_EQ(sequential.match(deep + "/").route_index, 0); + EXPECT_EQ(sequential.match(deep + "/x\ny").route_index, -1); + EXPECT_EQ(sequential.match(deep + "/\r").route_index, -1); +} + +TEST(url_pattern_list, wildcard_tail_check_covers_every_length) { + // The wildcard tail check works 8 bytes at a time: a terminator at every + // position of tails from 1 to 40 bytes must be seen, and tails without + // one (including bytes >= 0x80 and other control bytes) must pass. + auto list = make_list({"/files/*"}); + for (uint32_t n = 1; n <= 40; n++) { + std::string clean = "/files/" + std::string(n, 'x'); + clean[7 + n / 2] = static_cast(0xC3); // a non-ASCII byte + if (n > 2) { + clean[8] = '\t'; // a control byte that is not a terminator + } + EXPECT_EQ(list.match(clean).route_index, 0) << n; + for (uint32_t at = 0; at < n; at++) { + std::string url = "/files/" + std::string(n, 'x'); + url[7 + at] = (at % 2) ? '\n' : '\r'; + EXPECT_EQ(list.match(url).route_index, -1) << n << " " << at; + } + } +} + +TEST(url_pattern_list, inputs_need_no_terminator) { + // match() reads exactly the bytes of the view it is given: a pathname in an + // exactly sized heap buffer, with no terminator after it, is matched like + // any other (a read past its end is a heap-buffer-overflow under ASan). + const auto exact = [](const list_type& list, std::string_view input) { + const std::vector buffer(input.begin(), input.end()); + return list.match(std::string_view(buffer.data(), buffer.size())) + .route_index; + }; + auto list = make_list( + {"/", "/users/:id", "/users/me", "/posts", "/about/*", "/a/b/c/d/e/f/g"}); + EXPECT_EQ(exact(list, "/"), 0); + EXPECT_EQ(exact(list, "/users/42"), 1); + EXPECT_EQ(exact(list, "/users/me"), 2); + EXPECT_EQ(exact(list, "/posts"), 3); + EXPECT_EQ(exact(list, "/about/x/y"), 4); + EXPECT_EQ(exact(list, "/a/b/c/d/e/f/g"), 5); + EXPECT_EQ(exact(list, "/a/b/c/d/e/f/gh"), -1); + EXPECT_EQ(exact(list, "/users/"), -1); + EXPECT_EQ(exact(list, "//"), -1); + EXPECT_EQ(exact(list, ""), -1); + // A root with the first-byte index (three or more children, none with an + // empty key) probed with empty segments: "/" has no first byte to index. + auto indexed = make_list({"/users/:id", "/users/me", "/posts", "/about/*"}); + EXPECT_EQ(exact(indexed, "/"), -1); + EXPECT_EQ(exact(indexed, "//"), -1); + EXPECT_EQ(exact(indexed, "/users/"), -1); + EXPECT_EQ(exact(indexed, "/posts/"), -1); + EXPECT_EQ(exact(indexed, "/p"), -1); + EXPECT_EQ(exact(indexed, "/posts"), 2); +}