diff --git a/build.zig b/build.zig index 77399fa..282c6ea 100644 --- a/build.zig +++ b/build.zig @@ -155,7 +155,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn &[_][]const u8{ root_path, "src", "app", "mailers" }, ); - var generate_routes = try Routes.init( + var routes = try Routes.init( b.allocator, root_path, templates_path, @@ -163,11 +163,11 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn jobs_path, mailers_path, ); - try generate_routes.generateRoutes(); + const generated_routes = try routes.generateRoutes(); const routes_write_files = b.addWriteFiles(); - const routes_file = routes_write_files.add("routes.zig", generate_routes.buffer.items); + const routes_file = routes_write_files.add("routes.zig", generated_routes); const tests_write_files = b.addWriteFiles(); - const tests_file = tests_write_files.add("tests.zig", generate_routes.buffer.items); + const tests_file = tests_write_files.add("tests.zig", generated_routes); const routes_module = b.createModule(.{ .root_source_file = routes_file }); var src_dir = try std.fs.openDirAbsolute(b.pathFromRoot("src"), .{ .iterate = true }); @@ -229,6 +229,19 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn test_step.dependOn(&run_exe_unit_tests.step); test_step.dependOn(&run_static_routes_cmd.step); exe_unit_tests.root_module.addImport("routes", routes_module); + + const routes_step = b.step("jetzig:routes", "List all routes in your app"); + const exe_routes = b.addExecutable(.{ + .name = "routes", + .root_source_file = jetzig_dep.path("src/routes.zig"), + .target = target, + .optimize = optimize, + }); + exe_routes.root_module.addImport("jetzig", jetzig_module); + exe_routes.root_module.addImport("routes", routes_module); + exe_routes.root_module.addImport("app", &exe.root_module); + const run_routes_cmd = b.addRunArtifact(exe_routes); + routes_step.dependOn(&run_routes_cmd.step); } fn generateMarkdownFragments(b: *std.Build) ![]const u8 { diff --git a/cli/cli.zig b/cli/cli.zig index beb85fb..00be41d 100644 --- a/cli/cli.zig +++ b/cli/cli.zig @@ -4,6 +4,7 @@ const init = @import("commands/init.zig"); const update = @import("commands/update.zig"); const generate = @import("commands/generate.zig"); const server = @import("commands/server.zig"); +const routes = @import("commands/routes.zig"); const bundle = @import("commands/bundle.zig"); const tests = @import("commands/tests.zig"); @@ -21,6 +22,7 @@ const Options = struct { .update = "Update current project to latest version of Jetzig", .generate = "Generate scaffolding", .server = "Run a development server", + .routes = "List all routes in your app", .bundle = "Create a deployment bundle", .@"test" = "Run app tests", .help = "Print help and exit", @@ -33,10 +35,12 @@ const Verb = union(enum) { update: update.Options, generate: generate.Options, server: server.Options, + routes: routes.Options, bundle: bundle.Options, @"test": tests.Options, g: generate.Options, s: server.Options, + r: routes.Options, b: bundle.Options, t: tests.Options, }; @@ -70,6 +74,7 @@ pub fn main() !void { \\ update Update current project to latest version of Jetzig. \\ generate Generate scaffolding. \\ server Run a development server. + \\ routes List all routes in your app. \\ bundle Create a deployment bundle. \\ test Run app tests. \\ @@ -110,6 +115,13 @@ fn run(allocator: std.mem.Allocator, options: args.ParseArgsResult(Options, Verb options.positionals, .{ .help = options.options.help }, ), + .r, .routes => |opts| routes.run( + allocator, + opts, + writer, + options.positionals, + .{ .help = options.options.help }, + ), .b, .bundle => |opts| bundle.run( allocator, opts, diff --git a/cli/commands/routes.zig b/cli/commands/routes.zig new file mode 100644 index 0000000..713818d --- /dev/null +++ b/cli/commands/routes.zig @@ -0,0 +1,45 @@ +const std = @import("std"); +const args = @import("args"); +const util = @import("../util.zig"); + +/// Command line options for the `routes` command. +pub const Options = struct { + pub const meta = .{ + .usage_summary = "", + .full_text = + \\Output all available routes for this app. + \\ + \\Example: + \\ + \\ jetzig routes + , + }; +}; + +/// Run the `jetzig routes` command. +pub fn run( + allocator: std.mem.Allocator, + options: Options, + writer: anytype, + positionals: [][]const u8, + other_options: struct { help: bool }, +) !void { + _ = positionals; + _ = options; + if (other_options.help) { + try args.printHelp(Options, "jetzig routes", writer); + return; + } + + var cwd = try util.detectJetzigProjectDir(); + defer cwd.close(); + + const realpath = try std.fs.realpathAlloc(allocator, "."); + defer allocator.free(realpath); + + try util.runCommandStreaming(allocator, realpath, &[_][]const u8{ + "zig", + "build", + "jetzig:routes", + }); +} diff --git a/demo/src/main.zig b/demo/src/main.zig index 013d928..0d5fe94 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -175,16 +175,18 @@ pub const jetzig_options = struct { }; }; +pub fn init(app: *jetzig.App) !void { + // Example custom route: + app.route(.GET, "/custom/:id/foo/bar", @import("app/views/custom/foo.zig"), .bar); +} + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const allocator = if (builtin.mode == .Debug) gpa.allocator() else std.heap.c_allocator; defer if (builtin.mode == .Debug) std.debug.assert(gpa.deinit() == .ok); - const app = try jetzig.init(allocator); + var app = try jetzig.init(allocator); defer app.deinit(); - // Example custom route: - // app.route(.GET, "/custom/:id/foo/bar", @import("app/views/custom/foo.zig"), .bar); - try app.start(routes, .{}); } diff --git a/src/Routes.zig b/src/Routes.zig index 43b87ed..963f25c 100644 --- a/src/Routes.zig +++ b/src/Routes.zig @@ -121,7 +121,7 @@ pub fn deinit(self: *Routes) void { } /// Generates the complete route set for the application -pub fn generateRoutes(self: *Routes) !void { +pub fn generateRoutes(self: *Routes) ![]const u8 { const writer = self.buffer.writer(); try writer.writeAll( @@ -177,6 +177,7 @@ pub fn generateRoutes(self: *Routes) !void { \\ ); + return try self.buffer.toOwnedSlice(); // std.debug.print("routes.zig\n{s}\n", .{self.buffer.items}); } @@ -270,6 +271,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function) \\ .action = .{1s}, \\ .view_name = "{2s}", \\ .view = jetzig.Route.ViewType{{ .{3s} = .{{ .{1s} = @import("{7s}").{1s} }} }}, + \\ .path = "{7s}", \\ .static = {4s}, \\ .uri_path = "{5s}", \\ .template = "{6s}", diff --git a/src/jetzig.zig b/src/jetzig.zig index b08e569..70ab899 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -209,6 +209,8 @@ pub const config = struct { } }; +pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init")) root.init else null; + /// Initialize a new Jetzig app. Call this from `src/main.zig` and then call /// `start(@import("routes").routes)` on the returned value. pub fn init(allocator: std.mem.Allocator) !App { @@ -221,5 +223,6 @@ pub fn init(allocator: std.mem.Allocator) !App { .environment = environment, .allocator = allocator, .custom_routes = std.ArrayList(views.Route).init(allocator), + .initHook = initHook, }; } diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index b10d78a..a71bb3a 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -10,6 +10,7 @@ const App = @This(); environment: jetzig.Environment, allocator: std.mem.Allocator, custom_routes: std.ArrayList(jetzig.views.Route), +initHook: ?*const fn (*App) anyerror!void, pub fn deinit(self: *const App) void { @constCast(self).custom_routes.deinit(); @@ -22,9 +23,11 @@ const AppOptions = struct {}; /// Starts an application. `routes` should be `@import("routes").routes`, a generated file /// automatically created at build time. `templates` should be /// `@import("src/app/views/zmpl.manifest.zig").templates`, created by Zmpl at compile time. -pub fn start(self: App, routes_module: type, options: AppOptions) !void { +pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { _ = options; // See `AppOptions` + if (self.initHook) |hook| try hook(@constCast(self)); + var mime_map = jetzig.http.mime.MimeMap.init(self.allocator); defer mime_map.deinit(); try mime_map.build(); @@ -137,7 +140,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void { } pub fn route( - self: *const App, + self: *App, comptime method: jetzig.http.Request.Method, comptime path: []const u8, comptime module: type, @@ -155,11 +158,11 @@ pub fn route( @memcpy(&view_name, module_name); std.mem.replaceScalar(u8, &view_name, '.', '/'); - @constCast(self).custom_routes.append(.{ + self.custom_routes.append(.{ .name = member, .action = .custom, .method = method, - .view_name = self.allocator.dupe(u8, &view_name) catch @panic("OOM"), + .view_name = module_name, .uri_path = path, .layout = if (@hasDecl(module, "layout")) module.layout else null, .view = comptime switch (viewType(path)) { diff --git a/src/jetzig/colors.zig b/src/jetzig/colors.zig index 032b3e3..e44a633 100644 --- a/src/jetzig/colors.zig +++ b/src/jetzig/colors.zig @@ -119,6 +119,10 @@ fn runtimeWrap(allocator: std.mem.Allocator, attribute: []const u8, message: []c ); } +pub fn bold(comptime message: []const u8) []const u8 { + return codes.escape ++ codes.bold ++ message ++ codes.escape ++ codes.reset; +} + pub fn black(comptime message: []const u8) []const u8 { return wrap(codes.black, message); } diff --git a/src/jetzig/loggers/LogQueue.zig b/src/jetzig/loggers/LogQueue.zig index 26a9e95..5a65790 100644 --- a/src/jetzig/loggers/LogQueue.zig +++ b/src/jetzig/loggers/LogQueue.zig @@ -170,23 +170,26 @@ pub const Reader = struct { self.queue.writer.mutex.lock(); defer self.queue.writer.mutex.unlock(); - const writer = switch (event.target) { - .stdout => blk: { + switch (event.target) { + .stdout => { stdout_written = true; if (builtin.os.tag == .windows) { file = self.stdout_file; colorize = self.queue.stdout_colorize; } - break :blk stdout_writer; }, - .stderr => blk: { + .stderr => { stderr_written = true; if (builtin.os.tag == .windows) { file = self.stderr_file; colorize = self.queue.stderr_colorize; } - break :blk stderr_writer; }, + } + + const writer = switch (event.target) { + .stdout => stdout_writer, + .stderr => stderr_writer, }; if (event.ptr) |ptr| { @@ -196,11 +199,7 @@ pub const Reader = struct { continue; } - if (builtin.os.tag == .windows and colorize) { - try writeWindows(file, writer, event); - } else { - try writer.writeAll(event.message[0..event.len]); - } + try jetzig.util.writeAnsi(file, writer, event.message[0..event.len]); self.queue.writer.position -= 1; @@ -273,20 +272,14 @@ fn initPool(allocator: std.mem.Allocator, T: type) std.heap.MemoryPool(T) { fn writeWindows(file: std.fs.File, writer: anytype, event: Event) !void { var info: std.os.windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; - _ = std.os.windows.kernel32.GetConsoleScreenBufferInfo( - file.handle, - &info - ); + _ = std.os.windows.kernel32.GetConsoleScreenBufferInfo(file.handle, &info); var it = std.mem.tokenizeSequence(u8, event.message[0..event.len], "\x1b["); while (it.next()) |token| { if (std.mem.indexOfScalar(u8, token, 'm')) |index| { if (index > 0 and index + 1 < token.len) { if (jetzig.colors.windows_map.get(token[0..index])) |color| { - try std.os.windows.SetConsoleTextAttribute( - file.handle, - color - ); + try std.os.windows.SetConsoleTextAttribute(file.handle, color); try writer.writeAll(token[index + 1 ..]); continue; } diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index d902e86..6acb53c 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -12,6 +12,8 @@ store: *jetzig.kv.Store, cache: *jetzig.kv.Store, job_queue: *jetzig.kv.Store, +const initHook = jetzig.root.initHook; + pub fn init(allocator: std.mem.Allocator, routes_module: type) !App { switch (jetzig.testing.state) { .ready => {}, diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index 1211780..b1420f8 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -1,4 +1,7 @@ const std = @import("std"); +const builtin = @import("builtin"); + +const colors = @import("colors.zig"); /// Compare two strings with case-insensitive matching. pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) bool { @@ -85,3 +88,30 @@ pub fn generateVariableName(buf: *[32]u8) []const u8 { } return buf[0..32]; } + +/// Write a string of bytes, possibly containing ANSI escape codes. Translate ANSI escape codes +/// into Windows API console commands. Allow building an ANSI string and writing at once to a +/// Windows console. In non-Windows environments, output ANSI bytes directly. +pub fn writeAnsi(file: std.fs.File, writer: anytype, text: []const u8) !void { + if (builtin.os.tag != .windows) { + try writer.writeAll(text); + } else { + var info: std.os.windows.CONSOLE_SCREEN_BUFFER_INFO = undefined; + _ = std.os.windows.kernel32.GetConsoleScreenBufferInfo(file.handle, &info); + + var it = std.mem.tokenizeSequence(u8, text, "\x1b["); + while (it.next()) |token| { + if (std.mem.indexOfScalar(u8, token, 'm')) |index| { + if (index > 0 and index + 1 < token.len) { + if (colors.windows_map.get(token[0..index])) |color| { + try std.os.windows.SetConsoleTextAttribute(file.handle, color); + try writer.writeAll(token[index + 1 ..]); + continue; + } + } + } + // Fallback + try writer.writeAll(token); + } + } +} diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index 053dd3a..94d1d52 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -51,6 +51,7 @@ action: Action, method: jetzig.http.Request.Method = undefined, // Used by custom routes only view_name: []const u8, uri_path: []const u8, +path: ?[]const u8 = null, view: ViewType, render: RenderFn = renderFn, renderStatic: RenderStaticFn = renderStaticFn, diff --git a/src/routes.zig b/src/routes.zig new file mode 100644 index 0000000..50945ce --- /dev/null +++ b/src/routes.zig @@ -0,0 +1,69 @@ +const std = @import("std"); +const routes = @import("routes"); +const app = @import("app"); +const jetzig = @import("jetzig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + comptime var max_uri_path_len: usize = 0; + + log("Jetzig Routes:", .{}); + + const environment = jetzig.Environment.init(undefined); + const initHook: ?*const fn (*jetzig.App) anyerror!void = if (@hasDecl(app, "init")) app.init else null; + + inline for (routes.routes) |route| max_uri_path_len = @max(route.uri_path.len + 5, max_uri_path_len); + const padded_path = std.fmt.comptimePrint("{{s: <{}}}", .{max_uri_path_len}); + + inline for (routes.routes) |route| { + const action = comptime switch (route.action) { + .get => jetzig.colors.cyan("{s: <7}"), + .index => jetzig.colors.blue("{s: <7}"), + .post => jetzig.colors.yellow("{s: <7}"), + .put => jetzig.colors.magenta("{s: <7}"), + .patch => jetzig.colors.purple("{s: <7}"), + .delete => jetzig.colors.red("{s: <7}"), + .custom => unreachable, + }; + + log(" " ++ action ++ " " ++ padded_path ++ " {?s}", .{ + @tagName(route.action), + route.uri_path ++ switch (route.action) { + .index, .post => "", + .get, .put, .patch, .delete => "/:id", + .custom => "", + }, + route.path, + }); + } + + var jetzig_app = jetzig.App{ + .environment = environment, + .allocator = allocator, + .custom_routes = std.ArrayList(jetzig.views.Route).init(allocator), + .initHook = initHook, + }; + + if (initHook) |hook| try hook(&jetzig_app); + + for (jetzig_app.custom_routes.items) |route| { + log( + " " ++ jetzig.colors.bold(jetzig.colors.white("{s: <7}")) ++ " " ++ padded_path ++ " {s}:{s}", + .{ route.name, route.uri_path, route.view_name, route.name }, + ); + } +} + +fn log(comptime message: []const u8, args: anytype) void { + std.debug.print(message ++ "\n", args); +} + +fn sortedRoutes(comptime unordered_routes: []const jetzig.views.Route) void { + comptime std.sort.pdq(jetzig.views.Route, unordered_routes, {}, lessThanFn); +} +pub fn lessThanFn(context: void, lhs: jetzig.views.Route, rhs: jetzig.views.Route) bool { + _ = context; + return std.mem.order(u8, lhs.uri_path, rhs.uri_path).compare(std.math.CompareOperator.lt); +}