Merge pull request #14 from jetzig-framework/cli-generate

CLI generate command
This commit is contained in:
bobf 2024-03-09 19:07:45 +00:00 committed by GitHub
commit 647833ca5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 605 additions and 57 deletions

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/checkout@v3
with:
submodules: true
- name: Setup Zig
# You may pin to the exact commit or the version.
# uses: goto-bus-stop/setup-zig@41ae19e72e21b9a1380e86ff9f058db709fc8fc6
@ -32,10 +32,10 @@ jobs:
with:
version: master
cache: true # Let's see how this behaves
- run: zig version
- run: zig env
- name: Build
run: zig build --verbose
@ -51,13 +51,13 @@ jobs:
cd cli
for target in "${targets[@]}"; do
mkdir -p $root/artifacts/$target
echo "Building target ${target}..."
echo "Building target ${target}..."
zig build -Dtarget=${target} -Doptimize=ReleaseSafe --prefix $root/artifacts/${target}/ &
sed -e '1,5d' < $root/README.md > $root/artifacts/${target}/README.md
cp $root/LICENSE $root/artifacts/${target}/
done
wait
- name: Upload artifacts Target Windows
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v2
@ -74,7 +74,7 @@ jobs:
if: ${{ matrix.os == 'ubuntu-latest' }}
uses: actions/upload-artifact@v2
with:
name: builds-macos-x86
name: build-macos-x86
path: artifacts/x86_64-macos
- name: Upload artifacts Target MacOS 2
if: ${{ matrix.os == 'ubuntu-latest' }}

View File

