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 { const max_archives = 16; pub const Archive = struct { /// The id of the distribution file. /// Default: empty (no distname suffix) id: []const u8, /// The path that should be the root of the archive (relative to the repository's root). root: ?[]const u8 = null, /// A list of paths that should be included in the archive. /// All paths are relative to the root's root. /// Default: empty (everything is included) paths: [64][]const u8 = undefined, paths_len: usize = 0, }; raw: conf.Config, prepare_target: ?[]const u8, archives: [max_archives]Archive, archives_len: usize, fn from_file(env: *const Env, tmpl: Template) !Config { const config_file = tmpl.config_file(); const conf_res = try conf.Config.from_file(env.allocator, &config_file); 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: *const Env, raw: conf.Config) Config { var dist_prepare_target: ?[]const u8 = null; var archives: [max_archives]Archive = undefined; var n_archives: usize = 0; var iter = raw.iterate(); while (iter.next()) |v| { if (std.mem.eql(u8, v.key, "dist.prepare-target")) { dist_prepare_target = if (v.val.len > 0) v.val else null; } else if (std.mem.startsWith(u8, v.key, "dist.archive")) { var archive_key = v.key[12..]; if (std.mem.findScalar(u8, archive_key, '.')) |dot| { var archive_id: []const u8 = undefined; if (dot == 0) { archive_id = ""; } else if (archive_key[0] != '[' or archive_key[dot - 1] != ']') { env.warn("unknown template configuration `{s}`", .{v.key}); continue; } else { archive_id = archive_key[1 .. dot - 1]; } archive_key = archive_key[dot + 1 ..]; var archive: ?*Archive = null; for (0..n_archives) |i| { if (std.mem.eql(u8, archives[i].id, archive_id)) { archive = &archives[i]; break; } } if (archive == null) { if (n_archives == max_archives) { fatal("maximum number of distribution files exceeded", .{}); } archive = &archives[n_archives]; archives[n_archives] = .{ .id = archive_id }; n_archives += 1; } if (std.mem.eql(u8, archive_key, "root")) { archive.?.root = if (v.val.len > 0) v.val else null; } else if (std.mem.eql(u8, archive_key, "paths")) { archive.?.paths_len = split_paths(&archive.?.paths, v.val); } else { env.warn("unknown template configuration `{s}`", .{v.key}); } } else { env.warn("unknown template configuration `{s}`", .{v.key}); } } else { env.warn("unknown template configuration `{s}`", .{v.key}); } } if (n_archives == 0) { archives[0] = .{ .id = "" }; n_archives = 1; } return .{ .raw = raw, .prepare_target = dist_prepare_target, .archives = archives, .archives_len = n_archives, }; } 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: *const 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: *const Env, tmpl_confs: []const conf.Config, target: *const fs.Path) !void { try render_files_recursively(env, target, &self.path, "", tmpl_confs); } fn render_files_recursively(env: *const Env, dest_root: *const fs.Path, src_root: *const fs.Path, src_subpath: []const u8, tmpl_confs: []const conf.Config) !void { const path: fs.Path = .join(.{ src_root.name(), src_subpath }); var iter = try fs.iterate(&path); defer iter.deinit(); while (try iter.next()) |entry| { const entry_subpath: fs.Path = .join(.{ src_subpath, entry.name }); const src_path: fs.Path = .join(.{ src_root.name(), entry_subpath.name() }); const dest_path = try replace_vars_in_path(dest_root, entry_subpath.name(), tmpl_confs); switch (entry.type) { .dir => { try fs.mkdir(&dest_path); try render_files_recursively(env, dest_root, src_root, entry_subpath.name(), tmpl_confs); }, .file => { env.info("writing template file {s}", .{dest_path.name()[dest_root.len + 1 ..]}); const input = try fs.read_file(env.allocator, &src_path); var output_buf: [512]u8 = undefined; var output_writer = try fs.file_writer(&dest_path, &output_buf, .{ .create = true }); defer output_writer.deinit(); const output = &output_writer.interface; try replace_vars(output, input, tmpl_confs); try output.flush(); }, else => {}, // skip } } } /// 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.findScalar(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.findScalarPos(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: *const 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.findScalar(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); res_writer.writeAll(part) catch unreachable; }, error.WriteFailed => return error.PathTooLong, else => unreachable, }; name = name[idx + 1 ..]; } res_writer.flush() catch unreachable; 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, iter: ?fs.Iterator, pub fn deinit(self: *Iterator) void { if (self.iter) |*iter| iter.deinit(); } pub fn next(self: *Iterator) !?Template { if (self.iter) |*iter| { while (try iter.next()) |entry| { if (entry.type == .dir) { return .{ .name = entry.name, .path = .join(.{ self.path.name(), entry.name }), }; } } } return null; } }; pub inline fn iterate(env: *const Env) !Iterator { var tmpl_iter: Iterator = .{ .path = etc_dir(env), .iter = undefined }; tmpl_iter.iter = fs.iterate(&tmpl_iter.path) catch |err| it: { if (err == error.FileNotFound) break :it null; return err; }; return tmpl_iter; } pub fn exists(env: *const Env, template_name: []const u8) !bool { return try get(env, template_name) != null; } pub fn get(env: *const 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: *const 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: *const Env, tmpl: Template) !Config { return Config.from_file(env, tmpl); } pub fn edit_config(env: *const Env, template_name: []const u8) !void { const tmpl = try must_get(env, template_name); const config_file = tmpl.config_file(); try shell.edit_config_file(env, &config_file, verify_config); } pub fn import_config(env: *const Env, template_name: []const u8, new_config_file: *const fs.Path) !void { const tmpl = try must_get(env, template_name); const parsed = try conf.Config.from_file(env.allocator, new_config_file); 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); } const config_file = tmpl.config_file(); try fs.mv_file(new_config_file, &config_file); } pub fn etc_dir(env: *const Env) fs.Path { return env.etc_path(.{"tmpl"}); } fn verify_config(env: *const Env, raw: conf.Config) bool { const tmpl_conf: Config = .init(env, raw); var valid = true; if (tmpl_conf.prepare_target) |prepare_target| { if (std.mem.findAny(u8, prepare_target, " \n\t\r")) |_| { cli.stderr.write_line("Error: invalid value `dist.prepare-target`", .{}); valid = false; } } for (tmpl_conf.archives[0..tmpl_conf.archives_len]) |archive| { if (archive.root) |root| { if (!fs.is_relative(root)) { if (archive.id.len > 0) { cli.stderr.write_line("Error: value `dist.archive[{s}].root` is not a relative path", .{archive.id}); } else { cli.stderr.write_line("Error: value `dist.archive.root` is not a relative path", .{}); } valid = false; } } for (archive.paths[0..archive.paths_len]) |path| { if (!fs.is_relative(path)) { if (archive.id.len > 0) { cli.stderr.write_line("Error: value `dist.archive[{s}].paths` contains a non-relative path", .{archive.id}); } else { cli.stderr.write_line("Error: value `dist.archive.paths` contains a non-relative path", .{}); } valid = false; } } } return valid; } fn split_paths(buf: [][]const u8, str: []const u8) usize { var path_count: usize = 0; var pos: usize = 0; while (pos < str.len) { pos = std.mem.findNonePos(u8, str, pos, " \t\r\n") orelse break; var space = pos; while (space < str.len) : (space += 1) { switch (str[space]) { ' ' => if (str[space - 1] != '\\') break, '\t', '\r', '\n' => break, else => {}, } } buf[path_count] = str[pos..space]; path_count += 1; pos = space + 1; } return path_count; } fn tmpl_corrupted(tmpl_name: []const u8, comptime reason: []const u8) noreturn { fatal("template {s} corrupted (" ++ reason ++ ")", .{tmpl_name}); } test "parse config" { var raw: conf.Config = .init(std.testing.allocator); try raw.add_var("dist.prepare-target", "prepare-everything"); try raw.add_var("dist.archive.root", "foo"); try raw.add_var("dist.archive.paths", "foo"); try raw.add_var("dist.archive[bar].root", "foo"); try raw.add_var("dist.archive[bar].paths", "bar\nbaz"); const env: Env = undefined; const config: Config = .init(&env, raw); defer config.deinit(); try std.testing.expectEqualStrings("prepare-everything", config.prepare_target.?); try std.testing.expectEqual(2, config.archives_len); try std.testing.expectEqualStrings("", config.archives[0].id); try std.testing.expectEqualStrings("foo", config.archives[0].root.?); try std.testing.expectEqual(1, config.archives[0].paths_len); try std.testing.expectEqualStrings("foo", config.archives[0].paths[0]); try std.testing.expectEqualStrings("bar", config.archives[1].id); try std.testing.expectEqualStrings("foo", config.archives[1].root.?); try std.testing.expectEqual(2, config.archives[1].paths_len); try std.testing.expectEqualStrings("bar", config.archives[1].paths[0]); try std.testing.expectEqualStrings("baz", config.archives[1].paths[1]); } 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; inline for ([_]struct { root: []const u8, subpath: []const u8, expected: []const u8, }{ .{ .root = "root", .subpath = ".config/foo", .expected = "root/.config/foo" }, .{ .root = "/usr/local", .subpath = "etc", .expected = "/usr/local/etc" }, .{ .root = "/usr/local", .subpath = "etc/{{a}}.{{b}}", .expected = "/usr/local/etc/foo.bar" }, .{ .root = "/usr/local", .subpath = "etc/{{/foo", .expected = "/usr/local/etc/{{/foo" }, .{ .root = "/usr/local", .subpath = "etc/{{}}/foo", .expected = "/usr/local/etc/{{}}/foo" }, }) |t| { const root: fs.Path = .init(t.root); replaced = try Template.replace_vars_in_path(&root, t.subpath, &.{config}); try std.testing.expectEqualStrings(t.expected, replaced.name()); } } test "split paths" { var paths: [8][]const u8 = undefined; var n: usize = undefined; n = split_paths(&paths, ""); try std.testing.expectEqual(0, n); n = split_paths(&paths, "dir"); try std.testing.expectEqual(1, n); try std.testing.expectEqualStrings("dir", paths[0]); n = split_paths(&paths, "dir/subdir"); try std.testing.expectEqual(1, n); try std.testing.expectEqualStrings("dir/subdir", paths[0]); n = split_paths(&paths, "dir/sub\\ dir"); try std.testing.expectEqual(1, n); try std.testing.expectEqualStrings("dir/sub\\ dir", paths[0]); n = split_paths(&paths, "dir_one/subdir dir_two"); try std.testing.expectEqual(2, n); try std.testing.expectEqualStrings("dir_one/subdir", paths[0]); try std.testing.expectEqualStrings("dir_two", paths[1]); n = split_paths(&paths, "dir_one/subdir dir_two\ndir_three/foo\\ bar"); try std.testing.expectEqual(3, n); try std.testing.expectEqualStrings("dir_one/subdir", paths[0]); try std.testing.expectEqualStrings("dir_two", paths[1]); try std.testing.expectEqualStrings("dir_three/foo\\ bar", paths[2]); }