const std = @import("std"); const jetzig = @import("jetzig"); ast: std.zig.Ast = undefined, allocator: std.mem.Allocator, root_path: []const u8, templates_path: []const u8, views_path: []const u8, jobs_path: []const u8, mailers_path: []const u8, buffer: std.ArrayList(u8), dynamic_routes: std.ArrayList(Function), static_routes: std.ArrayList(Function), module_paths: std.ArrayList([]const u8), data: *jetzig.data.Data, const Routes = @This(); const Function = struct { name: []const u8, view_name: []const u8, args: []Arg, routes: *const Routes, path: []const u8, source: []const u8, params: std.ArrayList([]const u8), legacy: bool = false, static: bool = false, /// The full name of a route. This **must** match the naming convention used by static route /// compilation. /// path: `src/app/views/iguanas.zig`, action: `index` => `iguanas_index` pub fn fullName(self: Function) ![]const u8 { const relative_path = try self.routes.relativePathFrom(.views, self.path, .posix); defer self.routes.allocator.free(relative_path); const path = relative_path[0 .. relative_path.len - std.fs.path.extension(relative_path).len]; std.mem.replaceScalar(u8, path, '/', '_'); return std.mem.concat(self.routes.allocator, u8, &[_][]const u8{ path, "_", self.name }); } pub fn viewName(self: Function) ![]const u8 { const relative_path = try self.routes.relativePathFrom(.views, self.path, .posix); defer self.routes.allocator.free(relative_path); return try self.routes.allocator.dupe(u8, chompExtension(relative_path)); } /// The path used to match the route. Resource ID and extension is not included here and is /// appended as needed during matching logic at run time. pub fn uriPath(self: Function) ![]const u8 { const relative_path = try self.routes.relativePathFrom(.views, self.path, .posix); defer self.routes.allocator.free(relative_path); const path = relative_path[0 .. relative_path.len - std.fs.path.extension(relative_path).len]; const is_root = std.mem.eql(u8, path, "root"); const is_new = std.mem.eql(u8, self.name, "new"); const is_edit = std.mem.eql(u8, self.name, "edit"); if (is_root) { if (is_edit) return try self.routes.allocator.dupe(u8, "/edit"); if (is_new) return try self.routes.allocator.dupe(u8, "/new"); return try self.routes.allocator.dupe(u8, "/"); } const maybe_new = if (is_new) ("/new") else ""; // jetzig.http.Path.actionPath translates `/foo/bar/1/edit` to `/foo/bar/edit` const maybe_edit = if (is_edit) ("/edit") else ""; return try std.mem.concat(self.routes.allocator, u8, &[_][]const u8{ "/", path, maybe_new, maybe_edit }); } pub fn lessThanFn(context: void, lhs: Function, rhs: Function) bool { _ = context; return std.mem.order(u8, lhs.name, rhs.name).compare(std.math.CompareOperator.lt); } }; // An argument passed to a view function. const Arg = struct { name: []const u8, type_name: []const u8, pub fn typeBasename(self: Arg) ![]const u8 { if (std.mem.indexOfScalar(u8, self.type_name, '.')) |_| { var it = std.mem.splitBackwardsScalar(u8, self.type_name, '.'); while (it.next()) |capture| { return capture; } } const pointer_start = std.mem.indexOfScalar(u8, self.type_name, '*'); if (pointer_start) |index| { if (self.type_name.len < index + 1) return error.JetzigAstParserError; return self.type_name[index + 1 ..]; } else { return self.type_name; } } }; pub fn init( allocator: std.mem.Allocator, root_path: []const u8, templates_path: []const u8, views_path: []const u8, jobs_path: []const u8, mailers_path: []const u8, ) !Routes { const data = try allocator.create(jetzig.data.Data); data.* = jetzig.data.Data.init(allocator); return .{ .allocator = allocator, .root_path = root_path, .templates_path = templates_path, .views_path = views_path, .jobs_path = jobs_path, .mailers_path = mailers_path, .buffer = std.ArrayList(u8).init(allocator), .static_routes = std.ArrayList(Function).init(allocator), .dynamic_routes = std.ArrayList(Function).init(allocator), .module_paths = std.ArrayList([]const u8).init(allocator), .data = data, }; } pub fn deinit(self: *Routes) void { self.ast.deinit(self.allocator); self.buffer.deinit(); self.static_routes.deinit(); self.dynamic_routes.deinit(); } /// Generates the complete route set for the application pub fn generateRoutes(self: *Routes) ![]const u8 { const writer = self.buffer.writer(); try writer.writeAll( \\const jetzig = @import("jetzig"); \\ \\pub const routes = [_]jetzig.Route{ \\ ); try self.writeRoutes(writer); try writer.writeAll( \\}; \\ ); try writer.writeAll( \\ \\pub const mailers = [_]jetzig.MailerDefinition{ \\ ); try self.writeMailers(writer); try writer.writeAll( \\}; \\ ); try writer.writeAll( \\ \\pub const jobs = [_]jetzig.JobDefinition{ \\ .{ .name = "__jetzig_mail", .runFn = jetzig.mail.Job.run }, \\ ); try self.writeJobs(writer); try writer.writeAll( \\}; \\ ); try writer.writeAll( \\test { \\ ); for (self.module_paths.items) |module_path| { try writer.print( \\ _ = @import("{s}"); \\ , .{module_path}); } try writer.writeAll( \\ @import("std").testing.refAllDeclsRecursive(@This()); \\} \\ ); return try self.buffer.toOwnedSlice(); } pub fn relativePathFrom( self: Routes, root: enum { root, views, mailers, jobs }, sub_path: []const u8, format: enum { os, posix }, ) ![]u8 { const root_path = switch (root) { .root => self.root_path, .views => self.views_path, .mailers => self.mailers_path, .jobs => self.jobs_path, }; const path = try std.fs.path.relative(self.allocator, root_path, sub_path); defer self.allocator.free(path); return switch (format) { .posix => try self.normalizePosix(path), .os => try self.allocator.dupe(u8, path), }; } fn writeRoutes(self: *Routes, writer: anytype) !void { var dir = std.fs.openDirAbsolute(self.views_path, .{ .iterate = true }) catch |err| { switch (err) { error.FileNotFound => { std.debug.print( "[jetzig] Views directory not found, no routes generated: `{s}`\n", .{self.views_path}, ); return; }, else => return err, } }; defer dir.close(); var walker = try dir.walk(self.allocator); defer walker.deinit(); while (try walker.next()) |entry| { if (entry.kind != .file) continue; const extension = std.fs.path.extension(entry.path); if (!std.mem.eql(u8, extension, ".zig")) continue; const realpath = try dir.realpathAlloc(self.allocator, entry.path); defer self.allocator.free(realpath); const view_routes = try self.generateRoutesForView( dir, try self.allocator.dupe(u8, realpath), ); for (view_routes.static) |view_route| { try self.static_routes.append(view_route); } for (view_routes.dynamic) |view_route| { try self.dynamic_routes.append(view_route); } } std.sort.pdq(Function, self.static_routes.items, {}, Function.lessThanFn); std.sort.pdq(Function, self.dynamic_routes.items, {}, Function.lessThanFn); for (self.static_routes.items) |static_route| { try self.writeRoute(writer, static_route); } for (self.dynamic_routes.items) |dynamic_route| { try self.writeRoute(writer, dynamic_route); const name = try dynamic_route.fullName(); defer self.allocator.free(name); } std.debug.print("[jetzig] Imported {} route(s)\n", .{self.dynamic_routes.items.len}); } fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) !void { const full_name = try route.fullName(); defer self.allocator.free(full_name); const uri_path = try route.uriPath(); defer self.allocator.free(uri_path); const output_template = \\ .{{ \\ .id = "{9s}", \\ .name = "{0s}", \\ .action = .{1s}, \\ .view_name = "{2s}", \\ .view = jetzig.Route.View{{ .{3s} = @import("{7s}").{1s} }}, \\ .path = "{7s}", \\ .static = {4s}, \\ .uri_path = "{5s}", \\ .template = "{6s}", \\ .before_callbacks = jetzig.callbacks.beforeCallbacks(@import("{7s}")), \\ .after_callbacks = jetzig.callbacks.afterCallbacks(@import("{7s}")), \\ .layout = if (@hasDecl(@import("{7s}"), "layout")) @import("{7s}").layout else null, \\ .json_params = &[_][]const u8 {{ {8s} }}, \\ .formats = if (@hasDecl(@import("{7s}"), "formats")) @import("{7s}").formats else null, \\ }}, \\ ; const module_path = try self.relativePathFrom(.root, route.path, .posix); defer self.allocator.free(module_path); const view_name = try route.viewName(); defer self.allocator.free(view_name); const template = try std.mem.concat( self.allocator, u8, &[_][]const u8{ view_name, "/", route.name }, ); const with_id = std.StaticStringMap(bool).initComptime(.{ .{ "index", false }, .{ "post", false }, .{ "new", false }, .{ "edit", true }, .{ "get", true }, .{ "edit", true }, .{ "put", true }, .{ "patch", true }, .{ "delete", true }, }).get(route.name).?; const tag = if (!route.legacy and !route.static and with_id) "with_id" else if (!route.legacy and !route.static and !with_id) "without_id" else if (!route.legacy and route.static and with_id) "static_with_id" else if (!route.legacy and route.static and !with_id) "static_without_id" else if (route.legacy and !route.static and with_id) "legacy_with_id" else if (route.legacy and !route.static and !with_id) "legacy_without_id" else if (route.legacy and route.static and with_id) "legacy_static_with_id" else if (route.legacy and route.static and !with_id) "legacy_static_without_id" else unreachable; std.mem.replaceScalar(u8, module_path, '\\', '/'); try self.module_paths.append(try self.allocator.dupe(u8, module_path)); var buf: [32]u8 = undefined; const id = jetzig.util.generateVariableName(&buf); const output = try std.fmt.allocPrint(self.allocator, output_template, .{ full_name, route.name, view_name, tag, if (route.static) "true" else "false", uri_path, template, module_path, try std.mem.join(self.allocator, ", \n", route.params.items), id, }); defer self.allocator.free(output); try writer.writeAll(output); } const RouteSet = struct { dynamic: []Function, static: []Function, }; fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !RouteSet { const stat = try dir.statFile(path); const source = try dir.readFileAllocOptions( self.allocator, path, @intCast(stat.size), null, .of(u8), 0, ); defer self.allocator.free(source); self.ast = try std.zig.Ast.parse(self.allocator, source, .zig); var static_routes = std.ArrayList(Function).init(self.allocator); var dynamic_routes = std.ArrayList(Function).init(self.allocator); var static_params: ?*jetzig.data.Value = null; for (self.ast.nodes.items(.tag), 0..) |tag, index| { switch (tag) { .fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| { var function = try self.parseFunction(function_tag, @enumFromInt(index), path, source); if (function) |*capture| { if (capture.args.len == 0) { std.debug.print( "Expected at least 1 argument for view function `{s}` in `{s}`", .{ capture.name, path }, ); return error.JetzigMissingViewArgument; } for (capture.args, 0..) |arg, arg_index| { if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) { capture.static = jetzig.build_options.build_static; capture.legacy = arg_index + 1 < capture.args.len; try static_routes.append(capture.*); } else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { capture.static = false; capture.legacy = arg_index + 1 < capture.args.len; try dynamic_routes.append(capture.*); } } } }, .simple_var_decl => { const decl = self.ast.simpleVarDecl(@enumFromInt(index)); if (self.isStaticParamsDecl(decl)) { self.data.reset(); const params = try self.data.root(.object); try self.parseStaticParamsDecl(decl, params); static_params = self.data.value; } }, else => {}, } } for (static_routes.items) |*static_route| { var encoded_params = std.ArrayList([]const u8).init(self.allocator); defer encoded_params.deinit(); if (static_params) |capture| { if (capture.get(static_route.name)) |params| { for (params.items(.array)) |item| { const json = try item.toJson(); var encoded_buf = std.ArrayList(u8).init(self.allocator); defer encoded_buf.deinit(); const writer = encoded_buf.writer(); try std.json.encodeJsonString(json, .{}, writer); try static_route.params.append(try self.allocator.dupe(u8, encoded_buf.items)); } } } } return .{ .dynamic = dynamic_routes.items, .static = static_routes.items, }; } // Parse the `pub const static_params` definition and into a `jetzig.data.Value`. fn parseStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl, params: *jetzig.data.Value) !void { const init_node = decl.ast.init_node.unwrap() orelse return; switch (self.ast.nodeTag(init_node)) { .struct_init_dot_two, .struct_init_dot_two_comma => { try self.parseStruct(init_node, params); }, else => return, } } // Recursively parse a struct into a jetzig.data.Value so it can be serialized as JSON and stored // in `routes.zig` - used for static param comparison at runtime. fn parseStruct(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { var struct_buf: [2]std.zig.Ast.Node.Index = undefined; const maybe_struct_init = self.ast.fullStructInit(&struct_buf, node); if (maybe_struct_init == null) { std.debug.print("Expected struct node.\n", .{}); return error.JetzigAstParserError; } const struct_init = maybe_struct_init.?; for (struct_init.ast.fields) |field| try self.parseField(field, params); } // Array of param sets for a route, e.g. `.{ .{ .foo = "bar" } } fn parseArray(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { var array_buf: [2]std.zig.Ast.Node.Index = undefined; const maybe_array = self.ast.fullArrayInit(&array_buf, node); if (maybe_array == null) { std.debug.print("Expected array node.\n", .{}); return error.JetzigAstParserError; } const array = maybe_array.?; const main_token = self.ast.nodeMainToken(node); const field_name = self.ast.tokenSlice(main_token - 3); const params_array = try self.data.array(); try params.put(field_name, params_array); for (array.ast.elements) |element| { const elem = self.ast.nodeTag(element); switch (elem) { .struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => { const route_params = try self.data.object(); try params_array.append(route_params); try self.parseStruct(element, route_params); }, .array_init_dot, .array_init_dot_two, .array_init_dot_comma, .array_init_dot_two_comma => { const route_params = try self.data.object(); try params_array.append(route_params); try self.parseField(element, route_params); }, .string_literal => { const string_token = self.ast.nodeMainToken(element); const string_value = self.ast.tokenSlice(string_token); // Strip quotes: `"foo"` -> `foo` try params_array.append(string_value[1 .. string_value.len - 1]); }, .number_literal => { const number_token = self.ast.nodeMainToken(element); const number_value = self.ast.tokenSlice(number_token); try params_array.append(try parseNumber(number_value, self.data)); }, inline else => { @setEvalBranchQuota(10_000); const tag = self.ast.nodeTag(element); std.debug.print("Unexpected token: {}\n", .{tag}); return error.JetzigStaticParamsParseError; }, } } } // Parse the value of a param field (recursively when field is a struct/array) fn parseField(self: *Routes, node: std.zig.Ast.Node.Index, params: *jetzig.data.Value) anyerror!void { switch (self.ast.nodeTag(node)) { // Route params, e.g. `.index = .{ ... }` .array_init_dot, .array_init_dot_two, .array_init_dot_comma, .array_init_dot_two_comma => { try self.parseArray(node, params); }, .struct_init_dot, .struct_init_dot_two, .struct_init_dot_two_comma => { const nested_params = try self.data.object(); const main_token = self.ast.nodeMainToken(node); const field_name = self.ast.tokenSlice(main_token - 3); try params.put(field_name, nested_params); try self.parseStruct(node, nested_params); }, // Individual param in a params struct, e.g. `.foo = "bar"` .string_literal => { const main_token = self.ast.nodeMainToken(node); const field_name = self.ast.tokenSlice(main_token - 2); const field_value = self.ast.tokenSlice(main_token); try params.put( field_name, // strip outer quotes field_value[1 .. field_value.len - 1], ); }, .number_literal => { const main_token = self.ast.nodeMainToken(node); const field_name = self.ast.tokenSlice(main_token - 2); const field_value = self.ast.tokenSlice(main_token); try params.put(field_name, try parseNumber(field_value, self.data)); }, else => |tag| { std.debug.print("Unexpected token: {}\n", .{tag}); return error.JetzigStaticParamsParseError; }, } } fn parseNumber(value: []const u8, data: *jetzig.data.Data) !*jetzig.data.Value { if (std.mem.containsAtLeast(u8, value, 1, ".")) { return data.float(try std.fmt.parseFloat(f64, value)); } else { return data.integer(try std.fmt.parseInt(i64, value, 10)); } } fn isStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl) bool { if (decl.visib_token) |token_index| { const visibility = self.ast.tokenSlice(token_index); const mutability = self.ast.tokenSlice(decl.ast.mut_token); const identifier = self.ast.tokenSlice(decl.ast.mut_token + 1); // FIXME return (std.mem.eql(u8, visibility, "pub") and std.mem.eql(u8, mutability, "const") and std.mem.eql(u8, identifier, "static_params")); } else { return false; } } fn parseFunction( self: *Routes, function_type: std.zig.Ast.Node.Tag, index: std.zig.Ast.Node.Index, path: []const u8, source: []const u8, ) !?Function { var buf: [1]std.zig.Ast.Node.Index = undefined; const fn_proto = switch (function_type) { .fn_proto_multi => self.ast.fnProtoMulti(index), .fn_proto_one => self.ast.fnProtoOne(&buf, index), .fn_proto_simple => self.ast.fnProtoSimple(&buf, index), else => unreachable, }; if (fn_proto.name_token) |token| { const function_name = try self.allocator.dupe(u8, self.ast.tokenSlice(token)); var args = std.ArrayList(Arg).init(self.allocator); defer args.deinit(); if (!isActionFunctionName(function_name)) { self.allocator.free(function_name); return null; } var it = fn_proto.iterate(&self.ast); while (it.next()) |arg| { if (arg.name_token) |arg_token| { const arg_name = self.ast.tokenSlice(arg_token); const node = self.ast.nodes.get(@intFromEnum(arg.type_expr.?)); const type_name = try self.parseTypeExpr(node); try args.append(.{ .name = arg_name, .type_name = type_name }); } } const view_name = path[0 .. path.len - std.fs.path.extension(path).len]; return .{ .name = function_name, .view_name = try self.allocator.dupe(u8, view_name), .routes = self, .path = path, .args = try self.allocator.dupe(Arg, args.items), .source = try self.allocator.dupe(u8, source), .params = std.ArrayList([]const u8).init(self.allocator), }; } return null; } fn parseTypeExpr(self: *Routes, node: std.zig.Ast.Node) ![]const u8 { switch (node.tag) { // Currently all expected params are pointers, keeping this here in case that changes in future: .identifier => {}, .ptr_type_aligned => { var buf = std.ArrayList([]const u8).init(self.allocator); defer buf.deinit(); for (0..(self.ast.tokens.len - node.main_token)) |index| { const token = self.ast.tokens.get(node.main_token + index); switch (token.tag) { .asterisk, .period, .identifier => { try buf.append(self.ast.tokenSlice(@as(u32, @intCast(node.main_token + index)))); }, else => return try std.mem.concat(self.allocator, u8, buf.items), } } }, else => {}, } return error.JetzigAstParserError; } fn isActionFunctionName(name: []const u8) bool { inline for (@typeInfo(jetzig.views.Route.Action).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, name)) return true; } return false; } inline fn chompExtension(path: []const u8) []const u8 { return path[0 .. path.len - std.fs.path.extension(path).len]; } fn zigEscape(self: Routes, input: []const u8) ![]const u8 { var buf = std.ArrayList(u8).init(self.allocator); const writer = buf.writer(); try std.zig.stringEscape(input, "", .{}, writer); return try buf.toOwnedSlice(); } fn normalizePosix(self: Routes, path: []const u8) ![]u8 { var buf = std.ArrayList([]const u8).init(self.allocator); defer buf.deinit(); var it = std.mem.splitSequence(u8, path, std.fs.path.sep_str); while (it.next()) |segment| try buf.append(segment); return try std.mem.join(self.allocator, std.fs.path.sep_str_posix, buf.items); } fn writeMailers(self: Routes, writer: anytype) !void { var dir = std.fs.openDirAbsolute(self.mailers_path, .{ .iterate = true }) catch |err| { switch (err) { error.FileNotFound => { std.debug.print( "[jetzig] Mailers directory not found, no mailers generated: `{s}`\n", .{self.mailers_path}, ); return; }, else => return err, } }; defer dir.close(); var count: usize = 0; var walker = try dir.walk(self.allocator); while (try walker.next()) |entry| { if (!std.mem.eql(u8, std.fs.path.extension(entry.path), ".zig")) continue; const realpath = try dir.realpathAlloc(self.allocator, entry.path); defer self.allocator.free(realpath); const root_relative_path = try self.relativePathFrom(.root, realpath, .posix); defer self.allocator.free(root_relative_path); const mailers_relative_path = try self.relativePathFrom(.mailers, realpath, .posix); defer self.allocator.free(mailers_relative_path); const module_path = try self.zigEscape(root_relative_path); defer self.allocator.free(module_path); const name_path = try self.zigEscape(mailers_relative_path); defer self.allocator.free(name_path); const name = chompExtension(name_path); try writer.writeAll(try std.fmt.allocPrint( self.allocator, \\ .{{ \\ .name = "{0s}", \\ .deliverFn = @import("{1s}").deliver, \\ .defaults = if (@hasDecl(@import("{1s}"), "defaults")) @import("{1s}").defaults else null, \\ .html_template = "{0s}/html", \\ .text_template = "{0s}/text", \\ }}, \\ , .{ name, module_path }, )); count += 1; } std.debug.print("[jetzig] Imported {} mailer(s)\n", .{count}); } fn writeJobs(self: Routes, writer: anytype) !void { var dir = std.fs.openDirAbsolute(self.jobs_path, .{ .iterate = true }) catch |err| { switch (err) { error.FileNotFound => { std.debug.print( "[jetzig] Jobs directory not found, no jobs generated: `{s}`\n", .{self.jobs_path}, ); return; }, else => return err, } }; defer dir.close(); var count: usize = 0; var walker = try dir.walk(self.allocator); while (try walker.next()) |entry| { if (!std.mem.eql(u8, std.fs.path.extension(entry.path), ".zig")) continue; const realpath = try dir.realpathAlloc(self.allocator, entry.path); defer self.allocator.free(realpath); const root_relative_path = try self.relativePathFrom(.root, realpath, .posix); defer self.allocator.free(root_relative_path); const jobs_relative_path = try self.relativePathFrom(.jobs, realpath, .posix); defer self.allocator.free(jobs_relative_path); const module_path = try self.zigEscape(root_relative_path); defer self.allocator.free(module_path); const name_path = try self.zigEscape(jobs_relative_path); defer self.allocator.free(name_path); const name = chompExtension(name_path); try writer.writeAll(try std.fmt.allocPrint( self.allocator, \\ .{{ \\ .name = "{0s}", \\ .runFn = @import("{1s}").run, \\ }}, \\ , .{ name, module_path }, )); count += 1; } std.debug.print("[jetzig] Imported {} job(s)\n", .{count}); }