aboutsummaryrefslogtreecommitdiff
path: root/src/template.zig
diff options
context:
space:
mode:
authortsne <tsne.dev@outlook.com>2025-01-01 14:58:28 +0100
committertsne <tsne.dev@outlook.com>2025-10-30 08:32:49 +0100
commit44e5ad763794a438ecfd50c8b7f6ea760ea82da5 (patch)
tree575a1fc72ceb1d7f052cf582abc1e038e27f69a3 /src/template.zig
downloadporteur-44e5ad763794a438ecfd50c8b7f6ea760ea82da5.tar.gz
initial commitHEADmain
Diffstat (limited to 'src/template.zig')
-rw-r--r--src/template.zig476
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());
+}