@ -1,6 +1,7 @@
const std = @import("std");
const args = @import("args");
const init = @import("init.zig");
const init = @import("commands/init.zig");
const generate = @import("commands/generate.zig");
const Options = struct {
help: bool = false,
@ -10,8 +11,10 @@ const Options = struct {
};
pub const meta = .{
.usage_summary = "[COMMAND]",
.option_docs = .{
.init = "Initialize a new project",
.generate = "Generate scaffolding",
.help = "Print help and exit",
},
};
@ -19,6 +22,8 @@ const Options = struct {
const Verb = union(enum) {
init: init.Options,
generate: generate.Options,
g: generate.Options,
};
/// Main entrypoint for `jetzig` executable. Parses command line args and generates a new
@ -33,28 +38,45 @@ pub fn main() !void {
const writer = std.io.getStdErr().writer();
if (options.verb) |verb| {
switch (verb) {
.init => |opts| return init.run(
allocator,
opts,
writer,
options.positionals,
.{ .help = options.options.help },
),
run(allocator, options, writer) catch |err| {
switch (err) {
error.JetzigCommandError => std.os.exit(1),
else => return err,
}
}
};
if (options.options.help) {
if (options.options.help or options.verb == null) {
try args.printHelp(Options, "jetzig", writer);
try writer.writeAll(
\\
\\Commands:
\\
\\ init Initialize a new project.
\\ generate Generate scaffolding.
\\
\\ Pass --help to any command for more information, e.g. `jetzig init --help`
\\
);
}
}
fn run(allocator: std.mem.Allocator, options: args.ParseArgsResult(Options, Verb), writer: anytype) !void {
if (options.verb) |verb| {
return switch (verb) {
.init => |opts| init.run(
allocator,
opts,
writer,
options.positionals,
.{ .help = options.options.help },
),
.g, .generate => |opts| generate.run(
allocator,
opts,
writer,
options.positionals,
.{ .help = options.options.help },
),
};
}
}

81
cli/commands/generate.zig Normal file
View File

@ -0,0 +1,81 @@
const std = @import("std");
const args = @import("args");
const view = @import("generate/view.zig");
const partial = @import("generate/partial.zig");
const middleware = @import("generate/middleware.zig");
const util = @import("../util.zig");
/// Command line options for the `generate` command.
pub const Options = struct {
path: ?[]const u8 = null,
pub const shorthands = .{
.p = "path",
};
pub const meta = .{
.usage_summary = "[view|middleware] [options]",
.full_text =
\\Generates scaffolding for views, middleware, and other objects in future.
\\
\\When generating a view, by default all actions will be included.
\\Optionally pass one or more of the following arguments to specify desired actions:
\\
\\ index, get, post, patch, put, delete
\\
\\Each view action can be qualified with a `:static` option to mark the view content
\\as statically generated at build time.
\\
\\e.g. generate a view named `iguanas` with a static `index` action:
\\
\\ jetzig generate view iguanas index:static get post delete
,
};
};
/// Run the `jetzig init` command.
pub fn run(
allocator: std.mem.Allocator,
options: Options,
writer: anytype,
positionals: [][]const u8,
other_options: struct { help: bool },
) !void {
var cwd = try util.detectJetzigProjectDir();
defer cwd.close();
_ = options;
if (other_options.help) {
try args.printHelp(Options, "jetzig generate", writer);
return;
}
var generate_type: ?enum { view, partial, middleware } = null;
var sub_args = std.ArrayList([]const u8).init(allocator);
defer sub_args.deinit();
for (positionals) |arg| {
if (generate_type == null and std.mem.eql(u8, arg, "view")) {
generate_type = .view;
} else if (generate_type == null and std.mem.eql(u8, arg, "partial")) {
generate_type = .partial;
} else if (generate_type == null and std.mem.eql(u8, arg, "middleware")) {
generate_type = .middleware;
} else if (generate_type == null) {
std.debug.print("Unknown generator command: {s}\n", .{arg});
return error.JetzigCommandError;
} else {
try sub_args.append(arg);
}
}
if (generate_type) |capture| {
return switch (capture) {
.view => view.run(allocator, cwd, sub_args.items),
.partial => partial.run(allocator, cwd, sub_args.items),
.middleware => middleware.run(allocator, cwd, sub_args.items),
};
} else {
std.debug.print("Missing sub-command. Expected: [view|middleware]\n", .{});
return error.JetzigCommandError;
}
}

View File

@ -0,0 +1,101 @@
const std = @import("std");
const util = @import("../../util.zig");
/// Run the middleware generator. Create a middleware file in `src/app/middleware/`
pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8) !void {
if (args.len != 1 or !util.isCamelCase(args[0])) {
std.debug.print(
\\Expected a middleware name in CamelCase.
\\
\\Example:
\\
\\ jetzig generate middleware IguanaBrain
\\
, .{});
return error.JetzigCommandError;
}
const dir_path = try std.fs.path.join(allocator, &[_][]const u8{ "src", "app", "middleware" });
defer allocator.free(dir_path);
var dir = try cwd.makeOpenPath(dir_path, .{});
defer dir.close();
const filename = try std.mem.concat(allocator, u8, &[_][]const u8{ args[0], ".zig" });
defer allocator.free(filename);
const file = dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Middleware already exists: {s}\n", .{filename});
return error.JetzigCommandError;
},
else => return err,
}
};
try file.writeAll(middleware_content);
file.close();
const realpath = try dir.realpathAlloc(allocator, filename);
defer allocator.free(realpath);
std.debug.print(
\\Generated middleware: {s}
\\
\\Edit `src/main.zig` and add the new middleware to the `jetzig_options.middleware` declaration:
\\
\\ pub const jetzig_options = struct {{
\\ pub const middleware: []const type = &.{{
\\ @import("app/middleware/{s}.zig"),
\\ }};
\\ }};
\\
\\Middleware are invoked in the order they appear in `jetzig_options.middleware`.
\\
\\
, .{ realpath, args[0] });
}
const middleware_content =
\\const std = @import("std");
\\const jetzig = @import("jetzig");
\\
\\/// Define any custom data fields you want to store here. Assigning to these fields in the `init`
\\/// function allows you to access them in the `beforeRequest` and `afterRequest` functions, where
\\/// they can also be modified.
\\my_custom_value: []const u8,
\\
\\const Self = @This();
\\
\\/// Initialize middleware.
\\pub fn init(request: *jetzig.http.Request) !*Self {
\\ var middleware = try request.allocator.create(Self);
\\ middleware.my_custom_value = "initial value";
\\ return middleware;
\\}
\\
\\/// Invoked immediately after the request head has been processed, before relevant view function
\\/// is processed. This gives you access to request headers but not the request body.
\\pub fn beforeRequest(self: *Self, request: *jetzig.http.Request) !void {
\\ request.server.logger.debug("[middleware] my_custom_value: {s}", .{self.my_custom_value});
\\ self.my_custom_value = @tagName(request.method);
\\}
\\
\\/// Invoked immediately after the request has finished responding. Provides full access to the
\\/// response as well as the request.
\\pub fn afterRequest(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void {
\\ request.server.logger.debug(
\\ "[middleware] my_custom_value: {s}, response status: {s}",
\\ .{ self.my_custom_value, @tagName(response.status_code) },
\\ );
\\}
\\
\\/// Invoked after `afterRequest` is called, use this function to do any clean-up.
\\/// Note that `request.allocator` is an arena allocator, so any allocations are automatically
\\/// done before the next request starts processing.
\\pub fn deinit(self: *Self, request: *jetzig.http.Request) void {
\\ request.allocator.destroy(self);
\\}
\\
;

View File

@ -0,0 +1,46 @@
const std = @import("std");
/// Run the partial generator. Create a partial template in `src/app/views/`
pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8) !void {
if (args.len != 2) {
std.debug.print(
\\Expected a view name and a name for a partial.
\\
\\Example:
\\
\\ jetzig generate partial iguanas ziglet
\\
, .{});
return error.JetzigCommandError;
}
const dir_path = try std.fs.path.join(allocator, &[_][]const u8{ "src", "app", "views", args[0] });
defer allocator.free(dir_path);
var dir = try cwd.makeOpenPath(dir_path, .{});
defer dir.close();
const filename = try std.mem.concat(allocator, u8, &[_][]const u8{ "_", args[1], ".zmpl" });
defer allocator.free(filename);
const file = dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Partial already exists: {s}\n", .{filename});
return error.JetzigCommandError;
},
else => return err,
}
};
try file.writeAll(
\\<div>Partial content goes here.</div>
\\
);
file.close();
const realpath = try dir.realpathAlloc(allocator, filename);
defer allocator.free(realpath);
std.debug.print("Generated partial template: {s}\n", .{realpath});
}

