diff options
| author | tsne <tsne.dev@outlook.com> | 2025-01-01 14:58:28 +0100 |
|---|---|---|
| committer | tsne <tsne.dev@outlook.com> | 2025-10-30 08:32:49 +0100 |
| commit | 44e5ad763794a438ecfd50c8b7f6ea760ea82da5 (patch) | |
| tree | 575a1fc72ceb1d7f052cf582abc1e038e27f69a3 /src/template.zig | |
| download | porteur-44e5ad763794a438ecfd50c8b7f6ea760ea82da5.tar.gz | |
Diffstat (limited to 'src/template.zig')
| -rw-r--r-- | src/template.zig | 476 |
1 files changed, 476 insertions, 0 deletions
diff --git a/src/template.zig b/src/template.zig new file mode 100644 index 0000000..210e77d --- /dev/null +++ b/src/template.zig @@ -0,0 +1,476 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; + +const cli = @import("cli.zig"); +const fs = @import("fs.zig"); +const conf = @import("conf.zig"); +const shell = @import("shell.zig"); +const Env = @import("env.zig"); +const fatal = cli.fatal; + +pub const Config = struct { + raw: conf.Config, + + prepare_dist_target: ?[]const u8, + + fn from_file(env: Env, tmpl: Template) !Config { + const config_file = tmpl.config_file(); + const conf_res = try conf.Config.from_file(env.allocator, config_file.name()); + switch (conf_res) { + .conf => |c| return .init(env, c), + .err => |e| { + env.err("{s} (line {d})", .{ e.msg, e.line }); + tmpl_corrupted(tmpl.name, "invalid configuration"); + }, + } + } + + fn init(env: Env, raw: conf.Config) Config { + var prepare_dist_target: ?[]const u8 = null; + + var iter = raw.iterate(); + while (iter.next()) |v| { + if (std.mem.eql(u8, v.key, "prepare-dist-target") and v.val.len > 0) { + prepare_dist_target = v.val; + } else { + env.warn("unknown template configuration `{s}`", .{v.key}); + } + } + + return .{ + .raw = raw, + .prepare_dist_target = prepare_dist_target, + }; + } + + pub fn deinit(self: Config) void { + self.raw.deinit(); + } +}; + +pub const Template = struct { + const config_filename = ".config"; + + name: []const u8, + path: fs.Path, + + pub fn init(env: Env, template_name: []const u8) Template { + var template_path = etc_dir(env); + template_path.append(.{template_name}); + return .{ + .name = template_name, + .path = template_path, + }; + } + + pub fn render(self: *const Template, env: Env, tmpl_conf: []const conf.Config, target: fs.Path) !void { + var dir = try std.fs.openDirAbsoluteZ(self.path.name(), .{ .iterate = true }); + defer dir.close(); + + var walker = try dir.walk(env.allocator); + defer walker.deinit(); + + while (try walker.next()) |entry| { + if (std.mem.eql(u8, entry.path, config_filename)) { + continue; + } + + const entry_path = try replace_vars_in_path(target, entry.path, tmpl_conf); + switch (entry.kind) { + .directory => { + try fs.mkdir_all(entry_path); + }, + .file => { + env.info("writing template file {s}", .{entry_path.name()[target.len + 1 ..]}); + const input = try fs.read_file(env.allocator, .join(.{ self.path.name(), entry.path })); + + var output_file = try std.fs.createFileAbsoluteZ(entry_path.name(), .{ .mode = 0o644 }); + defer output_file.close(); + + var write_buf: [1024]u8 = undefined; + var output_writer = output_file.writer(&write_buf); + const output = &output_writer.interface; + + try replace_vars(output, input.?, tmpl_conf); + try output.flush(); + }, + else => {}, + } + } + } + + /// Does not flush the writer. + fn replace_vars(w: *std.io.Writer, text: []const u8, tmpl_confs: []const conf.Config) !void { + var rest = text; + while (rest.len > 0) { + const pos = std.mem.indexOfScalar(u8, rest, '{') orelse { + try w.writeAll(rest); + return; + }; + + if (pos > 0) { + try w.writeAll(rest[0..pos]); + rest = rest[pos..]; + } + + assert(rest.len > 0 and rest[0] == '{'); + if (rest.len == 1) { + try w.writeByte(rest[0]); + return; + } + if (rest[1] != '{') { + try w.writeAll(rest[0..2]); + rest = rest[2..]; + continue; + } + + // Consume all opening braces to take the innermost pair. + var begin: usize = 2; + while (begin < rest.len and rest[begin] == '{') { + try w.writeByte('{'); + begin += 1; + } + + const end = std.mem.indexOfScalarPos(u8, rest, begin, '}') orelse return error.missing_variable_closing; + if (end == rest.len - 1 or rest[end + 1] != '}') return error.missing_variable_closing; + + const var_name = rest[begin..end]; + if (var_name.len == 0) return error.missing_variable_name; + var found_var = false; + for (tmpl_confs) |config| { + if (config.find_var(var_name)) |v| { + if (v.val.len > 0) { + try w.writeAll(v.val); + } + found_var = true; + break; + } + } + if (!found_var) { + fatal("variable `{s}` not defined", .{var_name}); + } + + rest = rest[end + 2 ..]; + } + } + + fn replace_vars_in_path(root: fs.Path, subpath: []const u8, tmpl_confs: []const conf.Config) !fs.Path { + assert(root.len > 0); + assert(subpath.len > 0); + + const slash = std.fs.path.sep; + var res: fs.Path = root; + res.buf[res.len] = slash; + res.len += 1; + var res_writer = std.io.Writer.fixed(res.buf[res.len..]); + + var name = subpath; + while (name.len > 0) { + const idx = std.mem.indexOfScalar(u8, name, slash) orelse name.len - 1; + const part = name[0 .. idx + 1]; + const pos = res_writer.end; + replace_vars(&res_writer, part, tmpl_confs) catch |err| switch (err) { + error.missing_variable_closing, error.missing_variable_name => { + res_writer.undo(res_writer.end - pos); + try res_writer.writeAll(part); + }, + else => return err, + }; + name = name[idx + 1 ..]; + } + try res_writer.flush(); + + res.len += res_writer.end; + res.buf[res.len] = 0; + return res; + } + + fn config_file(self: Template) fs.Path { + return .join(.{ self.path.name(), config_filename }); + } +}; + +pub const Iterator = struct { + path: fs.Path, + dir: ?std.fs.Dir = null, + iter: ?std.fs.Dir.Iterator = null, + + pub fn deinit(self: *Iterator) void { + if (self.dir) |*dir| dir.close(); + } + + pub fn next(self: *Iterator) !?Template { + if (self.iter) |*iter| { + while (try iter.next()) |entry| { + if (entry.kind == .directory) { + var path = self.path; + path.append(.{entry.name}); + return .{ + .name = entry.name, + .path = path, + }; + } + } + } + return null; + } +}; + +pub inline fn iterate(env: Env) !Iterator { + const path = etc_dir(env); + const dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) { + return .{ .path = path }; + } + return err; + }; + return .{ + .path = path, + .dir = dir, + .iter = dir.iterate(), + }; +} + +pub fn exists(env: Env, template_name: []const u8) !bool { + return try get(env, template_name) != null; +} + +pub fn exists_any(env: Env) !bool { + var iter = try iterate(env); + defer iter.deinit(); + if (try iter.next()) |_| { + return true; + } + return false; +} + +pub fn get(env: Env, template_name: []const u8) !?Template { + const tmpl: Template = .init(env, template_name); + const info = try fs.dir_info(tmpl.path); + return if (info.exists) tmpl else null; +} + +pub fn must_get(env: Env, template_name: []const u8) !Template { + return try get(env, template_name) orelse { + fatal("template not found: {s}", .{template_name}); + }; +} + +pub fn read_config(env: Env, tmpl: Template) !Config { + return Config.from_file(env, tmpl); +} + +pub fn edit_config(env: Env, template_name: []const u8) !void { + const tmpl = try must_get(env, template_name); + try shell.edit_config_file(env, tmpl.config_file(), verify_config); +} + +pub fn import_config(env: Env, template_name: []const u8, new_config_file: fs.Path) !void { + const tmpl = try must_get(env, template_name); + + const parsed = try conf.Config.from_file(env.allocator, new_config_file.name()); + const config = switch (parsed) { + .conf => |c| c, + .err => |e| { + env.err("{s} (line {d})", .{ e.msg, e.line }); + fatal("failed to import {s}", .{new_config_file.name()}); + }, + }; + defer config.deinit(); + + if (!verify_config(env, config)) { + std.process.exit(1); + } + try fs.mv_file(new_config_file, tmpl.config_file()); +} + +pub fn etc_dir(env: Env) fs.Path { + return env.etc_path(.{"tmpl"}); +} + +fn verify_config(env: Env, raw: conf.Config) bool { + const tmpl_conf: Config = .init(env, raw); + var valid = true; + if (tmpl_conf.prepare_dist_target) |prepare_dist_target| { + if (std.mem.indexOfAny(u8, prepare_dist_target, " \n\t\r")) |_| { + cli.stderr.write_line("Error: invalid value `prepare-dist-target`", .{}); + valid = false; + } + } + return valid; +} + +fn tmpl_corrupted(tmpl_name: []const u8, comptime reason: []const u8) noreturn { + fatal("template {s} corrupted (" ++ reason ++ ")", .{tmpl_name}); +} + +test "render simple template - variable at end" { + const tmpl = "This is a simple {{test}} with multiple {{vars}} in different {{places}}"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("test", "template"); + try config.add_var("not", "used"); + try config.add_var("vars", "variables"); + try config.add_var("places", "positions"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, 2 * tmpl.len); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("This is a simple template with multiple variables in different positions", rendered.written()); +} + +test "render simple template - variable at beginning" { + const tmpl = "{{this}} is a simple {{test}} with multiple {{vars}} in different places"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("this", "This"); + try config.add_var("test", "template"); + try config.add_var("not", "used"); + try config.add_var("vars", "variables"); + try config.add_var("places", "positions"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, 2 * tmpl.len); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("This is a simple template with multiple variables in different places", rendered.written()); +} + +test "render empty template" { + const tmpl = ""; + + const config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + var rendered: std.io.Writer.Allocating = .init(std.testing.allocator); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("", rendered.written()); +} + +test "render template without braces" { + const tmpl = "No braces at all"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("No braces at all", rendered.written()); +} + +test "render template without variable and brace" { + const tmpl = "{No variable seen here {"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("{No variable seen here {", rendered.written()); +} + +test "render template with multiple opening braces" { + const tmpl = "Use ${{{foo}}} in your env."; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("foo", "FOOBAR"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + try Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectEqualStrings("Use ${FOOBAR} in your env.", rendered.written()); +} + +test "render template with missing variable closing" { + const tmpl = "Invalid template {{foo"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + const res = Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectError(error.missing_variable_closing, res); +} + +test "render template with missing variable closing at the end" { + const tmpl = "Invalid template {{"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + const res = Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectError(error.missing_variable_closing, res); +} + +test "render template with incomplete variable closing" { + const tmpl = "Invalid {{foo} template"; + + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var rendered: std.io.Writer.Allocating = try .initCapacity(std.testing.allocator, tmpl.len); + defer rendered.deinit(); + + const res = Template.replace_vars(&rendered.writer, tmpl, &.{config}); + try std.testing.expectError(error.missing_variable_closing, res); +} + +test "replace variables in path" { + var config: conf.Config = .init(std.testing.allocator); + defer config.deinit(); + + try config.add_var("a", "foo"); + try config.add_var("b", "bar"); + + var replaced: fs.Path = undefined; + + replaced = try Template.replace_vars_in_path(.init("root"), ".config/foo", &.{config}); + try std.testing.expectEqualStrings("root/.config/foo", replaced.name()); + + replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc", &.{config}); + try std.testing.expectEqualStrings("/usr/local/etc", replaced.name()); + + replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{a}}.{{b}}", &.{config}); + try std.testing.expectEqualStrings("/usr/local/etc/foo.bar", replaced.name()); + + replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{/foo", &.{config}); + try std.testing.expectEqualStrings("/usr/local/etc/{{/foo", replaced.name()); + + replaced = try Template.replace_vars_in_path(.init("/usr/local"), "etc/{{}}/foo", &.{config}); + try std.testing.expectEqualStrings("/usr/local/etc/{{}}/foo", replaced.name()); +} |