Files
bun.sh/src/router.zig

1666 lines
60 KiB
Zig

// This is a Next.js-compatible file-system router.
// It uses the filesystem to infer entry points.
// Despite being Next.js-compatible, it's not tied to Next.js.
// It does not handle the framework parts of rendering pages.
// All it does is resolve URL paths to the appropriate entry point and parse URL params/query.
const Router = @This();
const Api = @import("./api/schema.zig").Api;
const std = @import("std");
usingnamespace @import("global.zig");
const DirInfo = @import("./resolver/dir_info.zig");
const Fs = @import("./fs.zig");
const Options = @import("./options.zig");
const allocators = @import("./allocators.zig");
const URLPath = @import("./http/url_path.zig");
const PathnameScanner = @import("./query_string_map.zig").PathnameScanner;
const CodepointIterator = @import("./string_immutable.zig").CodepointIterator;
const index_route_hash = @truncate(u32, std.hash.Wyhash.hash(0, "$$/index-route$$-!(@*@#&*%-901823098123"));
const arbitrary_max_route = 4096;
pub const Param = struct {
name: string,
value: string,
pub const List = std.MultiArrayList(Param);
};
dir: StoredFileDescriptorType = 0,
routes: Routes,
loaded_routes: bool = false,
allocator: *std.mem.Allocator,
fs: *Fs.FileSystem,
config: Options.RouteConfig,
pub fn init(
fs: *Fs.FileSystem,
allocator: *std.mem.Allocator,
config: Options.RouteConfig,
) !Router {
return Router{
.routes = Routes{
.config = config,
.allocator = allocator,
.static = std.StringHashMap(*Route).init(allocator),
},
.fs = fs,
.allocator = allocator,
.config = config,
};
}
pub fn getEntryPoints(this: *const Router) ![]const string {
return this.routes.list.items(.filepath);
}
pub fn getPublicPaths(this: *const Router) ![]const string {
return this.routes.list.items(.public_path);
}
pub fn routeIndexByHash(this: *const Router, hash: u32) ?usize {
if (hash == index_route_hash) {
return this.routes.index_id;
}
return std.mem.indexOfScalar(u32, this.routes.list.items(.hash), hash);
}
pub fn getNames(this: *const Router) ![]const string {
return this.routes.list.items(.name);
}
const banned_dirs = [_]string{
"node_modules",
};
const RouteIndex = struct {
route: *Route,
name: string,
match_name: string,
filepath: string,
public_path: string,
hash: u32,
pub const List = std.MultiArrayList(RouteIndex);
};
pub const Routes = struct {
list: RouteIndex.List = RouteIndex.List{},
dynamic: []*Route = &[_]*Route{},
dynamic_names: []string = &[_]string{},
dynamic_match_names: []string = &[_]string{},
/// completely static children of indefinite depth
/// `"blog/posts"`
/// `"dashboard"`
/// `"profiles"`
/// this is a fast path?
static: std.StringHashMap(*Route),
/// Corresponds to "index.js" on the filesystem
index: ?*Route = null,
index_id: ?usize = 0,
allocator: *std.mem.Allocator,
config: Options.RouteConfig,
// This is passed here and propagated through Match
// We put this here to avoid loading the FrameworkConfig for the client, on the server.
client_framework_enabled: bool = false,
pub fn matchPage(this: *Routes, routes_dir: string, url_path: URLPath, params: *Param.List) ?Match {
// Trim trailing slash
var path = url_path.path;
var redirect = false;
// Normalize trailing slash
// "/foo/bar/index/" => "/foo/bar/index"
if (path.len > 0 and path[path.len - 1] == '/') {
path = path[0 .. path.len - 1];
redirect = true;
}
// Normal case: "/foo/bar/index" => "/foo/bar"
// Pathological: "/foo/bar/index/index/index/index/index/index" => "/foo/bar"
// Extremely pathological: "/index/index/index/index/index/index/index" => "index"
while (strings.endsWith(path, "/index")) {
path = path[0 .. path.len - "/index".len];
redirect = true;
}
if (strings.eqlComptime(path, "index")) {
path = "";
redirect = true;
}
// one final time, trim trailing slash
while (path.len > 0 and path[path.len - 1] == '/') {
path = path[0 .. path.len - 1];
redirect = true;
}
if (strings.eqlComptime(path, ".")) {
path = "";
redirect = false;
}
if (path.len == 0) {
if (this.index) |index| {
return Match{
.params = params,
.name = index.name,
.path = index.abs_path.slice(),
.pathname = url_path.pathname,
.basename = index.entry.base(),
.hash = index_route_hash,
.file_path = index.abs_path.slice(),
.query_string = url_path.query_string,
.client_framework_enabled = this.client_framework_enabled,
};
}
return null;
}
const MatchContextType = struct {
params: Param.List,
};
var matcher = MatchContextType{ .params = params.* };
defer params.* = matcher.params;
if (this.match(this.allocator, path, *MatchContextType, &matcher)) |route| {
return Match{
.params = params,
.name = route.name,
.path = route.abs_path.slice(),
.pathname = url_path.pathname,
.basename = route.entry.base(),
.hash = route.full_hash,
.file_path = route.abs_path.slice(),
.query_string = url_path.query_string,
.client_framework_enabled = this.client_framework_enabled,
};
}
return null;
}
fn matchDynamic(this: *Routes, allocator: *std.mem.Allocator, path: string, comptime MatchContext: type, ctx: MatchContext) ?*Route {
// its cleaned, so now we search the big list of strings
var i: usize = 0;
while (i < this.dynamic_names.len) : (i += 1) {
const name = this.dynamic_match_names[i];
const case_sensitive_name_without_leading_slash = this.dynamic_names[i][1..];
var offset: u32 = 0;
if (Pattern.match(path, name, case_sensitive_name_without_leading_slash, allocator, *@TypeOf(ctx.params), &ctx.params, true)) {
return this.dynamic[i];
}
}
return null;
}
fn match(this: *Routes, allocator: *std.mem.Allocator, pathname_: string, comptime MatchContext: type, ctx: MatchContext) ?*Route {
var pathname = std.mem.trimLeft(u8, pathname_, "/");
if (pathname.len == 0) {
return this.index;
}
return this.static.get(pathname) orelse
this.matchDynamic(allocator, pathname, MatchContext, ctx);
}
};
const RouteLoader = struct {
allocator: *std.mem.Allocator,
fs: *FileSystem,
config: Options.RouteConfig,
dedupe_dynamic: std.AutoArrayHashMap(u32, string),
log: *Logger.Log,
index: ?*Route = null,
static_list: std.StringHashMap(*Route),
all_routes: std.ArrayListUnmanaged(*Route),
pub fn appendRoute(this: *RouteLoader, route: Route) void {
// /index.js
if (route.full_hash == index_route_hash) {
var new_route = this.allocator.create(Route) catch unreachable;
this.index = new_route;
new_route.* = route;
this.all_routes.append(this.allocator, new_route) catch unreachable;
return;
}
// static route
if (route.param_count == 0) {
var entry = this.static_list.getOrPut(route.match_name.slice()) catch unreachable;
if (entry.found_existing) {
const source = Logger.Source.initEmptyFile(route.abs_path.slice());
this.log.addErrorFmt(
&source,
Logger.Loc.Empty,
this.allocator,
"Route \"{s}\" is already defined by {s}",
.{ route.name, entry.value_ptr.*.abs_path.slice() },
) catch unreachable;
return;
}
var new_route = this.allocator.create(Route) catch unreachable;
new_route.* = route;
entry.value_ptr.* = new_route;
this.all_routes.append(this.allocator, new_route) catch unreachable;
return;
}
{
const entry = this.dedupe_dynamic.getOrPutValue(route.full_hash, route.abs_path.slice()) catch unreachable;
if (entry.found_existing) {
const source = Logger.Source.initEmptyFile(route.abs_path.slice());
this.log.addErrorFmt(
&source,
Logger.Loc.Empty,
this.allocator,
"Route \"{s}\" is already defined by {s}",
.{ route.name, entry.value_ptr.* },
) catch unreachable;
return;
}
}
{
var new_route = this.allocator.create(Route) catch unreachable;
new_route.* = route;
this.all_routes.append(this.allocator, new_route) catch unreachable;
}
}
pub fn loadAll(allocator: *std.mem.Allocator, config: Options.RouteConfig, log: *Logger.Log, comptime ResolverType: type, resolver: *ResolverType, root_dir_info: *const DirInfo) Routes {
var this = RouteLoader{
.allocator = allocator,
.log = log,
.fs = resolver.fs,
.config = config,
.static_list = std.StringHashMap(*Route).init(allocator),
.dedupe_dynamic = std.AutoArrayHashMap(u32, string).init(allocator),
.all_routes = .{},
};
defer this.dedupe_dynamic.deinit();
this.load(ResolverType, resolver, root_dir_info);
if (this.all_routes.items.len == 0) return Routes{
.static = this.static_list,
.config = config,
.allocator = allocator,
};
std.sort.sort(*Route, this.all_routes.items, Route.Sorter{}, Route.Sorter.sortByName);
var route_list = RouteIndex.List{};
route_list.setCapacity(allocator, this.all_routes.items.len) catch unreachable;
var dynamic_start: ?usize = null;
var index_id: ?usize = null;
const public_dir_is_in_top_level_dir = strings.startsWith(this.config.dir, this.fs.top_level_dir);
for (this.all_routes.items) |route, i| {
if (route.param_count > 0 and dynamic_start == null) {
dynamic_start = i;
}
if (route.full_hash == index_route_hash) index_id = i;
route_list.appendAssumeCapacity(.{
.name = route.name,
.filepath = route.abs_path.slice(),
.match_name = route.match_name.slice(),
.public_path = route.public_path.slice(),
.route = route,
.hash = route.full_hash,
});
}
var dynamic: []*Route = &[_]*Route{};
var dynamic_names: []string = &[_]string{};
var dynamic_match_names: []string = &[_]string{};
if (dynamic_start) |dynamic_i| {
dynamic = route_list.items(.route)[dynamic_i..];
dynamic_names = route_list.items(.name)[dynamic_i..];
dynamic_match_names = route_list.items(.match_name)[dynamic_i..];
if (index_id) |index_i| {
if (index_i > dynamic_i) {
// Due to the sorting order, the index route can be the last route.
// We don't want to attempt to match the index route or different stuff will break.
dynamic = dynamic[0 .. dynamic.len - 1];
dynamic_names = dynamic_names[0 .. dynamic_names.len - 1];
dynamic_match_names = dynamic_match_names[0 .. dynamic_match_names.len - 1];
}
}
}
return Routes{
.list = route_list,
.dynamic = dynamic,
.dynamic_names = dynamic_names,
.dynamic_match_names = dynamic_match_names,
.static = this.static_list,
.index = this.index,
.config = config,
.allocator = allocator,
.index_id = index_id,
};
}
pub fn load(this: *RouteLoader, comptime ResolverType: type, resolver: *ResolverType, root_dir_info: *const DirInfo) void {
var fs = this.fs;
if (root_dir_info.getEntriesConst()) |entries| {
var iter = entries.data.iterator();
outer: while (iter.next()) |entry_ptr| {
const entry = entry_ptr.value;
if (entry.base()[0] == '.') {
continue :outer;
}
switch (entry.kind(&fs.fs)) {
.dir => {
inline for (banned_dirs) |banned_dir| {
if (strings.eqlComptime(entry.base(), comptime banned_dir)) {
continue :outer;
}
}
var abs_parts = [_]string{ entry.dir, entry.base() };
if (resolver.readDirInfoIgnoreError(fs.abs(&abs_parts))) |_dir_info| {
const dir_info: *const DirInfo = _dir_info;
this.load(
ResolverType,
resolver,
dir_info,
);
}
},
.file => {
const extname = std.fs.path.extension(entry.base());
// exclude "." or ""
if (extname.len < 2) continue;
for (this.config.extensions) |_extname| {
if (strings.eql(extname[1..], _extname)) {
// length is extended by one
// entry.dir is a string with a trailing slash
if (comptime isDebug) {
std.debug.assert(entry.dir.ptr[fs.top_level_dir.len - 1] == '/');
}
const public_dir = entry.dir.ptr[fs.top_level_dir.len - 1 .. entry.dir.len];
if (Route.parse(
entry.base(),
extname,
entry,
this.log,
this.allocator,
public_dir,
@truncate(u16, this.config.dir.len - fs.top_level_dir.len),
)) |route| {
this.appendRoute(route);
}
break;
}
}
},
}
}
}
}
};
// This loads routes recursively, in depth-first order.
// it does not currently handle duplicate exact route matches. that's undefined behavior, for now.
pub fn loadRoutes(
this: *Router,
log: *Logger.Log,
root_dir_info: *const DirInfo,
comptime ResolverType: type,
resolver: *ResolverType,
) anyerror!void {
if (this.loaded_routes) return;
this.routes = RouteLoader.loadAll(this.allocator, this.config, log, ResolverType, resolver, root_dir_info);
this.loaded_routes = true;
}
pub const TinyPtr = packed struct {
offset: u16 = 0,
len: u16 = 0,
pub inline fn str(this: TinyPtr, slice: string) string {
return if (this.len > 0) slice[this.offset .. this.offset + this.len] else "";
}
pub inline fn toStringPointer(this: TinyPtr) Api.StringPointer {
return Api.StringPointer{ .offset = this.offset, .length = this.len };
}
pub inline fn eql(a: TinyPtr, b: TinyPtr) bool {
return @bitCast(u32, a) == @bitCast(u32, b);
}
pub fn from(parent: string, in: string) TinyPtr {
if (in.len == 0 or parent.len == 0) return TinyPtr{};
const right = @ptrToInt(in.ptr) + in.len;
const end = @ptrToInt(parent.ptr) + parent.len;
if (comptime isDebug) {
std.debug.assert(end < right);
}
const length = @maximum(end, right) - right;
const offset = @maximum(@ptrToInt(in.ptr), @ptrToInt(parent.ptr)) - @ptrToInt(parent.ptr);
return TinyPtr{ .offset = @truncate(u16, offset), .len = @truncate(u16, length) };
}
};
pub const Route = struct {
/// Public display name for the route.
/// "/", "/index" is "/"
/// "/foo/index.js" becomes "/foo"
/// case-sensitive, has leading slash
name: string,
/// Name used for matching.
/// - Omits leading slash
/// - Lowercased
match_name: PathString,
entry: *Fs.FileSystem.Entry,
full_hash: u32,
param_count: u16,
abs_path: PathString,
/// URL-safe path for the route's transpiled script relative to project's top level directory
/// - It might not share a prefix with the absolute path due to symlinks.
/// - It has a leading slash
public_path: PathString,
pub const Ptr = TinyPtr;
pub const index_route_name: string = "/";
var route_file_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
var second_route_file_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
pub const Sorter = struct {
const sort_table: [std.math.maxInt(u8)]u8 = brk: {
var table: [std.math.maxInt(u8)]u8 = undefined;
var i: u16 = 0;
while (i < @as(u16, table.len)) {
table[i] = @intCast(u8, i);
i += 1;
}
// move dynamic routes to the bottom
table['['] = 252;
table[']'] = 253;
// of each segment
table['/'] = 254;
break :brk table;
};
pub fn sortByNameString(ctx: @This(), lhs: string, rhs: string) bool {
const math = std.math;
const n = @minimum(lhs.len, rhs.len);
var i: usize = 0;
while (i < n) : (i += 1) {
switch (math.order(sort_table[lhs[i]], sort_table[rhs[i]])) {
.eq => continue,
.lt => return true,
.gt => return false,
}
}
return math.order(lhs.len, rhs.len) == .lt;
}
pub fn sortByName(ctx: @This(), a: *Route, b: *Route) bool {
// ensure that dynamic routes are always at the bottom
// this is so we skip looking at static routes when matching dynamic routes
// without allocating a new array
if (a.param_count > 0 and b.param_count == 0) return false;
if (b.param_count > 0 and a.param_count == 0) return true;
return @call(.{ .modifier = .always_inline }, sortByNameString, .{ ctx, a.match_name.slice(), b.match_name.slice() });
}
};
pub fn parse(
base_: string,
extname: string,
entry: *Fs.FileSystem.Entry,
log: *Logger.Log,
allocator: *std.mem.Allocator,
public_dir_: string,
routes_dirname_len: u16,
) ?Route {
var abs_path_str: string = if (entry.abs_path.isEmpty())
""
else
entry.abs_path.slice();
var base = base_[0 .. base_.len - extname.len];
var public_dir = std.mem.trim(u8, public_dir_, "/");
// this is a path like
// "/pages/index.js"
// "/pages/foo/index.ts"
// "/pages/foo/bar.tsx"
// the name we actually store will often be this one
var public_path: string = brk: {
if (base.len == 0) break :brk public_dir;
route_file_buf[0] = '/';
var buf = route_file_buf[1..];
std.mem.copy(
u8,
buf,
public_dir,
);
buf[public_dir.len] = '/';
std.mem.copy(u8, buf[public_dir.len + 1 ..], base);
std.mem.copy(u8, buf[public_dir.len + 1 + base.len ..], extname);
break :brk route_file_buf[0 .. 1 + public_dir.len + 1 + base.len + extname.len];
};
var name = public_path[0 .. public_path.len - extname.len];
while (name.len > 1 and name[name.len - 1] == '/') {
name = name[0 .. name.len - 1];
}
name = name[routes_dirname_len..];
if (strings.endsWith(name, "/index")) {
name = name[0 .. name.len - 6];
}
name = std.mem.trimRight(u8, name, "/");
var match_name: string = name;
var param_count: u16 = 0;
const is_index = name.len == 0;
if (name.len > 0) {
param_count = Pattern.validate(
name[1..],
allocator,
log,
) orelse return null;
var has_uppercase = false;
var name_i: usize = 0;
while (!has_uppercase and name_i < public_path.len) : (name_i += 1) {
has_uppercase = public_path[name_i] >= 'A' and public_path[name_i] <= 'Z';
}
const name_offset = @ptrToInt(name.ptr) - @ptrToInt(public_path.ptr);
if (has_uppercase) {
public_path = FileSystem.DirnameStore.instance.append(@TypeOf(public_path), public_path) catch unreachable;
name = public_path[name_offset..][0..name.len];
match_name = FileSystem.DirnameStore.instance.appendLowerCase(@TypeOf(name[1..]), name[1..]) catch unreachable;
} else {
public_path = FileSystem.DirnameStore.instance.append(@TypeOf(public_path), public_path) catch unreachable;
name = public_path[name_offset..][0..name.len];
match_name = name[1..];
}
std.debug.assert(match_name[0] != '/');
std.debug.assert(name[0] == '/');
} else {
name = Route.index_route_name;
match_name = Route.index_route_name;
public_path = FileSystem.DirnameStore.instance.append(@TypeOf(public_path), public_path) catch unreachable;
}
if (abs_path_str.len == 0) {
var file: std.fs.File = undefined;
var needs_close = false;
defer if (needs_close) file.close();
if (entry.cache.fd != 0) {
file = std.fs.File{ .handle = entry.cache.fd };
} else {
var parts = [_]string{ entry.dir, entry.base() };
abs_path_str = FileSystem.instance.absBuf(&parts, &route_file_buf);
route_file_buf[abs_path_str.len] = 0;
var buf = route_file_buf[0..abs_path_str.len :0];
file = std.fs.openFileAbsoluteZ(buf, .{ .read = true }) catch |err| {
log.addErrorFmt(null, Logger.Loc.Empty, allocator, "{s} opening route: {s}", .{ @errorName(err), abs_path_str }) catch unreachable;
return null;
};
FileSystem.setMaxFd(file.handle);
needs_close = FileSystem.instance.fs.needToCloseFiles();
if (!needs_close) entry.cache.fd = file.handle;
}
var _abs = std.os.getFdPath(file.handle, &route_file_buf) catch |err| {
log.addErrorFmt(null, Logger.Loc.Empty, allocator, "{s} resolving route: {s}", .{ @errorName(err), abs_path_str }) catch unreachable;
return null;
};
abs_path_str = FileSystem.DirnameStore.instance.append(@TypeOf(_abs), _abs) catch unreachable;
entry.abs_path = PathString.init(abs_path_str);
}
return Route{
.name = name,
.entry = entry,
.public_path = PathString.init(public_path),
.match_name = PathString.init(match_name),
.full_hash = if (is_index)
index_route_hash
else
@truncate(u32, std.hash.Wyhash.hash(0, name)),
.param_count = param_count,
.abs_path = entry.abs_path,
};
}
};
threadlocal var params_list: Param.List = undefined;
pub fn match(app: *Router, server: anytype, comptime RequestContextType: type, ctx: *RequestContextType) !void {
ctx.matched_route = null;
// If there's an extname assume it's an asset and not a page
switch (ctx.url.extname.len) {
0 => {},
// json is used for updating the route client-side without a page reload
"json".len => {
if (!strings.eqlComptime(ctx.url.extname, "json")) {
try ctx.handleRequest();
return;
}
},
else => {
try ctx.handleRequest();
return;
},
}
params_list.shrinkRetainingCapacity(0);
if (app.routes.matchPage(app.config.dir, ctx.url, &params_list)) |route| {
if (route.redirect_path) |redirect| {
try ctx.handleRedirect(redirect);
return;
}
std.debug.assert(route.path.len > 0);
if (server.watcher.watchloop_handle == null) {
server.watcher.start() catch {};
}
ctx.matched_route = route;
RequestContextType.JavaScriptHandler.enqueue(ctx, server, &params_list) catch {
server.javascript_enabled = false;
};
}
if (!ctx.controlled and !ctx.has_called_done) {
try ctx.handleRequest();
}
}
pub const Match = struct {
/// normalized url path from the request
path: string,
/// raw url path from the request
pathname: string,
/// absolute filesystem path to the entry point
file_path: string,
/// route name, like `"posts/[id]"`
name: string,
client_framework_enabled: bool = false,
/// basename of the route in the file system, including file extension
basename: string,
hash: u32,
params: *Param.List,
redirect_path: ?string = null,
query_string: string = "",
pub fn paramsIterator(this: *const Match) PathnameScanner {
return PathnameScanner.init(this.pathname, this.name, this.params);
}
pub fn nameWithBasename(file_path: string, dir: string) string {
var name = file_path;
if (strings.indexOf(name, dir)) |i| {
name = name[i + dir.len ..];
}
return name[0 .. name.len - std.fs.path.extension(name).len];
}
pub fn pathnameWithoutLeadingSlash(this: *const Match) string {
return std.mem.trimLeft(u8, this.pathname, "/");
}
};
const FileSystem = Fs.FileSystem;
const MockRequestContextType = struct {
controlled: bool = false,
url: URLPath,
match_file_path_buf: [1024]u8 = undefined,
handle_request_called: bool = false,
redirect_called: bool = false,
matched_route: ?Match = null,
has_called_done: bool = false,
pub fn handleRequest(this: *MockRequestContextType) !void {
this.handle_request_called = true;
}
pub fn handleRedirect(this: *MockRequestContextType, pathname: string) !void {
this.redirect_called = true;
}
pub const JavaScriptHandler = struct {
pub fn enqueue(ctx: *MockRequestContextType, server: *MockServer, params: *Router.Param.List) !void {}
};
};
pub const MockServer = struct {
watchloop_handle: ?StoredFileDescriptorType = null,
watcher: Watcher = Watcher{},
pub const Watcher = struct {
watchloop_handle: ?StoredFileDescriptorType = null,
pub fn start(this: *Watcher) anyerror!void {}
};
};
fn makeTest(cwd_path: string, data: anytype) !void {
Output.initTest();
std.debug.assert(cwd_path.len > 1 and !strings.eql(cwd_path, "/") and !strings.endsWith(cwd_path, "bun"));
const bun_tests_dir = try std.fs.cwd().makeOpenPath("bun-test-scratch", .{ .iterate = true });
bun_tests_dir.deleteTree(cwd_path) catch {};
const cwd = try bun_tests_dir.makeOpenPath(cwd_path, .{ .iterate = true });
try cwd.setAsCwd();
const Data = @TypeOf(data);
const fields: []const std.builtin.TypeInfo.StructField = comptime std.meta.fields(Data);
inline for (fields) |field| {
@setEvalBranchQuota(9999);
const value = @field(data, field.name);
if (std.fs.path.dirname(field.name)) |dir| {
try cwd.makePath(dir);
}
var file = try cwd.createFile(field.name, .{ .truncate = true });
try file.writeAll(std.mem.span(value));
file.close();
}
}
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
const expectEqualStrings = std.testing.expectEqualStrings;
const expectStr = std.testing.expectEqualStrings;
const Logger = @import("./logger.zig");
pub const Test = struct {
pub fn makeRoutes(comptime testName: string, data: anytype) !Routes {
Output.initTest();
try makeTest(testName, data);
const JSAst = @import("./js_ast.zig");
JSAst.Expr.Data.Store.create(default_allocator);
JSAst.Stmt.Data.Store.create(default_allocator);
var fs = try FileSystem.init1(default_allocator, null);
var top_level_dir = fs.top_level_dir;
var pages_parts = [_]string{ top_level_dir, "pages" };
var pages_dir = try Fs.FileSystem.instance.absAlloc(default_allocator, &pages_parts);
// _ = try std.fs.makeDirAbsolute(
// pages_dir,
// );
var router = try Router.init(&FileSystem.instance, default_allocator, Options.RouteConfig{
.dir = pages_dir,
.routes_enabled = true,
.extensions = &.{"js"},
});
const Resolver = @import("./resolver/resolver.zig").Resolver;
var logger = Logger.Log.init(default_allocator);
errdefer {
logger.printForLogLevel(Output.errorWriter()) catch {};
}
var opts = Options.BundleOptions{
.resolve_mode = .lazy,
.platform = .browser,
.loaders = undefined,
.define = undefined,
.log = &logger,
.routes = router.config,
.entry_points = &.{},
.out_extensions = std.StringHashMap(string).init(default_allocator),
.transform_options = std.mem.zeroes(Api.TransformOptions),
.external = Options.ExternalModules.init(
default_allocator,
&FileSystem.instance.fs,
FileSystem.instance.top_level_dir,
&.{},
&logger,
.browser,
),
};
var resolver = Resolver.init1(default_allocator, &logger, &FileSystem.instance, opts);
var root_dir = (try resolver.readDirInfo(pages_dir)).?;
var entries = root_dir.getEntries().?;
return RouteLoader.loadAll(default_allocator, opts.routes, &logger, Resolver, &resolver, root_dir);
// try router.loadRoutes(root_dir, Resolver, &resolver, 0, true);
// var entry_points = try router.getEntryPoints(default_allocator);
// try expectEqual(std.meta.fieldNames(@TypeOf(data)).len, entry_points.len);
// return router;
}
pub fn make(comptime testName: string, data: anytype) !Router {
try makeTest(testName, data);
const JSAst = @import("./js_ast.zig");
JSAst.Expr.Data.Store.create(default_allocator);
JSAst.Stmt.Data.Store.create(default_allocator);
var fs = try FileSystem.init1(default_allocator, null);
var top_level_dir = fs.top_level_dir;
var pages_parts = [_]string{ top_level_dir, "pages" };
var pages_dir = try Fs.FileSystem.instance.absAlloc(default_allocator, &pages_parts);
// _ = try std.fs.makeDirAbsolute(
// pages_dir,
// );
var router = try Router.init(&FileSystem.instance, default_allocator, Options.RouteConfig{
.dir = pages_dir,
.routes_enabled = true,
.extensions = &.{"js"},
});
const Resolver = @import("./resolver/resolver.zig").Resolver;
var logger = Logger.Log.init(default_allocator);
errdefer {
logger.printForLogLevel(Output.errorWriter()) catch {};
}
var opts = Options.BundleOptions{
.resolve_mode = .lazy,
.platform = .browser,
.loaders = undefined,
.define = undefined,
.log = &logger,
.routes = router.config,
.entry_points = &.{},
.out_extensions = std.StringHashMap(string).init(default_allocator),
.transform_options = std.mem.zeroes(Api.TransformOptions),
.external = Options.ExternalModules.init(
default_allocator,
&FileSystem.instance.fs,
FileSystem.instance.top_level_dir,
&.{},
&logger,
.browser,
),
};
var resolver = Resolver.init1(default_allocator, &logger, &FileSystem.instance, opts);
var root_dir = (try resolver.readDirInfo(pages_dir)).?;
var entries = root_dir.getEntries().?;
try router.loadRoutes(&logger, root_dir, Resolver, &resolver);
var entry_points = try router.getEntryPoints();
try expectEqual(std.meta.fieldNames(@TypeOf(data)).len, entry_points.len);
return router;
}
};
test "Route Loader" {
var server = MockServer{};
var ctx = MockRequestContextType{
.url = try URLPath.parse("/hi"),
};
const fixtures = @import("./test/fixtures.zig");
var router = try Test.make("routes-basic", fixtures.github_api_routes_list);
var parameters = Param.List{};
const MatchContext = struct {
params: Param.List,
pub fn empty(this: *@This()) !void {
try expectEqual(this.params.len, 0);
}
};
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/organizations", *MatchContext, &match_ctx);
try match_ctx.empty();
try expectEqualStrings(route.?.name, "organizations");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/app/installations/", *MatchContext, &match_ctx);
try match_ctx.empty();
try expectEqualStrings(route.?.name, "app/installations");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/app/installations/123", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "app/installations/[installation_id]");
try expectEqualStrings(match_ctx.params.get(0).name, "installation_id");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/codes_of_conduct/", *MatchContext, &match_ctx);
try match_ctx.empty();
try expectEqualStrings(route.?.name, "codes_of_conduct");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "codes_of_conduct/123", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "codes_of_conduct/[key]");
try expectEqualStrings(match_ctx.params.get(0).name, "key");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "codes_of_conduct/123/", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "codes_of_conduct/[key]");
try expectEqualStrings(match_ctx.params.get(0).name, "key");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/orgs/123/index", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "orgs/[org]");
try expectEqualStrings(match_ctx.params.get(0).name, "org");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/orgs/123/actions/permissions", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "orgs/[org]/actions/permissions");
try expectEqualStrings(match_ctx.params.get(0).name, "org");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/orgs/orgg/teams/teamm/discussions/123/comments/999/reactions", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "orgs/[org]/teams/[team_slug]/discussions/[discussion_number]/comments/[comment_number]/reactions");
try expectEqualStrings(match_ctx.params.get(0).name, "org");
try expectEqualStrings(match_ctx.params.get(0).value, "orgg");
try expectEqualStrings(match_ctx.params.get(1).name, "team_slug");
try expectEqualStrings(match_ctx.params.get(1).value, "teamm");
try expectEqualStrings(match_ctx.params.get(2).name, "discussion_number");
try expectEqualStrings(match_ctx.params.get(2).value, "123");
try expectEqualStrings(match_ctx.params.get(3).name, "comment_number");
try expectEqualStrings(match_ctx.params.get(3).value, "999");
}
{
var match_ctx = MatchContext{ .params = .{} };
var route = router.match(default_allocator, "/repositories/123/environments/production/not-real", *MatchContext, &match_ctx);
try expectEqualStrings(route.?.name, "repositories/[repository_id]/[...jarred-fake-catch-all]");
try expectEqualStrings(match_ctx.params.get(0).name, "repository_id");
try expectEqualStrings(match_ctx.params.get(0).value, "123");
try expectEqualStrings(match_ctx.params.get(1).name, "jarred-fake-catch-all");
try expectEqualStrings(match_ctx.params.get(1).value, "environments/production/not-real");
try expectEqual(match_ctx.params.len, 2);
}
}
test "Routes basic" {
var server = MockServer{};
var ctx = MockRequestContextType{
.url = try URLPath.parse("/hi"),
};
var router = try Test.make("routes-basic", .{
.@"pages/hi.js" = "//hi",
.@"pages/index.js" = "//index",
.@"pages/blog/hi.js" = "//blog/hi",
});
try router.match(&server, MockRequestContextType, &ctx);
try expectEqualStrings(ctx.matched_route.?.name, "/hi");
ctx = MockRequestContextType{
.url = try URLPath.parse("/"),
};
try router.match(&server, MockRequestContextType, &ctx);
try expectEqualStrings(ctx.matched_route.?.name, "/");
ctx = MockRequestContextType{
.url = try URLPath.parse("/blog/hi"),
};
try router.match(&server, MockRequestContextType, &ctx);
try expectEqualStrings(ctx.matched_route.?.name, "/blog/hi");
ctx = MockRequestContextType{
.url = try URLPath.parse("/blog/hey"),
};
try router.match(&server, MockRequestContextType, &ctx);
try expect(ctx.matched_route == null);
ctx = MockRequestContextType{
.url = try URLPath.parse("/blog/"),
};
try router.match(&server, MockRequestContextType, &ctx);
try expect(ctx.matched_route == null);
ctx = MockRequestContextType{
.url = try URLPath.parse("/pages/hi"),
};
try router.match(&server, MockRequestContextType, &ctx);
try expect(ctx.matched_route == null);
}
test "Dynamic routes" {
var server = MockServer{};
var ctx = MockRequestContextType{
.url = try URLPath.parse("/blog/hi"),
};
var filepath_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
var router = try Test.make("routes-dynamic", .{
.@"pages/index.js" = "//index.js",
.@"pages/blog/hi.js" = "//blog-hi",
.@"pages/posts/[id].js" = "//hi",
// .@"pages/blog/posts/bacon.js" = "//index",
});
try router.match(&server, MockRequestContextType, &ctx);
try expectEqualStrings(ctx.matched_route.?.name, "blog/hi");
var params = ctx.matched_route.?.paramsIterator();
try expect(params.next() == null);
ctx.matched_route = null;
ctx.url = try URLPath.parse("/posts/123");
try router.match(&server, MockRequestContextType, &ctx);
params = ctx.matched_route.?.paramsIterator();
try expectEqualStrings(ctx.matched_route.?.name, "posts/[id]");
try expectEqualStrings(params.next().?.rawValue(ctx.matched_route.?.pathname), "123");
// ctx = MockRequestContextType{
// .url = try URLPath.parse("/"),
// };
// try router.match(&server, MockRequestContextType, &ctx);
// try expectEqualStrings(ctx.matched_route.name, "index");
}
test "Pattern" {
const pattern = "[dynamic]/static/[dynamic2]/[...catch_all]";
const dynamic = try Pattern.init(pattern, 0);
try expectStr(@tagName(dynamic.value), "dynamic");
const static = try Pattern.init(pattern, dynamic.len);
try expectStr(@tagName(static.value), "static");
const dynamic2 = try Pattern.init(pattern, static.len);
try expectStr(@tagName(dynamic2.value), "dynamic");
const static2 = try Pattern.init(pattern, dynamic2.len);
try expectStr(@tagName(static2.value), "static");
const catch_all = try Pattern.init(pattern, static2.len);
try expectStr(@tagName(catch_all.value), "catch_all");
try expectStr(dynamic.value.dynamic.str(pattern), "dynamic");
try expectStr(static.value.static, "/static/");
try expectStr(dynamic2.value.dynamic.str(pattern), "dynamic2");
try expectStr(static2.value.static, "/");
try expectStr(catch_all.value.catch_all.str(pattern), "catch_all");
}
const Pattern = struct {
value: Value,
len: RoutePathInt = 0,
/// Match a filesystem route pattern to a URL path.
pub fn match(
// `path` must be lowercased and have no leading slash
path: string,
/// case-sensitive, must not have a leading slash
name: string,
/// case-insensitive, must not have a leading slash
match_name: string,
allocator: *std.mem.Allocator,
comptime ParamsListType: type,
params: ParamsListType,
comptime allow_optional_catch_all: bool,
) bool {
var offset: RoutePathInt = 0;
var path_ = path;
while (offset < name.len) {
var pattern = Pattern.init(match_name, offset) catch unreachable;
offset = pattern.len;
switch (pattern.value) {
.static => |str| {
const segment = path_[0 .. std.mem.indexOfScalar(u8, path_, '/') orelse path_.len];
if (!str.eql(segment)) {
params.shrinkRetainingCapacity(0);
return false;
}
path_ = if (segment.len < path_.len)
path_[segment.len + 1 ..]
else
"";
if (path_.len == 0 and pattern.isEnd(name)) return true;
},
.dynamic => |dynamic| {
if (std.mem.indexOfScalar(u8, path_, '/')) |i| {
params.append(allocator, .{
.name = dynamic.str(name),
.value = path,
}) catch unreachable;
path_ = path_[i + 1 ..];
if (pattern.isEnd(name)) {
params.shrinkRetainingCapacity(0);
return false;
}
continue;
} else if (pattern.isEnd(name)) {
params.append(allocator, .{
.name = dynamic.str(name),
.value = path_,
}) catch unreachable;
return true;
} else if (comptime allow_optional_catch_all) {
pattern = Pattern.init(match_name, offset) catch unreachable;
if (pattern.value == .optional_catch_all) {
params.append(allocator, .{
.name = dynamic.str(name),
.value = path_,
}) catch unreachable;
path_ = "";
}
return true;
}
if (comptime !allow_optional_catch_all) {
return true;
}
},
.catch_all => |dynamic| {
if (path_.len > 0) {
params.append(allocator, .{
.name = dynamic.str(name),
.value = path_,
}) catch unreachable;
return true;
}
return false;
},
.optional_catch_all => |dynamic| {
if (comptime allow_optional_catch_all) {
if (path_.len > 0) params.append(allocator, .{
.name = dynamic.str(name),
.value = path_,
}) catch unreachable;
return true;
}
return false;
},
}
}
return false;
}
/// Validate a Route pattern, returning the number of route parameters.
/// `null` means invalid. Error messages are logged.
/// That way, we can provide a list of all invalid routes rather than failing the first time.
pub fn validate(input: string, allocator: *std.mem.Allocator, log: *Logger.Log) ?u16 {
if (CodepointIterator.needsUTF8Decoding(input)) {
const source = Logger.Source.initEmptyFile(input);
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Route name must be plaintext",
.{},
) catch unreachable;
return null;
}
var count: u16 = 0;
var offset: RoutePathInt = 0;
std.debug.assert(input.len > 0);
const end = @truncate(u32, input.len - 1);
while (offset < end) {
const pattern: Pattern = Pattern.initUnhashed(input, offset) catch |err| {
const source = Logger.Source.initEmptyFile(input);
switch (err) {
error.CatchAllMustBeAtTheEnd => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Catch-all route must be at the end of the path",
.{},
) catch unreachable;
},
error.InvalidCatchAllRoute => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Invalid catch-all route, e.g. should be [...param]",
.{},
) catch unreachable;
},
error.InvalidOptionalCatchAllRoute => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Invalid optional catch-all route, e.g. should be [[...param]]",
.{},
) catch unreachable;
},
error.InvalidRoutePattern => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Invalid dynamic route",
.{},
) catch unreachable;
},
error.MissingParamName => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Route is missing a parameter name, e.g. [param]",
.{},
) catch unreachable;
},
error.PatternMissingClosingBracket => {
log.addErrorFmt(
&source,
Logger.Loc.Empty,
allocator,
"Route is missing a closing bracket]",
.{},
) catch unreachable;
},
}
return null;
};
offset = pattern.len;
count += @intCast(u16, @boolToInt(@enumToInt(@as(Pattern.Tag, pattern.value)) > @enumToInt(Pattern.Tag.static)));
}
return count;
}
pub fn eql(a: Pattern, b: Pattern) bool {
return a.len == b.len and a.value.eql(b.value);
}
pub const PatternParseError = error{
CatchAllMustBeAtTheEnd,
InvalidCatchAllRoute,
InvalidOptionalCatchAllRoute,
InvalidRoutePattern,
MissingParamName,
PatternMissingClosingBracket,
};
const RoutePathInt = u16;
pub fn init(input: string, offset_: RoutePathInt) PatternParseError!Pattern {
return initMaybeHash(input, offset_, true);
}
pub fn isEnd(this: Pattern, input: string) bool {
return @as(usize, this.len) >= input.len - 1;
}
pub fn initUnhashed(input: string, offset_: RoutePathInt) PatternParseError!Pattern {
return initMaybeHash(input, offset_, false);
}
inline fn initMaybeHash(input: string, offset_: RoutePathInt, comptime do_hash: bool) PatternParseError!Pattern {
const initHashedString = if (comptime do_hash) HashedString.init else HashedString.initNoHash;
var offset: RoutePathInt = offset_;
while (input.len > @as(usize, offset) and input[offset] == '/') {
offset += 1;
}
if (input.len == 0 or input.len <= @as(usize, offset)) return Pattern{
.value = .{ .static = HashedString.empty },
.len = @truncate(RoutePathInt, @minimum(input.len, @as(usize, offset))),
};
var i: RoutePathInt = offset;
var tag = Tag.static;
const end = @intCast(RoutePathInt, input.len - 1);
if (offset == end) return Pattern{ .len = offset, .value = .{ .static = HashedString.empty } };
while (i <= end) : (i += 1) {
switch (input[i]) {
'/' => {
return Pattern{ .len = @minimum(i + 1, end), .value = .{ .static = initHashedString(input[offset..i]) } };
},
'[' => {
if (i > offset) {
return Pattern{ .len = i, .value = .{ .static = initHashedString(input[offset..i]) } };
}
tag = Tag.dynamic;
var param = TinyPtr{};
var catch_all_start = i;
i += 1;
param.offset = i;
if (i >= end) return error.InvalidRoutePattern;
switch (input[i]) {
'/', ']' => return error.MissingParamName,
'[' => {
tag = Tag.optional_catch_all;
if (end < i + 4) {
return error.InvalidOptionalCatchAllRoute;
}
i += 1;
const catch_all_dot_start = i;
if (!strings.eqlComptimeIgnoreLen(input[i..][0..3], "...")) return error.InvalidOptionalCatchAllRoute;
i += 3;
param.offset = i;
},
'.' => {
tag = Tag.catch_all;
i += 1;
if (end < i + 2) {
return error.InvalidCatchAllRoute;
}
if (!strings.eqlComptimeIgnoreLen(input[i..][0..2], "..")) return error.InvalidCatchAllRoute;
i += 2;
param.offset = i;
},
else => {},
}
i += 1;
while (i <= end and input[i] != ']') : (i += 1) {
if (input[i] == '/') return error.InvalidRoutePattern;
}
if (i > end) return error.PatternMissingClosingBracket;
param.len = i - param.offset;
i += 1;
if (tag == Tag.optional_catch_all) {
if (input[i] != ']') return error.PatternMissingClosingBracket;
i += 1;
}
if (@enumToInt(tag) > @enumToInt(Tag.dynamic) and i <= end) return error.CatchAllMustBeAtTheEnd;
return Pattern{
.len = @minimum(i + 1, end),
.value = switch (tag) {
.dynamic => .{
.dynamic = param,
},
.catch_all => .{ .catch_all = param },
.optional_catch_all => .{ .optional_catch_all = param },
else => unreachable,
},
};
},
else => {},
}
}
return Pattern{ .len = i, .value = .{ .static = HashedString.init(input[offset..i]) } };
}
pub const Tag = enum(u4) {
static = 0,
dynamic = 1,
catch_all = 2,
optional_catch_all = 3,
};
pub const Value = union(Tag) {
static: HashedString,
dynamic: TinyPtr,
catch_all: TinyPtr,
optional_catch_all: TinyPtr,
pub fn eql(a: Value, b: Value) bool {
return @as(Tag, a) == @as(Tag, b) and switch (a) {
.static => HashedString.eql(a.static, b.static),
.dynamic => a.dynamic.eql(b.dynamic),
.catch_all => a.catch_all.eql(b.catch_all),
.optional_catch_all => a.optional_catch_all.eql(b.optional_catch_all),
};
}
};
};
test "Pattern Match" {
Output.initTest();
const Entry = Param;
const regular_list = .{
.@"404" = .{
"404",
&[_]Entry{},
},
.@"[teamSlug]" = .{
"value",
&[_]Entry{
.{ .name = "teamSlug", .value = "value" },
},
},
.@"hi/hello/[teamSlug]" = .{
"hi/hello/123",
&[_]Entry{
.{ .name = "teamSlug", .value = "123" },
},
},
.@"hi/[teamSlug]/hello" = .{
"hi/123/hello",
&[_]Entry{
.{ .name = "teamSlug", .value = "123" },
},
},
.@"[teamSlug]/hi/hello" = .{
"123/hi/hello",
&[_]Entry{
.{ .name = "teamSlug", .value = "123" },
},
},
.@"[teamSlug]/[project]" = .{
"team/bacon",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "bacon" },
},
},
.@"lemon/[teamSlug]/[project]" = .{
"lemon/team/bacon",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "bacon" },
},
},
.@"[teamSlug]/[project]/lemon" = .{
"team/bacon/lemon",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "bacon" },
},
},
.@"[teamSlug]/lemon/[project]" = .{
"team/lemon/lemon",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "lemon" },
},
},
.@"[teamSlug]/lemon/[...project]" = .{
"team/lemon/lemon-bacon-cheese/wow/brocollini",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "lemon-bacon-cheese/wow/brocollini" },
},
},
.@"[teamSlug]/lemon/[project]/[[...slug]]" = .{
"team/lemon/lemon/slugggg",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "lemon" },
.{ .name = "slug", .value = "slugggg" },
},
},
};
const optional_catch_all = .{
.@"404" = .{
"404",
&[_]Entry{},
},
.@"404/[[...slug]]" = .{
"404",
&[_]Entry{},
},
.@"404a/[[...slug]]" = .{
"404a",
&[_]Entry{},
},
.@"[teamSlug]/lemon/[project]/[[...slug]]" = .{
"team/lemon/lemon/slugggg",
&[_]Entry{
.{ .name = "teamSlug", .value = "team" },
.{ .name = "project", .value = "lemon" },
.{ .name = "slug", .value = "slugggg" },
},
},
};
const TestList = struct {
pub fn run(comptime list: anytype) usize {
const ParamListType = std.MultiArrayList(Entry);
var parameters = ParamListType{};
var failures: usize = 0;
inline for (comptime std.meta.fieldNames(@TypeOf(list))) |pattern| {
parameters.shrinkRetainingCapacity(0);
const part = comptime @field(list, pattern);
const pathname = part.@"0";
const entries = part.@"1";
fail: {
if (!Pattern.match(pathname, pattern, pattern, default_allocator, *ParamListType, &parameters, true)) {
Output.prettyErrorln("Expected pattern <b>\"{s}\"<r> to match <b>\"{s}\"<r>", .{ pattern, pathname });
failures += 1;
break :fail;
}
if (comptime entries.len > 0) {
for (parameters.items(.name)) |entry_name, i| {
if (!strings.eql(entry_name, entries[i].name)) {
failures += 1;
Output.prettyErrorln("{s} -- Expected name <b>\"{s}\"<r> but received <b>\"{s}\"<r> for path {s}", .{ pattern, entries[i].name, parameters.get(i).name, pathname });
break :fail;
}
if (!strings.eql(parameters.get(i).value, entries[i].value)) {
failures += 1;
Output.prettyErrorln("{s} -- Expected value <b>\"{s}\"<r> but received <b>\"{s}\"<r> for path {s}", .{ pattern, entries[i].value, parameters.get(i).value, pathname });
break :fail;
}
}
}
if (parameters.len != entries.len) {
Output.prettyErrorln("Expected parameter count for <b>\"{s}\"<r> to match <b>\"{s}\"<r>", .{ pattern, pathname });
failures += 1;
break :fail;
}
}
}
return failures;
}
};
if (TestList.run(regular_list) > 0) try expect(false);
if (TestList.run(optional_catch_all) > 0) try expect(false);
}