View File

@ -0,0 +1,220 @@
const std = @import("std");
const util = @import("../../util.zig");
/// Run the view generator. Create a view in `src/app/views/`
pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8) !void {
if (args.len == 0) {
std.debug.print(".\n", .{});
std.debug.print(
\\Expected view name followed by optional actions.
\\
\\Example:
\\
\\ jetzig generate view iguanas index:static get post delete
\\
, .{});
return error.JetzigCommandError;
}
var buf = std.ArrayList(u8).init(allocator);
defer buf.deinit();
const writer = buf.writer();
try writer.writeAll(
\\const std = @import("std");
\\const jetzig = @import("jetzig");
\\
\\
);
const filename = try std.mem.concat(allocator, u8, &[_][]const u8{ args[0], ".zig" });
defer allocator.free(filename);
const action_args = if (args.len > 1)
args[1..]
else
&[_][]const u8{ "index", "get", "post", "put", "patch", "delete" };
var actions = std.ArrayList(Action).init(allocator);
defer actions.deinit();
var static_actions = std.ArrayList(Action).init(allocator);
defer static_actions.deinit();
for (action_args) |arg| {
if (parseAction(arg)) |action| {
try actions.append(action);
if (action.static) try static_actions.append(action);
} else {
std.debug.print("Unexpected argument: {s}\n", .{arg});
return error.JetzigCommandError;
}
}
if (static_actions.items.len > 0) try writeStaticParams(allocator, static_actions.items, writer);
for (actions.items) |action| {
try writeAction(allocator, writer, action);
try writeTemplate(allocator, cwd, args[0], action);
}
var dir = try cwd.openDir("src/app/views", .{});
defer dir.close();
const file = dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Path already exists, skipping view creation: {s}\n", .{filename});
return error.JetzigCommandError;
},
else => return err,
}
};
try file.writeAll(util.strip(buf.items));
try file.writeAll("\n");
file.close();
const realpath = try dir.realpathAlloc(allocator, filename);
defer allocator.free(realpath);
std.debug.print("Generated view: {s}\n", .{realpath});
}
const Method = enum { index, get, post, put, patch, delete };
const Action = struct {
method: Method,
static: bool,
};
// Parse a view arg. Grammar:
// [index[:static]|get[:static]|post[:static]|put[:static]|patch[:static]|delete[:static]]
fn parseAction(arg: []const u8) ?Action {
inline for (@typeInfo(Method).Enum.fields) |tag| {
const with_static = tag.name ++ ":static";
const method: Method = @enumFromInt(tag.value);
if (std.mem.eql(u8, tag.name, arg)) return .{ .method = method, .static = false };
if (std.mem.eql(u8, with_static, arg)) return .{ .method = method, .static = true };
}
return null;
}
// Write a view function to the output buffer.
fn writeAction(allocator: std.mem.Allocator, writer: anytype, action: Action) !void {
const function = try std.fmt.allocPrint(
allocator,
\\pub fn {s}({s}request: *jetzig.{s}, data: *jetzig.Data) !jetzig.View {{
\\ _ = data;{s}
\\ return request.render({s});
\\}}
\\
\\
,
.{
@tagName(action.method),
switch (action.method) {
.index, .post => "",
.get, .put, .patch, .delete => "id: []const u8, ",
},
if (action.static) "StaticRequest" else "Request",
switch (action.method) {
.index, .post => "",
.get, .put, .patch, .delete => "\n _ = id;",
},
switch (action.method) {
.index, .get => ".ok",
.post => ".created",
.put, .patch, .delete => ".ok",
},
},
);
defer allocator.free(function);
try writer.writeAll(function);
}
// Output static params example. Only invoked if one or more static routes are created.
fn writeStaticParams(allocator: std.mem.Allocator, actions: []Action, writer: anytype) !void {
try writer.writeAll(
\\// Define an array of params for each static view function.
\\// At build time, static outputs are generated for each set of params.
\\// At run time, requests matching the provided params will render the pre-rendered content.
\\pub const static_params = .{
\\
);
for (actions) |action| {
switch (action.method) {
.index, .post => {
const output = try std.fmt.allocPrint(
allocator,
\\ .{s} = .{{
\\ .{{ .params = .{{ .foo = "bar", .baz = "qux" }} }},
\\ }},
\\
,
.{@tagName(action.method)},
);
defer allocator.free(output);
try writer.writeAll(output);
},
.get, .put, .patch, .delete => {
const output = try std.fmt.allocPrint(
allocator,
\\ .{s} = .{{
\\ .{{ .id = "1", .params = .{{ .foo = "bar", .baz = "qux" }} }},
\\ }},
\\
,
.{@tagName(action.method)},
);
defer allocator.free(output);
try writer.writeAll(output);
},
}
}
try writer.writeAll(
\\};
\\
\\
);
}
// Generates a Zmpl template for a corresponding view + action.
fn writeTemplate(allocator: std.mem.Allocator, cwd: std.fs.Dir, name: []const u8, action: Action) !void {
const path = try std.fs.path.join(allocator, &[_][]const u8{
"src",
"app",
"views",
name,
});
defer allocator.free(path);
var view_dir = try cwd.makeOpenPath(path, .{});
defer view_dir.close();
const filename = try std.mem.concat(allocator, u8, &[_][]const u8{ @tagName(action.method), ".zmpl" });
defer allocator.free(filename);
const file = view_dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Path already exists, skipping template creation: {s}\n", .{filename});
return;
},
else => return err,
}
};
try file.writeAll(
\\<div>
\\ <span>Content goes here</span>
\\</div>
\\
);
file.close();
const realpath = try view_dir.realpathAlloc(allocator, filename);
defer allocator.free(realpath);
std.debug.print("Generated template: {s}\n", .{realpath});
}

View File

@ -1,18 +1,10 @@
const std = @import("std");
const args = @import("args");
const util = @import("../util.zig");
const init_data = @import("init_data").init_data;
fn base64Decode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const decoder = std.base64.Base64Decoder.init(
std.base64.url_safe_no_pad.alphabet_chars,
std.base64.url_safe_no_pad.pad_char,
);
const size = try decoder.calcSizeForSlice(input);
const ptr = try allocator.alloc(u8, size);
try decoder.decode(ptr, input);
return ptr;
}
/// Command line options for the `init` command.
pub const Options = struct {
path: ?[]const u8 = null,
@ -50,7 +42,7 @@ pub fn run(
for (positionals) |arg| {
if (install_path != null) {
std.debug.print("Unexpected positional argument: {s}\n", .{arg});
return error.JetzigUnexpectedPositionalArgumentsError;
return error.JetzigCommandError;
}
install_path = arg;
}
@ -230,7 +222,7 @@ fn runCommand(allocator: std.mem.Allocator, install_path: []const u8, argv: []co
std.debug.print("[exec] {s}", .{command});
if (result.term.Exited != 0) {
printFailure();
util.printFailure();
std.debug.print(
\\
\\Error running command: {s}
@ -244,9 +236,9 @@ fn runCommand(allocator: std.mem.Allocator, install_path: []const u8, argv: []co
\\{s}
\\
, .{ command, result.stdout, result.stderr });
return error.JetzigRunCommandError;
return error.JetzigCommandError;
} else {
printSuccess();
util.printSuccess();
}
}
@ -267,7 +259,7 @@ fn copySourceFile(
var content: []const u8 = undefined;
if (replace) |capture| {
const initial = readSourceFile(allocator, src) catch |err| {
printFailure();
util.printFailure();
return err;
};
defer allocator.free(initial);
@ -276,25 +268,25 @@ fn copySourceFile(
}
} else {
content = readSourceFile(allocator, src) catch |err| {
printFailure();
util.printFailure();
return err;
};
}
defer allocator.free(content);
writeSourceFile(install_dir, dest, content) catch |err| {
printFailure();
util.printFailure();
return err;
};
printSuccess();
util.printSuccess();
}
// Read a file from Jetzig source code.
fn readSourceFile(allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
inline for (init_data) |file| {
if (std.mem.eql(u8, path, file.path)) return try base64Decode(allocator, file.data);
if (std.mem.eql(u8, path, file.path)) return try util.base64Decode(allocator, file.data);
}
return error.SourceFileNotFound;
return error.JetzigCommandError;
}
// Write a file to the new project's directory.
@ -335,7 +327,7 @@ fn githubUrl(allocator: std.mem.Allocator) ![]const u8 {
if (fetch_result.status != .ok) {
std.debug.print("Error fetching from GitHub: {s}\n", .{url});
return error.JetzigGitHubFetchError;
return error.JetzigCommandError;
}
const parsed_response = try std.json.parseFromSlice(
@ -381,20 +373,15 @@ fn promptInput(
const input = try reader.readUntilDelimiterOrEofAlloc(allocator, '\n', max_read_bytes);
if (input) |capture| {
defer allocator.free(capture);
const stripped_input = strip(capture);
const stripped_input = util.strip(capture);
if (std.mem.eql(u8, stripped_input, "")) {
if (options.default) |default| return try allocator.dupe(u8, strip(default));
if (options.default) |default| return try allocator.dupe(u8, util.strip(default));
} else return try allocator.dupe(u8, stripped_input);
}
}
}
// Strip leading and trailing whitespace from a u8 slice.
fn strip(input: []const u8) []const u8 {
return std.mem.trim(u8, input, &std.ascii.whitespace);
}
// Initialize a new Git repository when setting up a new project (optional).
fn gitSetup(allocator: std.mem.Allocator, install_dir: *std.fs.Dir) !void {
try runCommand(allocator, install_dir, &[_][]const u8{
@ -416,13 +403,3 @@ fn gitSetup(allocator: std.mem.Allocator, install_dir: *std.fs.Dir) !void {
"Initialize Jetzig project",
});
}
/// Print a success confirmation.
fn printSuccess() void {
std.debug.print("\n", .{});
}
/// Print a failure confirmation.
fn printFailure() void {
std.debug.print("\n", .{});
}

96
cli/util.zig Normal file
View File

@ -0,0 +1,96 @@
const std = @import("std");
/// Decode a base64 string, used for parsing out build artifacts generated by the CLI program's
/// build.zig which are stored in the executable as a module.
pub fn base64Decode(allocator: std.mem.Allocator, input: []const u8) ![]const u8 {
const decoder = std.base64.Base64Decoder.init(
std.base64.url_safe_no_pad.alphabet_chars,
std.base64.url_safe_no_pad.pad_char,
);
const size = try decoder.calcSizeForSlice(input);
const ptr = try allocator.alloc(u8, size);
try decoder.decode(ptr, input);
return ptr;
}
/// Print a success confirmation.
pub fn printSuccess() void {
std.debug.print("\n", .{});
}
/// Print a failure confirmation.
pub fn printFailure() void {
std.debug.print("\n", .{});
}
/// Verifies that cwd is the root of a Jetzig project
pub fn detectJetzigProjectDir() !std.fs.Dir {
var dir = try std.fs.cwd().openDir(".", .{});
const max_parent_dirs: usize = 100; // Prevent symlink loops or other weird stuff.
for (0..max_parent_dirs) |_| {
if (try isPath(dir, "build.zig", .file) and try isPath(dir, "src/app/views", .dir)) return dir;
dir = dir.openDir("..", .{}) catch |err| {
switch (err) {
error.FileNotFound, error.NotDir => {
std.debug.print(
"Encountered unexpected detecting Jetzig project directory: {s}\n",
.{@errorName(err)},
);
return error.JetzigCommandError;
},
else => return err,
}
};
continue;
}
std.debug.print(
\\Exceeded maximum parent directory depth.
\\Unable to detect Jetzig project directory.
\\
,
.{},
);
return error.JetzigCommandError;
}
fn isPath(dir: std.fs.Dir, sub_path: []const u8, path_type: enum { file, dir }) !bool {
switch (path_type) {
.file => {
_ = dir.statFile(sub_path) catch |err| {
switch (err) {
error.FileNotFound => return false,
else => return err,
}
};
return true;
},
.dir => {
var test_dir = dir.openDir(sub_path, .{}) catch |err| {
switch (err) {
error.FileNotFound, error.NotDir => return false,
else => return err,
}
};
test_dir.close();
return true;
},
}
}
// Strip leading and trailing whitespace from a u8 slice.
pub fn strip(input: []const u8) []const u8 {
return std.mem.trim(u8, input, &std.ascii.whitespace);
}
/// Attempts to confirm if a string input is in CamelCase. False if the first character is
/// not alphabetic lower-case or if the input contains underscores.
pub fn isCamelCase(input: []const u8) bool {
if (input.len == 0) return false;
if (!std.mem.containsAtLeast(u8, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 1, &[_]u8{input[0]})) return false;
if (std.mem.containsAtLeast(u8, input, 1, "_")) return false;
return true;
}

View File

@ -0,0 +1,3 @@
pub fn exampleFunction() []const u8 {
return "example value";
}

2
src/cli.gitignore Normal file
View File

@ -0,0 +1,2 @@
zig-out/
zig-cache/