From 44e5ad763794a438ecfd50c8b7f6ea760ea82da5 Mon Sep 17 00:00:00 2001 From: tsne Date: Wed, 1 Jan 2025 14:58:28 +0100 Subject: initial commit --- src/cli.zig | 1162 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/conf.zig | 382 +++++++++++++++++ src/env.zig | 200 +++++++++ src/fs.zig | 269 ++++++++++++ src/git.zig | 179 ++++++++ src/main.zig | 1195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/port.zig | 846 ++++++++++++++++++++++++++++++++++++++ src/shell.zig | 191 +++++++++ src/template.zig | 476 ++++++++++++++++++++++ src/time.zig | 134 ++++++ src/tree.zig | 67 +++ 11 files changed, 5101 insertions(+) create mode 100644 src/cli.zig create mode 100644 src/conf.zig create mode 100644 src/env.zig create mode 100644 src/fs.zig create mode 100644 src/git.zig create mode 100644 src/main.zig create mode 100644 src/port.zig create mode 100644 src/shell.zig create mode 100644 src/template.zig create mode 100644 src/time.zig create mode 100644 src/tree.zig (limited to 'src') diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..8c5e37a --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,1162 @@ +//! The CLI is defined by a set of commands with options and subcommands. +//! A command is a type where with special declarations and fields. If a +//! command has options, it needs to define an `option` field, that is a +//! struct where all fields are valid options, i.e. created with `Option`. +//! If a command has any subcommands a `Subcommands` type can be declared, +//! which needs to be a tagged union that contains a command type for each +//! supported subcommand. To provide sufficient help for each command a +//! command type must define a `help` declaration of type `Command_Help`. +//! +//! The basic structure of a command type: +//! +//! struct { +//! pub const help = Command_Help{ ... }; +//! +//! pub const Subcommands = union(enum) { +//! another: Another_Command, +//! }; +//! +//! options: struct{ +//! one: Option(u16, .{ ... }), +//! two: Option([]const u8, .{ ... }), +//! }, +//! } +//! +//! With all the setup above it is possible to "generate" a command help +//! during compile time. There are two versions for a help text: a short +//! and a long version. The short version displays the usage pattern of +//! the command whereas the long version displays a detailed help text. + +const std = @import("std"); +const assert = std.debug.assert; + +var stdout_buf: [1024]u8 = undefined; +var stdout_printer: Printer = .init(std.fs.File.stdout().writer(&stdout_buf)); +var stderr_printer: Printer = .init(std.fs.File.stderr().writer(&.{})); + +pub var stdout = &stdout_printer; +pub var stderr = &stderr_printer; + +pub const Command_Help = struct { + pub const Section = struct { + /// The heading of the section. This must be a single line with + /// where each letter is uppercase. + heading: []const u8, + /// The text that should be displayed in the body of the section. + /// + /// Note: Single newlines will be replaced by spaces to format the + /// help text according to the terminal width. Multiple consecutive + /// newlines will be kept to keep paragraphs. If a line is indented + /// it will be printed as separate line. This can be used to display + /// lists or code, for example. + body: []const u8, + }; + + // The fully qualified command name, which includes the executable + // name and all the subcommands. All parts of the fully qualified + // name must be divided by a single space only. + // + // Note: The last part is used to determining the subcommand during + // argument parsing. + name: []const u8, + /// A synopsis of how to invoke the command. For each usage of the + /// command a separate invocation can be provided. It should give + /// a brief overview of how the command can be used. + /// An invocation pattern should be formatted in manpage style + /// (e.g. "program mycommand [-f|--foo]"). + /// + /// Note: For better formatting a single pattern can be split into + /// multiple lines. + synopsis: []const []const u8, + /// A short description of what the command does. In the long help + /// it is displayed directly after the command name. In the short + /// help it is used to give a brief description of the command. + /// + /// Note: This must be a single line of text. + short_description: []const u8, + /// A detailed description of the functioning of the command. This + /// text is displayed in the long help. It should explain all the + /// details of the command. + /// + /// Note: Single newlines will be replaced by spaces to format the + /// help text according to the terminal width. Multiple consecutive + /// newlines will be kept to keep paragraphs. If a line is indented + /// it will be printed as separate line. This can be used to display + /// lists or code, for example. + long_description: []const u8, + /// Extra sections that should be displayed at the bottom of the + /// command help. + extra_sections: []const Section = &.{}, +}; + +pub fn Option_Help(comptime T: type) type { + return struct { + /// The long name of an option without the leading double-dash. + /// + /// Note: Only alphanumeric characters or dashes are allowed, but + /// it must not start or end with a dash. + name: []const u8, + /// An option can have an optional short name which can be used as an + /// alternative to the long name. A short name is a single character. + /// + /// Note: A short name must be alphanumeric. + short_name: ?u8 = null, + /// Every non-boolean option takes an argument as its value. To improve + /// help message quality an argument must have a meaningful name that is + /// displayed in the help text of the corresponding command. + /// For boolean option on the other hand this field defaults to null and + /// will be ignored when printing a help text. + /// + /// Note: This must be a single word, i.e. only characters, numbers, + /// dashes, and underscores are allowed. + argument_name: ?[]const u8 = null, + /// A short description of what the option does. This text is displayed + /// in the short help of the corresponding command. It should give a brief + /// overview of what the option does. + /// + /// Note: This must be a single line of text. + short_description: []const u8, + /// A detailed description of the command option. This text is displayed + /// in the long help of the corresponding command. It should explain all + /// details of the option and can be as long as needed. + /// + /// Note: Single newlines will be replaced by spaces to format the + /// help text according to the terminal width. Multiple consecutive + /// newlines will be kept to keep paragraphs. + long_description: []const u8, + /// Default value that will be set if the user does not provide a specific + /// value. If this field is null the option value will also be null. + default_value: ?T = null, + }; +} + +pub fn Option(comptime T: type, comptime opt_help: Option_Help(T)) type { + comptime { + const has_argname = opt_help.argument_name != null and opt_help.argument_name.?.len > 0; + if (!has_argname and T != bool) { + @compileError("non-boolean options must have an argument name"); + } + + switch (@typeInfo(T)) { + .@"enum" => |info| { + for (info.fields) |field| { + for (field.name) |ch| { + switch (ch) { + 'a'...'z', 'A'...'Z', '0'...'9' => {}, + else => @compileError("invalid enum '" ++ field.name ++ "' value in option " ++ opt_help.name), + } + } + } + }, + else => {}, + } + } + + return struct { + pub const Type = T; + pub const help: Option_Help(T) = opt_help; + + const Self = @This(); + + value: ?T, + + fn init(self: *Self) void { + self.value = null; + } + + pub fn value_or_default(self: Self) T { + if (opt_help.default_value == null) { + @compileError("option '" ++ opt_help.name ++ "' does not have default value"); + } + return self.value orelse opt_help.default_value.?; + } + + fn parse(self: *Self, name: []const u8, value: [:0]const u8) void { + if (self.value != null) { + fatal("duplicate option '{s}'", .{name}); + } + + self.value = switch (@typeInfo(T)) { + .bool => val: { + if (value.len != 0) { + fatal("boolean option '{s}' does not take a value", .{name}); + } + break :val true; + }, + .int => val: { + if (value.len == 0) { + fatal("missing value for option '{s}'", .{name}); + } + break :val std.fmt.parseInt(T, value, 10) catch |err| switch (err) { + error.InvalidCharacter => fatal("option '{s}' requires a numeric value", .{name}), + error.Overflow => fatal("option '{s}' exceeds the maximum numeric value", .{name}), + }; + }, + .@"enum" => |info| val: { + if (info.fields.len == 0) { + @compileError("enum cli option " ++ @typeName(T) ++ " does not have any values"); + } + if (value.len == 0) { + fatal("missing value for option '{s}'", .{name}); + } + break :val std.meta.stringToEnum(T, value) orelse { + fatal("invalid value for option '{s}' (use " ++ join_enum_values(T) ++ ")", .{name}); + }; + }, + .pointer => |info| val: { + if (info.child != u8 and info.size != .Slice) { + @compileError("pointer cli option " ++ @typeName(T) ++ " not supported"); + } + break :val value; + }, + else => @compileError("cli option type " ++ @typeName(T) ++ " not supported"), + }; + } + }; +} + +pub const Args = struct { + /// The command we are currently in or null if we reached the end or positional arguments. + current_cmd: ?[]const u8, + iter: std.process.ArgIterator, + exe_name: []const u8, + + pub fn init(allocator: std.mem.Allocator, comptime exe_name: []const u8, program: anytype) !Args { + comptime assert(exe_name.len > 0); + + var iter = try std.process.ArgIterator.initWithAllocator(allocator); + errdefer iter.deinit(); + _ = iter.skip(); // skip executable name + const cmd_info = Command_Info.from_command(switch (@typeInfo(@TypeOf(program))) { + .pointer => |info| info.child, + else => @compileError("expected pointer, got " ++ @typeName(@TypeOf(program))), + }); + const current_cmd = parse_opts(&iter, cmd_info, program); + return Args{ + .current_cmd = current_cmd, + .iter = iter, + .exe_name = exe_name, + }; + } + + pub fn deinit(self: *Args, allocator: std.mem.Allocator) void { + _ = allocator; + self.iter.deinit(); + } + + pub fn next_subcommand(self: *Args, comptime Command: type) ?Command.Subcommands { + const subcommands = comptime Command_Info.from_subcommand(Command); + comptime assert(subcommands.len > 0); + + const arg = self.current_cmd orelse return null; + inline for (subcommands) |subcmd| { + if (std.mem.eql(u8, arg, subcmd.name)) { + var cmd: subcmd.type = undefined; + self.current_cmd = parse_opts(&self.iter, subcmd, &cmd); + return @unionInit(Command.Subcommands, subcmd.tag.?, cmd); + } + } + fatal_with_usage(Command, "unknown command '{s}' (see '{s} --help')", .{ arg, self.exe_name }); + } + + pub fn next_positional(self: *Args) ?[]const u8 { + if (self.current_cmd) |cmd| { + self.current_cmd = null; + return cmd; + } + self.current_cmd = null; + return self.iter.next(); + } + + /// Parse and fill all command options and return the current command. + fn parse_opts(iter: *std.process.ArgIterator, comptime cmd_info: Command_Info, cmd: *cmd_info.type) ?[]const u8 { + inline for (cmd_info.options) |opt| { + @field(cmd.options, opt.field).init(); + } + + args_loop: while (iter.next()) |arg| { + assert(arg.len > 0); + if (arg.len < 2 or arg[0] != '-') { + // We have either a single dash or a non-option here. Treat these as + // a positional or subcommand (however the executor chooses to use it). + return arg; + } + + var opt_name: []const u8 = undefined; + if (arg[1] != '-') { + // We check for a short option name. + // If we have a boolean flag or an option name longer than a + // single character, we have both the name and the value in + // a single argument. Otherwise we fetch the next argument. + opt_name = arg[1..2]; + if (opt_name[0] == 'h') { + if (arg.len > 2) { + fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{arg}); + } + cmd_info.print_usage(stderr); + std.process.exit(0); + } + + inline for (cmd_info.options) |opt| { + if (opt.short_name) |short_name| { + if (short_name == opt_name[0]) { + const value: [:0]const u8 = if (arg.len > 2 or opt.arg_name.len == 0) arg[2..] else next: { + const next_arg = iter.next(); + if (next_arg == null or next_arg.?[0] == '-') { + fatal("missing value for option '{c}'", .{opt_name[0]}); + } + break :next next_arg.?; + }; + @field(cmd.options, opt.field).parse(opt_name, value); + continue :args_loop; + } + } + } + } else if (arg.len > 2) { + // We check for a long option name. + // If we don't have the format `name=value`, we get the + // next argument lazily, because for boolean flags we + // don't need any extra argument. + opt_name = arg[2..]; + var value: ?[:0]const u8 = null; + if (std.mem.indexOfScalar(u8, arg, '=')) |eq| { + opt_name = arg[2..eq]; + value = arg[eq + 1 ..]; + } + + if (std.mem.eql(u8, opt_name, "help")) { + cmd_info.print_help(); + } + + inline for (cmd_info.options) |opt| { + if (std.mem.eql(u8, opt_name, opt.long_name)) { + value = value orelse if (opt.arg_name.len == 0) "" else next_arg: { + const next = iter.next(); + if (next == null or next.?[0] == '-') { + fatal("missing value for option '{s}'", .{opt_name}); + } + break :next_arg next.?; + }; + @field(cmd.options, opt.field).parse(opt_name, value.?); + continue :args_loop; + } + } + } else { + // A single "--" was detected which means the rest of the arguments + // are interpreted as positional. We are done parsing. + return null; + } + + fatal_with_usage(cmd_info.type, "unknown option '{s}'", .{opt_name}); + } + + return null; + } +}; + +pub const Print_Options = struct { + /// Indentation of the first line. + margin: u16 = 0, + /// Indentation of all lines except the first. + hanging_indent: u16 = 0, + /// The character by which the text should be split into multiple lines. + line_split: u8 = ' ', +}; + +const Usage_Column = struct { + cells: []const []const u8, + width: u16, +}; + +pub const Printer = struct { + writer: std.fs.File.Writer, + max_width: u16, + + fn init(w: std.fs.File.Writer) Printer { + return Printer{ + .writer = w, + .max_width = 80, + }; + } + + /// Drain the remaining buffered data and puts it to the sink. + pub fn flush(self: *Printer) void { + self.writer.interface.flush() catch {}; + } + + /// Write a value directly without splitting. This uses the formatting capabilities + /// of `std.fmt`. + pub fn write(self: *Printer, comptime fmt: []const u8, value: anytype) void { + self.writer.interface.print(fmt, value) catch {}; + } + + /// Write a value directly without splitting and append a newline. This uses the + /// formatting capabilities of `std.fmt`. + pub fn write_line(self: *Printer, comptime fmt: []const u8, value: anytype) void { + self.write(fmt ++ "\n", value); + } + + /// Print the given text and split it into multiple lines if it exceeds + /// a certain width. The splitting behaviour can be configured using the + /// given print options. + pub fn print(self: *Printer, text: []const u8, opts: Print_Options) void { + if (text.len > 0) { + const max_len_first = self.max_width - opts.margin; + const max_len_others = self.max_width - opts.margin - opts.hanging_indent; + + self.put_indent(opts.margin); + var rest = self.put_until_eol(text, max_len_first, opts.line_split); + while (rest.len > 0) { + self.put('\n'); + self.put_indent(opts.margin + opts.hanging_indent); + rest = self.put_until_eol(rest, max_len_others, opts.line_split); + } + } + } + + /// Same as `print`, but appends a newline at the end. + pub fn print_line(self: *Printer, text: []const u8, opts: Print_Options) void { + self.print(text, opts); + self.put('\n'); + } + + /// Print a formatted text and split it into multiple lines if if exceeds + /// a certain width. The splitting behaviour can be configured using the + /// given print options. + pub fn printf(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void { + if (@typeInfo(@TypeOf(args)).@"struct".fields.len == 0) { + self.print(format, opts); + } else { + // TODO: Shall we estimate the buffer size depending on the + // format string and arguments? + var buf: [2048]u8 = undefined; + const text = std.fmt.bufPrint(&buf, format, args) catch |err| switch (err) { + error.NoSpaceLeft => dots: { + buf[buf.len - 3] = '.'; + buf[buf.len - 2] = '.'; + buf[buf.len - 1] = '.'; + break :dots buf[0..]; + }, + }; + self.print(text, opts); + } + } + + /// Same as `printf`, but appends a newline at the end. + pub fn printf_line(self: *Printer, comptime format: []const u8, args: anytype, opts: Print_Options) void { + self.printf(format, args, opts); + self.put('\n'); + } + + /// Print multiple lines of text. This essentially calls `print_line` for each + /// given line of text. + pub fn print_lines(self: *Printer, text: []const u8, opts: Print_Options) void { + var rest = text; + while (std.mem.indexOfScalar(u8, rest, '\n')) |eol| { + self.print_line(rest[0..eol], opts); + rest = rest[eol + 1 ..]; + } + + if (rest.len > 0) { + self.print_line(rest, opts); + } + } + + fn put(self: *Printer, c: u8) void { + self.writer.interface.writeByte(c) catch {}; + } + + fn put_str(self: *Printer, text: []const u8) void { + self.writer.interface.writeAll(text) catch {}; + } + + fn print_usage_table(self: *Printer, comptime left_column: Usage_Column, comptime right_column: Usage_Column, opts: Print_Options) void { + assert(opts.hanging_indent > 0); + assert(left_column.cells.len == right_column.cells.len); + + const sep_len = 2; + const max_len: usize = @intCast(@min(left_column.width + sep_len + right_column.width, self.max_width - opts.margin)); + + if (left_column.width + sep_len > 2 * max_len / 3) { + // There is very little space for the right column. So we write each column + // to a separate line. + const left_margin = opts.margin; + const right_margin = opts.margin + opts.hanging_indent; + for (left_column.cells, right_column.cells) |left, right| { + self.print_line(left, .{ .margin = left_margin }); + self.print_line(right, .{ .margin = right_margin }); + } + } else { + // The left and the right column are on the same line. If the right column + // gets too long, we print multiple lines for the right column. + for (left_column.cells, right_column.cells) |left, right| { + assert(left.len <= left_column.width); + assert(right.len <= right_column.width); + + self.put_indent(opts.margin); + self.put_str(left); + self.put_indent(left_column.width - left.len + sep_len); + + const right_width = max_len - left_column.width - sep_len; + var rest = self.put_until_eol(right, right_width, opts.line_split); + while (rest.len > 0) { + self.put('\n'); + self.put_indent(opts.margin + left_column.width + sep_len); + rest = self.put_until_eol(right, right_width, opts.line_split); + } + self.put('\n'); + } + } + } + + fn put_until_eol(self: *Printer, text: []const u8, line_len: usize, line_split: u8) []const u8 { + const width = @min(text.len, line_len); + var eol: usize = undefined; + if (text.len <= width) { + eol = text.len; + } else if (text[width] == line_split) { + eol = width; + } else if (std.mem.lastIndexOfScalar(u8, text[0..width], line_split)) |last_split| { + eol = last_split; + if (std.mem.allEqual(u8, text[0..last_split], line_split)) { + // There are leading line splits here, so we consider an indentation. + // We want to keep indentation intact and print to the next split. + eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len; + } + } else { + // We don't have a split char in the line. So we extend the line + // to the next space. This hopefully will not happen in the wild. + eol = std.mem.indexOfScalarPos(u8, text, width, line_split) orelse text.len; + } + + self.put_str(text[0..eol]); + return if (eol < text.len) text[eol + 1 ..] else text[0..0]; + } + + fn put_indent(self: *Printer, n: usize) void { + const buf = [_]u8{' '} ** 32; + var remaining = n; + while (remaining > buf.len) : (remaining -= buf.len) { + self.put_str(&buf); + } + self.put_str(buf[0..remaining]); + } +}; + +pub fn fatal(comptime format: []const u8, args: anytype) noreturn { + stderr.printf_line(format, args, .{}); + stderr.flush(); + std.process.exit(1); +} + +pub fn fatal_with_usage(comptime Command: type, comptime format: []const u8, args: anytype) noreturn { + const info = Command_Info.from_command(Command); + stderr.printf_line(format, args, .{}); + stderr.put('\n'); + info.print_usage(stderr); + std.process.exit(1); +} + +/// Print the usage of the given command. +/// This function automatically flushes. +pub fn print_usage(p: *Printer, comptime Command: type) void { + Command_Info.from_command(Command).print_usage(p); + p.flush(); +} + +/// Print the help text of the given command. +pub fn print_help(comptime Command: type) noreturn { + Command_Info.from_command(Command).print_help(); +} + +pub fn ask(comptime prompt: []const u8, args: anytype, buf: []u8) ?[]const u8 { + var stdin = std.fs.File.stdin().reader(buf); + while (true) { + stderr.printf(prompt, args, .{}); + stderr.flush(); + const line = stdin.interface.takeDelimiterExclusive('\n') catch |err| switch (err) { + error.StreamTooLong => { + stderr.printf_line("ERROR: The input exceeds the maximum of {} characters.", .{buf.len}, .{}); + continue; + }, + else => unreachable, + }; + + const input = std.mem.trim(u8, line, " \t\r"); + return if (input.len == 0) null else input; + } +} + +pub fn ask_confirmation(comptime prompt: []const u8, args: anytype, comptime default_answer: enum { yes, no }) bool { + comptime assert(prompt.len > 0 and prompt[prompt.len - 1] == '?'); + const yes = "yes"; + const no = "no"; + const default_ans = switch (default_answer) { + .yes => yes, + .no => no, + }; + + var confirmbuf: [16]u8 = undefined; + return while (true) { + const ans = ask(prompt ++ " (" ++ yes ++ "/" ++ no ++ ") [" ++ default_ans ++ "]: ", args, &confirmbuf) orelse default_ans; + for (ans, 0..) |c, i| { + confirmbuf[i] = std.ascii.toLower(c); + } + if (std.mem.eql(u8, ans, yes[0..1]) or std.mem.eql(u8, ans, yes)) break true; + if (std.mem.eql(u8, ans, no[0..1]) or std.mem.eql(u8, ans, no)) break false; + }; +} + +const Pager = struct { + const Bold = struct { + text: []const u8, + dumb: bool, + + pub fn format(self: Bold, w: *std.io.Writer) std.io.Writer.Error!void { + if (!self.dumb) try w.writeAll("\x1b[1m"); + try w.writeAll(self.text); + if (!self.dumb) try w.writeAll("\x1b[m"); + } + }; + + pid: ?std.posix.pid_t, + printer: Printer, + dumb: bool, + + fn init(out_buf: []u8) Pager { + if (!stdout.writer.file.isTty()) { + return .{ .pid = null, .printer = stdout_printer, .dumb = true }; + } + + const fds = std.posix.pipe() catch return .{ .pid = null, .printer = stdout_printer, .dumb = true }; + const fdin = fds[0]; + const fdout = fds[1]; + + const pid = std.posix.fork() catch { + std.posix.close(fdin); + std.posix.close(fdout); + return .{ .pid = null, .printer = stdout_printer, .dumb = true }; + }; + + if (pid == 0) { + // pager process + std.posix.close(fdout); + std.posix.dup2(fdin, std.posix.STDIN_FILENO) catch unreachable; + + var env: [512:null]?[*:0]const u8 = undefined; + var envlen: usize = 0; + const environ = if (std.os.environ.len < 512) std.os.environ else std.os.environ[0..511]; + for (environ) |e| { + env[envlen] = e; + envlen += 1; + } + env[envlen] = null; + + // TODO: Allow environment PAGER to define custom pagers. + const pager_argv: [*:null]const ?[*:0]const u8 = &.{ "less", "-R", null }; + + std.posix.execvpeZ(pager_argv[0].?, pager_argv, &env) catch unreachable; + std.process.exit(1); + } else { + // application process + std.posix.close(fdin); + var out: std.fs.File = .{ .handle = fdout }; + return .{ + .pid = pid, + .printer = Printer.init(out.writer(out_buf)), + .dumb = if (std.posix.getenv("TERM")) |term| std.mem.eql(u8, term, "dumb") else true, + }; + } + } + + fn deinit(self: *Pager) noreturn { + self.printer.flush(); + if (self.pid) |pid| { + self.printer.writer.file.close(); + _ = std.posix.waitpid(pid, 0); + } + std.process.exit(0); + } + + fn bold(self: *Pager, text: []const u8) Bold { + return .{ .text = text, .dumb = self.dumb }; + } +}; + +/// Information about a single command. +const Command_Info = struct { + type: type, + name: []const u8, // last part of the fully qualified name + fq_name: []const u8, // fully qualified name separated by spaces + synopsis: []const []const u8, + short_description: []const u8, + long_description: []const u8, + options: []const Option_Info, + tag: ?[]const u8, // The tag in the subcommands union. + sections: []const Command_Help.Section, + + fn from_command(comptime Command: type) Command_Info { + const help: Command_Help = comptime hlp: { + if (!@hasDecl(Command, "help")) { + @compileError("missing help declaration in command " ++ @typeName(Command)); + } + if (@TypeOf(Command.help) != Command_Help) { + @compileError("invalid help type in command " ++ @typeName(Command)); + } + break :hlp Command.help; + }; + + const fq_name: []const u8 = comptime fqn: { + if (help.name.len == 0) { + @compileError("missing command name for " ++ @typeName(Command)); + } + + var first_non_space: usize = 0; + while (first_non_space < help.name.len and help.name[first_non_space] == ' ') { + first_non_space += 1; + } + + var last_non_space = first_non_space; + if (last_non_space < help.name.len) { + var i = last_non_space; + while (i < help.name.len) : (i += 1) { + switch (help.name[i]) { + ' ' => {}, + 'a'...'z', '0'...'9', '-' => last_non_space = i, + else => @compileError("invalid command name for " ++ @typeName(Command)), + } + } + } + + if (last_non_space < help.name.len) last_non_space += 1; + break :fqn help.name[0..last_non_space]; + }; + + const name: []const u8 = comptime nm: { + if (std.mem.lastIndexOfScalar(u8, fq_name, ' ')) |space| { + break :nm fq_name[space + 1 ..]; + } else { + break :nm fq_name; + } + }; + + const synopsis: []const []const u8 = comptime usage: { + if (help.synopsis.len == 0) { + @compileError("missing synopsis in command " ++ @typeName(Command)); + } + for (help.synopsis) |usage| { + if (usage.len == 0) { + @compileError("empty usage pattern in command " ++ @typeName(Command)); + } + } + break :usage help.synopsis; + }; + + const short_description: []const u8 = comptime descr: { + if (help.short_description.len == 0) { + @compileError("missing short help description in command " ++ @typeName(Command)); + } + if (contains_eol(help.short_description)) { + @compileError("short help description in command " ++ @typeName(Command) ++ " must be a single line"); + } + break :descr sanitize_description(help.short_description); + }; + + const long_description: []const u8 = comptime descr: { + if (help.long_description.len == 0) { + @compileError("missing long help description in command " ++ @typeName(Command)); + } + break :descr sanitize_description(help.long_description); + }; + + const options: []const Option_Info = comptime opts: { + for (std.meta.fields(Command)) |field| { + if (std.mem.eql(u8, field.name, "options")) { + break :opts Option_Info.from_options_field(Command, field); + } + } + break :opts &.{}; + }; + + const sections: [help.extra_sections.len]Command_Help.Section = comptime secs: { + var extra_sections: [help.extra_sections.len]Command_Help.Section = undefined; + for (help.extra_sections, 0..) |sec, i| { + if (contains_eol(sec.heading)) { + @compileError("help section heading in command " ++ @typeName(Command) ++ " must be a single line"); + } + if (!is_uppercase(sec.heading)) { + @compileError("help section heading in command " ++ @typeName(Command) ++ " must all be uppercase"); + } + extra_sections[i] = .{ + .heading = sec.heading, + .body = sanitize_description(sec.body), + }; + } + break :secs extra_sections; + }; + + return Command_Info{ + .type = Command, + .name = name, + .fq_name = fq_name, + .synopsis = synopsis, + .short_description = short_description, + .long_description = long_description, + .options = options, + .tag = null, + .sections = §ions, + }; + } + + fn from_subcommand(comptime Command: type) []Command_Info { + if (!@hasDecl(Command, "Subcommands")) { + return &.{}; + } + + const union_info = switch (@typeInfo(Command.Subcommands)) { + .@"union" => |u| u, + else => @compileError(@typeName(Command.Subcommands) ++ " is not a union"), + }; + assert(union_info.tag_type != null); + + var subcmds: [union_info.fields.len]Command_Info = undefined; + for (union_info.fields, 0..) |field, i| { + subcmds[i] = Command_Info.from_command(field.type); + subcmds[i].tag = field.name; + } + return &subcmds; + } + + /// Flushes the printer + fn print_usage(comptime cmd: Command_Info, p: *Printer) void { + const margin0 = 0; + const margin1 = 4; + const margin2 = 8; + + const Usage_Table = struct { + fn column(comptime Info: type, comptime infos: []const Info, comptime extract: fn (comptime Info) []const u8) Usage_Column { + const cells = comptime blk: { + var c: [infos.len][]const u8 = undefined; + for (infos, 0..) |info, i| { + c[i] = extract(info); + } + break :blk c; + }; + const width = comptime blk: { + var w: usize = 0; + for (infos, 0..) |_, i| { + w = @max(w, cells[i].len); + } + break :blk w; + }; + + return .{ .cells = &cells, .width = width }; + } + }; + + // usage + comptime assert(cmd.synopsis.len > 0); + p.print_line("Usage:", .{ .margin = margin0 }); + inline for (cmd.synopsis) |usage| { + p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' }); + } + p.put('\n'); + + // subcommands + const subcmds = Command_Info.from_subcommand(cmd.type); + if (subcmds.len > 0) { + const name_col = comptime Usage_Table.column(Command_Info, subcmds, struct { + fn extract(comptime subcmd: Command_Info) []const u8 { + return subcmd.name; + } + }.extract); + + const descr_col = comptime Usage_Table.column(Command_Info, subcmds, struct { + fn extract(comptime subcmd: Command_Info) []const u8 { + return subcmd.short_description; + } + }.extract); + + p.print_line("Commands:", .{ .margin = margin0 }); + p.print_usage_table(name_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 }); + p.put('\n'); + } + + // options + if (cmd.options.len > 0) { + const title_col = comptime Usage_Table.column(Option_Info, cmd.options, struct { + fn extract(comptime opt: Option_Info) []const u8 { + const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">"; + return if (opt.short_name) |short| + std.fmt.comptimePrint("-{c}, --{s}{s}", .{ short, opt.long_name, argname }) + else + std.fmt.comptimePrint(" --{s}{s}", .{ opt.long_name, argname }); + } + }.extract); + + const descr_col = comptime Usage_Table.column(Option_Info, cmd.options, struct { + fn extract(comptime opt: Option_Info) []const u8 { + return opt.short_description; + } + }.extract); + + p.print_line("Options:", .{ .margin = margin0 }); + p.print_usage_table(title_col, descr_col, .{ .margin = margin1, .hanging_indent = margin2 }); + p.put('\n'); + } + p.flush(); + } + + fn print_help(comptime cmd: Command_Info) noreturn { + var out_buf: [1024]u8 = undefined; + var pager = Pager.init(&out_buf); + defer pager.deinit(); + + var p = &pager.printer; + const margin0 = 0; + const margin1 = 4; + const margin2 = 8; + + p.put('\n'); + + // name + p.printf_line("{f}", .{pager.bold("NAME")}, .{ .margin = margin0 }); + p.print_line(cmd.fq_name ++ " - " ++ cmd.short_description, .{ .margin = margin1 }); + p.put('\n'); + + // synopsis + p.printf_line("{f}", .{pager.bold("SYNOPSIS")}, .{ .margin = margin0 }); + inline for (cmd.synopsis) |usage| { + p.print_line(usage, .{ .margin = margin1, .hanging_indent = margin2, .line_split = '\n' }); + } + p.put('\n'); + + // description + p.printf_line("{f}", .{pager.bold("DESCRIPTION")}, .{ .margin = margin0 }); + p.print_lines(cmd.long_description, .{ .margin = margin1 }); + p.put('\n'); + + // options + if (cmd.options.len > 0) { + p.printf_line("{f}", .{pager.bold("OPTIONS")}, .{ .margin = margin0 }); + inline for (cmd.options) |opt| { + const argname = if (opt.arg_name.len == 0) "" else " <" ++ opt.arg_name ++ ">"; + if (opt.short_name) |short| { + p.printf_line("-{f}, --{f}{s}", .{ pager.bold(&[1]u8{short}), pager.bold(opt.long_name), argname }, .{ .margin = margin1 }); + } else { + p.printf_line("--{f}{s}", .{ pager.bold(opt.long_name), argname }, .{ .margin = margin1 }); + } + p.print_lines(opt.long_description, .{ .margin = margin2 }); + p.put('\n'); + } + } + + // subcommands + const subcmds = Command_Info.from_subcommand(cmd.type); + if (subcmds.len > 0) { + p.printf_line("{f}", .{pager.bold("COMMANDS")}, .{ .margin = margin0 }); + inline for (subcmds) |subcmd| { + p.printf_line("{f}", .{pager.bold(subcmd.name)}, .{ .margin = margin1 }); + p.print_lines(subcmd.short_description, .{ .margin = margin2 }); + p.put('\n'); + } + } + + // sections + inline for (cmd.sections) |section| { + p.printf_line("{f}", .{pager.bold(section.heading)}, .{ .margin = margin0 }); + p.print_lines(section.body, .{ .margin = margin1 }); + p.put('\n'); + } + } +}; + +/// Information about a single option. An option must contain a public declaration `help` +/// of type `Option_Help(T)`. +const Option_Info = struct { + field: []const u8, + short_name: ?u8, + long_name: []const u8, + arg_name: []const u8, // empty for boolean options + short_description: []const u8, + long_description: []const u8, + + fn from_options_field(comptime Command: type, comptime options_field: std.builtin.Type.StructField) []const Option_Info { + const options = comptime blk: { + const struct_info = switch (@typeInfo(options_field.type)) { + .@"struct" => |s| s, + else => @compileError("options for command " ++ @typeName(Command) ++ " must be a struct"), + }; + + var opts: [struct_info.fields.len]Option_Info = undefined; + for (struct_info.fields, 0..) |field, i| { + const Opt = field.type; + const fq_fieldname = @typeName(Command) ++ "." ++ field.name; + if (!@hasDecl(Opt, "help")) { + @compileError("missing help declaration in option " ++ fq_fieldname); + } + + const help: Option_Help(Opt.Type) = hlp: { + if (@TypeOf(Opt.help) != Option_Help(Opt.Type)) { + @compileError("invalid help type in option " ++ fq_fieldname ++ ": " ++ @typeName(@TypeOf(Opt.help)) ++ " (expected " ++ @typeName(Option_Help(Opt.Type)) ++ ")"); + } + break :hlp Opt.help; + }; + + const short_name: ?u8 = name: { + if (help.short_name) |ch| { + switch (ch) { + 'a'...'z', 'A'...'Z', '0'...'9' => {}, + else => @compileError("invalid short name for option " ++ fq_fieldname), + } + } + + break :name help.short_name; + }; + + const long_name: []const u8 = name: { + if (std.mem.eql(u8, help.name, "help")) { + @compileError("option name 'help' is a reserved option name"); + } + if (help.name.len == 0) { + @compileError("missing option name " ++ fq_fieldname); + } + if (contains_whitespace(help.name)) { + @compileError("invalid option name " ++ fq_fieldname); + } + break :name sanitize_name(help.name); + }; + + const arg_name: []const u8 = name: { + const arg = help.argument_name orelse break :name ""; + if (contains_whitespace(arg)) { + @compileError("invalid argument name of option " ++ fq_fieldname); + } + break :name arg; + }; + + const short_description: []const u8 = descr: { + if (help.short_description.len == 0) { + @compileError("missing short help description in option " ++ fq_fieldname); + } + if (contains_eol(help.short_description)) { + @compileError("short help description in option " ++ fq_fieldname ++ " must be a single line"); + } + break :descr help.short_description; + }; + + const long_description: []const u8 = descr: { + if (help.long_description.len == 0) { + @compileError("missing long help description in option " ++ fq_fieldname); + } + break :descr sanitize_description(help.long_description); + }; + + opts[i] = Option_Info{ + .field = field.name, + .short_name = short_name, + .long_name = long_name, + .arg_name = arg_name, + .short_description = short_description, + .long_description = long_description, + }; + } + break :blk opts; + }; + return &options; + } + + fn contains_whitespace(comptime text: []const u8) bool { + for (text) |c| { + switch (c) { + ' ', '\t', '\r', '\n' => return true, + else => {}, + } + } + return false; + } +}; + +fn sanitize_name(comptime name: []const u8) []const u8 { + const sanitized = comptime blk: { + var res: [name.len]u8 = undefined; + for (name, 0..) |c, i| { + res[i] = switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9' => c, + '_', '-' => '-', + else => @compileError("invalid character in cli name '" ++ name ++ "'"), + }; + } + break :blk res; + }; + return &sanitized; +} + +fn sanitize_description(comptime text: []const u8) []const u8 { + @setEvalBranchQuota(100_000); + + const sanitized = comptime blk: { + var res: [text.len]u8 = undefined; + var prev_was_eol: bool = false; + for (text, 0..) |c, i| { + var ch = c; + switch (ch) { + '\n' => { + if (prev_was_eol) { + res[i - 1] = '\n'; // preserve empty line + } else { + ch = ' '; + } + prev_was_eol = true; + }, + '\t', ' ' => { + if (prev_was_eol) { + res[i - 1] = '\n'; + } else { + ch = ' '; + } + prev_was_eol = false; + }, + else => { + prev_was_eol = false; + }, + } + res[i] = ch; + } + break :blk res; + }; + return &sanitized; +} + +fn contains_eol(comptime text: []const u8) bool { + for (text) |c| { + switch (c) { + '\r', '\n' => return true, + else => {}, + } + } + return false; +} + +fn is_uppercase(comptime text: []const u8) bool { + for (text) |c| { + if ('a' <= c and c <= 'z') return false; + } + return true; +} + +fn join_enum_values(comptime E: type) []const u8 { + const fields = std.meta.fields(E); + return switch (fields.len) { + 0 => unreachable, + 1 => "'" ++ fields[0].name ++ "'", + 2 => "'" ++ fields[0].name ++ "' or '" ++ fields[1].name ++ "'", + 3 => str: { + var text: []const u8 = ""; + for (fields[0 .. fields.len - 1]) |field| { + text = text ++ "'" ++ field.name ++ "', "; + } + break :str text ++ "or '" ++ fields[fields.len - 1].name ++ "'"; + }, + }; +} diff --git a/src/conf.zig b/src/conf.zig new file mode 100644 index 0000000..419c369 --- /dev/null +++ b/src/conf.zig @@ -0,0 +1,382 @@ +const std = @import("std"); +const assert = std.debug.assert; + +pub const Var = struct { + key: []const u8, + val: []const u8, + + pub fn format(self: Var, w: *std.io.Writer) !void { + const spacebuf = [_]u8{' '} ** 32; + const equal_sign = " = "; + + try w.writeAll(self.key); + try w.writeAll(equal_sign); + if (std.mem.indexOfScalar(u8, self.val, '\n')) |eol0| { + try w.writeAll(self.val[0 .. eol0 + 1]); + var val = self.val[eol0 + 1 ..]; + while (val.len > 0) { + const eol = std.mem.indexOfScalar(u8, val, '\n') orelse val.len - 1; + const line = val[0 .. eol + 1]; + var spaces = self.key.len; + while (spaces > 0) { + const n = @min(spaces, spacebuf.len); + try w.writeAll(spacebuf[0..n]); + spaces -= n; + } + try w.writeAll(equal_sign); + try w.writeAll(line); + val = val[eol + 1 ..]; + } + } else { + try w.writeAll(self.val); + } + } +}; + +const Var_Header = packed struct(u64) { + key_len: u32, + val_len: u32, + + fn encode(self: Var_Header) [8]u8 { + var header: u64 = @intCast(self.key_len); + header <<= 32; + header |= @intCast(self.val_len); + return std.mem.toBytes(std.mem.nativeToBig(u64, header)); + } + + fn decode(buf: []const u8) Var_Header { + const header = std.mem.bigToNative(u64, std.mem.bytesToValue(u64, buf[0..8])); + return .{ + .key_len = @intCast(header >> 32), + .val_len = @intCast(header & 0xffffffff), + }; + } +}; + +pub const Iterator = struct { + rest: []const u8, + + pub fn next(self: *Iterator) ?Var { + if (self.rest.len == 0) return null; + + const header = Var_Header.decode(self.rest); + const v: Var = .{ + .key = self.rest[@sizeOf(Var_Header) .. @sizeOf(Var_Header) + header.key_len], + .val = self.rest[@sizeOf(Var_Header) + header.key_len .. @sizeOf(Var_Header) + header.key_len + header.val_len], + }; + self.rest = self.rest[@sizeOf(Var_Header) + v.key.len + v.val.len ..]; + return v; + } +}; + +pub const Parse_Result = union(enum) { + conf: Config, + err: struct { + msg: []const u8, + line: usize, + }, +}; + +pub const Config = struct { + allocator: std.mem.Allocator, + buf: []u8, // encoded key/value pairs + len: usize, // used length of `buf` + + pub fn init(allocator: std.mem.Allocator) Config { + return .{ .allocator = allocator, .buf = &.{}, .len = 0 }; + } + + pub fn from_file(allocator: std.mem.Allocator, path: []const u8) !Parse_Result { + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + return switch (err) { + error.FileNotFound => .{ .conf = .init(allocator) }, + else => err, + }; + }; + defer file.close(); + + var read_buf: [1024]u8 = undefined; + var reader = file.reader(&read_buf); + return parse(allocator, &reader.interface, read_buf.len); + } + + fn parse(allocator: std.mem.Allocator, reader: *std.io.Reader, comptime max_line_len: usize) !Parse_Result { + const spaces = " \t\r"; + + var allocating: std.io.Writer.Allocating = .init(allocator); + + var header: Var_Header = undefined; + var header_off: usize = 0; + var need_key = true; + var file_line: usize = 0; + while (true) { + file_line += 1; + const raw_line = reader.takeDelimiterExclusive('\n') catch |err| switch (err) { + error.StreamTooLong => { + return .{ + .err = .{ + .msg = std.fmt.comptimePrint("key/value line exceeds the maximum of {} bytes", .{max_line_len}), + .line = file_line, + }, + }; + }, + error.EndOfStream => { + break; + }, + else => unreachable, + }; + + const line = std.mem.trim(u8, raw_line, spaces); + if (line.len == 0 or line[0] == '#') { + // After an empty line, we consider the end of a value. + need_key = true; + continue; + } + + const eq = std.mem.indexOfScalar(u8, line, '=') orelse { + return .{ + .err = .{ + .msg = "invalid variable definition (no '=' found)", + .line = file_line, + }, + }; + }; + + const key = std.mem.trimRight(u8, line[0..eq], spaces); + const val = std.mem.trimLeft(u8, line[eq + 1 ..], spaces); + + if (key.len == 0) { + // We have a value continuation here. So we append a newline + // to the existing value and write the value line below. + if (need_key) { + return .{ + .err = .{ + .msg = "missing key name", + .line = file_line, + }, + }; + } + header.val_len += 1; + try allocating.writer.writeByte('\n'); + } else { + // We have a new key/value pair starting here. First, we need + // to write the header from the previous key/value pair if there + // was some. + if (allocating.writer.end > 0) { + @memcpy(allocating.writer.buffer[header_off .. header_off + @sizeOf(Var_Header)], &header.encode()); + } + + header_off = allocating.writer.end; + header.key_len = @intCast(key.len); + header.val_len = 0; + + try allocating.writer.writeAll(&[_]u8{0} ** @sizeOf(Var_Header)); + try allocating.writer.writeAll(key); + need_key = false; + } + + header.val_len += @intCast(val.len); + try allocating.writer.writeAll(val); + } + + if (allocating.writer.end > 0) { + @memcpy(allocating.writer.buffer[header_off .. header_off + @sizeOf(Var_Header)], &header.encode()); + } + return .{ + .conf = .{ + .allocator = allocator, + .buf = allocating.writer.buffer, + .len = allocating.writer.end, + }, + }; + } + + pub fn deinit(self: Config) void { + if (self.buf.len > 0) { + self.allocator.free(self.buf); + } + } + + pub fn iterate(self: Config) Iterator { + return .{ .rest = self.buf[0..self.len] }; + } + + pub fn add_var(self: *Config, key: []const u8, val: []const u8) !void { + const var_size = @sizeOf(Var_Header) + key.len + val.len; + if (self.len + var_size > self.buf.len) { + const new_size = @max(256, self.buf.len + @max(var_size, self.buf.len / 2)); + self.buf = try self.allocator.realloc(self.buf, new_size); + } + assert(self.len + var_size <= self.buf.len); + + const header: Var_Header = .{ + .key_len = @intCast(key.len), + .val_len = @intCast(val.len), + }; + @memcpy(self.buf[self.len .. self.len + 8], &header.encode()); + self.len += 8; + @memcpy(self.buf[self.len .. self.len + key.len], key); + self.len += key.len; + @memcpy(self.buf[self.len .. self.len + val.len], val); + self.len += val.len; + } + + /// Find a variable by its key and returns the first variable that + /// is found. If the key cannot be found, null will be returned. + pub fn find_var(self: Config, key: []const u8) ?Var { + var iter = self.iterate(); + while (iter.next()) |v| { + if (std.mem.eql(u8, v.key, key)) { + return v; + } + } + return null; + } +}; + +test "parse config - empty" { + var input = std.io.Reader.fixed(""); + var buf: [32]u8 = undefined; + var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf); + + var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len); + try std.testing.expect(result == .conf); + try std.testing.expectEqualSlices(u8, "", result.conf.buf[0..result.conf.len]); + defer result.conf.deinit(); + + var vars = result.conf.iterate(); + try std.testing.expect(vars.next() == null); +} + +test "parse config - singe line" { + var input = std.io.Reader.fixed("foo one = bar one"); + var buf: [32]u8 = undefined; + var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf); + + var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len); + try std.testing.expect(result == .conf); + try std.testing.expectEqualSlices( + u8, + "\x00\x00\x00\x07\x00\x00\x00\x07foo onebar one", + result.conf.buf[0..result.conf.len], + ); + defer result.conf.deinit(); + + var vars = result.conf.iterate(); + + const var_one = vars.next() orelse return error.missing_var_one; + try std.testing.expectEqualSlices(u8, "foo one", var_one.key); + try std.testing.expectEqualSlices(u8, "bar one", var_one.val); + + try std.testing.expectEqual(@as(?Var, null), vars.next()); +} + +test "parse config - mixed" { + var input = std.io.Reader.fixed( + \\foo_one = bar one + \\foo_two = bar two 1 + \\ = bar two 2 + \\ = + \\ = bar two 4 + \\ + \\foo_three = + \\ = bar three 2 + \\ = bar three 3 + \\ + \\ + \\ + \\foo_four = bar four 1 + \\ = bar four 2 + \\ = bar four 3 + \\ = + \\ + \\foo_five = + \\ + ); + var buf: [32]u8 = undefined; + var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf); + + var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len); + try std.testing.expect(result == .conf); + try std.testing.expectEqualSlices( + u8, + "" ++ + "\x00\x00\x00\x07\x00\x00\x00\x07foo_onebar one" ++ + "\x00\x00\x00\x07\x00\x00\x00\x1efoo_twobar two 1\nbar two 2\n\nbar two 4" ++ + "\x00\x00\x00\x09\x00\x00\x00\x18foo_three\nbar three 2\nbar three 3" ++ + "\x00\x00\x00\x08\x00\x00\x00\x21foo_fourbar four 1\nbar four 2\nbar four 3\n" ++ + "\x00\x00\x00\x08\x00\x00\x00\x00foo_five", + result.conf.buf[0..result.conf.len], + ); + defer result.conf.deinit(); + + var vars = result.conf.iterate(); + + const var_one = vars.next() orelse return error.missing_var_one; + try std.testing.expectEqualSlices(u8, "foo_one", var_one.key); + try std.testing.expectEqualSlices(u8, "bar one", var_one.val); + + const var_two = vars.next() orelse return error.missing_var_two; + try std.testing.expectEqualSlices(u8, "foo_two", var_two.key); + try std.testing.expectEqualSlices(u8, "bar two 1\nbar two 2\n\nbar two 4", var_two.val); + + const var_three = vars.next() orelse return error.missing_var_three; + try std.testing.expectEqualSlices(u8, "foo_three", var_three.key); + try std.testing.expectEqualSlices(u8, "\nbar three 2\nbar three 3", var_three.val); + + const var_four = vars.next() orelse return error.missing_var_four; + try std.testing.expectEqualSlices(u8, "foo_four", var_four.key); + try std.testing.expectEqualSlices(u8, "bar four 1\nbar four 2\nbar four 3\n", var_four.val); + + const var_five = vars.next() orelse return error.missing_var_five; + try std.testing.expectEqualSlices(u8, "foo_five", var_five.key); + try std.testing.expectEqualSlices(u8, "", var_five.val); + + try std.testing.expectEqual(@as(?Var, null), vars.next()); +} + +test "parse config - with comment" { + var input = std.io.Reader.fixed( + \\# first var: + \\foo_one = bar one + \\# second var: + \\foo_two = bar two 1 + \\ = bar two 2 + \\ = + \\ = bar two 4 + \\ # third var: + \\foo_three = + \\ = bar three 2 + \\ = bar three 3 + ); + var buf: [32]u8 = undefined; + var reader = std.io.Reader.limited(&input, std.io.Limit.limited(input.buffer.len), &buf); + + var result = try Config.parse(std.testing.allocator, &reader.interface, buf.len); + try std.testing.expect(result == .conf); + try std.testing.expectEqualSlices( + u8, + "" ++ + "\x00\x00\x00\x07\x00\x00\x00\x07foo_onebar one" ++ + "\x00\x00\x00\x07\x00\x00\x00\x1efoo_twobar two 1\nbar two 2\n\nbar two 4" ++ + "\x00\x00\x00\x09\x00\x00\x00\x18foo_three\nbar three 2\nbar three 3", + result.conf.buf[0..result.conf.len], + ); + defer result.conf.deinit(); + + var vars = result.conf.iterate(); + + const var_one = vars.next() orelse return error.missing_var_one; + try std.testing.expectEqualSlices(u8, "foo_one", var_one.key); + try std.testing.expectEqualSlices(u8, "bar one", var_one.val); + + const var_two = vars.next() orelse return error.missing_var_two; + try std.testing.expectEqualSlices(u8, "foo_two", var_two.key); + try std.testing.expectEqualSlices(u8, "bar two 1\nbar two 2\n\nbar two 4", var_two.val); + + const var_three = vars.next() orelse return error.missing_var_three; + try std.testing.expectEqualSlices(u8, "foo_three", var_three.key); + try std.testing.expectEqualSlices(u8, "\nbar three 2\nbar three 3", var_three.val); + + try std.testing.expectEqual(@as(?Var, null), vars.next()); +} diff --git a/src/env.zig b/src/env.zig new file mode 100644 index 0000000..7e8b355 --- /dev/null +++ b/src/env.zig @@ -0,0 +1,200 @@ +const options = @import("options"); +const std = @import("std"); +const assert = std.debug.assert; + +const cli = @import("cli.zig"); +const fs = @import("fs.zig"); +const conf = @import("conf.zig"); + +const Self = @This(); + +allocator: std.mem.Allocator, +prefix: []const u8, +etc: []const u8, // ${PREFIX}/etc/ +args: *cli.Args, + +config: conf.Config, +ports_dir: []const u8, +distfiles_dir: []const u8, +distfiles_history: usize, +default_category: ?[]const u8, +default_repo_branch: []const u8, +editor_cmd: ?[]const u8, + +pub fn init(allocator: std.mem.Allocator, etc: []const u8, args: *cli.Args) Self { + const prefix = if (options.path_prefix[options.path_prefix.len - 1] == '/') + options.path_prefix[0 .. options.path_prefix.len - 1] + else + options.path_prefix; + + const config = read_config(allocator, .join(.{ etc, "porteur/porteur.conf" })); + var self: Self = .{ + .allocator = allocator, + .prefix = prefix, + .etc = etc, + .args = args, + + .config = config, + .ports_dir = std.posix.getenvZ("PORTSDIR") orelse "/usr/ports", + .distfiles_dir = prefix ++ "/porteur/distfiles/{{TREENAME}}", + .distfiles_history = 1, + .default_category = null, + .default_repo_branch = "main", + .editor_cmd = null, + }; + + var iter = config.iterate(); + while (iter.next()) |v| { + if (std.mem.eql(u8, v.key, "portsdir") and v.val.len > 0) { + self.ports_dir = v.val; + } else if (std.mem.eql(u8, v.key, "distfiles-dir") and v.val.len > 0) { + self.distfiles_dir = std.mem.trimEnd(u8, v.val, "/"); + } else if (std.mem.eql(u8, v.key, "distfiles-history") and v.val.len > 0) { + self.distfiles_history = history: { + var res: usize = 0; + for (v.val) |c| { + if (c < '0' or c > '9') { + self.warn("invalid distfile history `{s}` (falling back to 1)", .{v.val}); + break :history 1; + } + const m = @mulWithOverflow(res, @as(usize, 10)); + const a = @addWithOverflow(m[0], @as(usize, @intCast(c - '0'))); + if (m[1] != 0 or a[1] != 0) { + break :history std.math.maxInt(usize); + } + res = a[0]; + } + break :history res; + }; + } else if (std.mem.eql(u8, v.key, "category") and v.val.len > 0) { + self.default_category = v.val; + } else if (std.mem.eql(u8, v.key, "git-branch") and v.val.len > 0) { + self.default_repo_branch = v.val; + } else if (std.mem.eql(u8, v.key, "editor") and v.val.len > 0) { + self.editor_cmd = v.val; + } else { + self.warn("unknown porteur configuration `{s}`", .{v.key}); + } + } + + return self; +} + +/// Return a subpath of the configuration directory. +pub fn etc_path(self: Self, subpath: anytype) fs.Path { + var etc: fs.Path = .join(.{ self.etc, "porteur" }); + etc.append(subpath); + return etc; +} + +/// Return a subpath of a ports tree's root directory. +pub fn ports_tree_path(self: Self, tree_name: []const u8, subpath: anytype) fs.Path { + var p: fs.Path = .join(.{ self.prefix, "porteur/ports", tree_name }); + p.append(subpath); + return p; +} + +/// Return the directory that contains the port's distfiles. +pub fn ports_dist_dir(self: Self, tree_name: []const u8, port_name: []const u8) fs.Path { + var dest: fs.Path = undefined; + dest.len = 0; + + var src = self.distfiles_dir; + loop: while (src.len > 0) { + inline for ([_]struct { key: []const u8, val: []const u8 }{ + .{ .key = "{{TREENAME}}", .val = tree_name }, + .{ .key = "{{PORTNAME}}", .val = port_name }, + }) |v| { + if (std.mem.indexOf(u8, src, v.key)) |idx| { + @memcpy(dest.buf[dest.len .. dest.len + idx], src[0..idx]); + dest.len += idx; + @memcpy(dest.buf[dest.len .. dest.len + v.val.len], v.val); + dest.len += v.val.len; + src = src[idx + v.key.len ..]; + continue :loop; + } + } + + @memcpy(dest.buf[dest.len .. dest.len + src.len], src); + dest.len += src.len; + break :loop; + } + + dest.buf[dest.len] = 0; + return dest; +} + +/// Return a subpath of the directory that contains the sources of all the ports of a +/// ports tree. +pub fn ports_src_path(self: Self, tree_name: []const u8, subpath: anytype) fs.Path { + var p: fs.Path = .join(.{ self.prefix, "porteur/src", tree_name }); + p.append(subpath); + return p; +} + +/// Return the subpath of a temporary working directory. +pub fn work_path(self: Self, subpath: anytype) fs.Path { + var p: fs.Path = .join(.{ self.prefix, "porteur/.wrk" }); + p.append(subpath); + return p; +} + +pub fn alloc(self: Self, size: usize) []u8 { + return self.allocator.alloc(u8, size) catch cli.fatal("out of memory", .{}); +} + +pub fn dealloc(self: Self, bytes: []const u8) void { + self.allocator.free(bytes); +} + +pub fn err(self: Self, comptime fmt: []const u8, args: anytype) void { + _ = self; + cli.stderr.write_line("Error: " ++ fmt, args); + cli.stderr.flush(); +} + +pub fn warn(self: Self, comptime fmt: []const u8, args: anytype) void { + _ = self; + cli.stderr.write_line("Warning: " ++ fmt, args); + cli.stderr.flush(); +} + +pub fn info(self: Self, comptime fmt: []const u8, args: anytype) void { + _ = self; + cli.stdout.write_line(fmt, args); + cli.stdout.flush(); +} + +fn read_config(allocator: std.mem.Allocator, conf_file: fs.Path) conf.Config { + if (conf.Config.from_file(allocator, conf_file.name())) |res| { + return switch (res) { + .conf => |c| c, + .err => |e| empty: { + cli.stderr.write_line("Error: {s} (line {d})", .{ e.msg, e.line }); + cli.stderr.flush(); + break :empty .init(allocator); + }, + }; + } else |e| { + cli.stderr.write_line("Error: cannot read config ({s})", .{@errorName(e)}); + cli.stderr.flush(); + return .init(allocator); + } +} + +test "replace distdir variables" { + var env: Self = undefined; + var dir: fs.Path = undefined; + + env.distfiles_dir = "/porteur/distfiles"; + dir = env.ports_dist_dir("not-relevant", "not-relevant"); + try std.testing.expectEqualStrings("/porteur/distfiles", dir.name()); + + env.distfiles_dir = "/porteur/distfiles/{{TREENAME}}"; + dir = env.ports_dist_dir("default", "not-relevant"); + try std.testing.expectEqualStrings("/porteur/distfiles/default", dir.name()); + + env.distfiles_dir = "/porteur/distfiles/{{TREENAME}}/{{PORTNAME}}"; + dir = env.ports_dist_dir("default", "myport"); + try std.testing.expectEqualStrings("/porteur/distfiles/default/myport", dir.name()); +} diff --git a/src/fs.zig b/src/fs.zig new file mode 100644 index 0000000..f1bee2d --- /dev/null +++ b/src/fs.zig @@ -0,0 +1,269 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const fatal = @import("cli.zig").fatal; + +pub const Path = struct { + buf: [std.fs.max_path_bytes]u8, + len: usize, + + pub fn init(path: []const u8) Path { + var self: Path = undefined; + assert(path.len < self.buf.len); + self.len = path.len; + @memcpy(self.buf[0..path.len], path); + self.buf[self.len] = 0; + return self; + } + + pub fn join(segments: anytype) Path { + var self: Path = undefined; + self.len = 0; + self.append(segments); + return self; + } + + pub fn append(self: *Path, segments: anytype) void { + const info = @typeInfo(@TypeOf(segments)).@"struct"; + comptime assert(info.is_tuple); + inline for (info.fields) |field| { + var seg: []const u8 = @field(segments, field.name); + if (seg.len > 0) { + if (self.len > 0 and seg[0] == std.fs.path.sep) seg = seg[1..]; + if (seg[seg.len - 1] == std.fs.path.sep) seg = seg[0 .. seg.len - 1]; + } + if (seg.len > 0) { + if (self.len > 0) { + self.buf[self.len] = std.fs.path.sep; + self.len += 1; + } + @memcpy(self.buf[self.len .. self.len + seg.len], seg); + self.len += seg.len; + } + } + self.buf[self.len] = 0; + } + + pub fn name(self: *const Path) [:0]const u8 { + return self.buf[0..self.len :0]; + } + + pub fn basename(self: *const Path) []const u8 { + return std.fs.path.basename(self.name()); + } + + pub fn dir(self: *const Path) ?Path { + const dirname = std.fs.path.dirname(self.name()) orelse return null; + return .init(dirname); + } + + /// Returns the relative name of `self` regarding to `root`. + /// Both paths must be absolute. + pub fn relname(self: *const Path, root: Path) ?[]const u8 { + assert(std.fs.path.isAbsolute(root.name())); + assert(std.fs.path.isAbsolute(self.name())); + if (!std.mem.startsWith(u8, self.name(), root.name())) return null; + if (self.len == root.len) return ""; + var rel = self.buf[root.len..self.len]; + if (rel[0] == std.fs.path.sep) rel = rel[1..]; + return rel; + } +}; + +pub fn mkdir_all(path: Path) !void { + try std.fs.cwd().makePath(path.name()); +} + +pub fn touch(path: Path) !void { + const cwd = std.fs.cwd(); + if (std.fs.path.dirname(path.name())) |dir| { + try cwd.makePath(dir); + } + const f = try cwd.createFileZ(path.name(), .{ .mode = 0o644 }); + f.close(); +} + +pub fn rm(path: Path) !void { + std.fs.deleteTreeAbsolute(path.name()) catch |err| { + if (err != error.FileNotFound) return err; + }; +} + +pub fn cp_file(from_path: Path, to_path: Path) !void { + const from = from_path.name(); + const to = to_path.name(); + std.fs.deleteTreeAbsolute(to) catch |err| { + if (err != error.FileNotFound) return err; + }; + if (to_path.dir()) |to_parent| try mkdir_all(to_parent); + try std.fs.copyFileAbsolute(from, to, .{}); +} + +pub fn cp_dir(allocator: std.mem.Allocator, from_path: Path, to_path: Path) !void { + const from = from_path.name(); + const to = to_path.name(); + std.fs.deleteTreeAbsolute(to) catch |err| { + if (err != error.FileNotFound) return err; + }; + try mkdir_all(to_path); + try cp_tree(allocator, from, to); +} + +pub fn mv_dir(allocator: std.mem.Allocator, from_path: Path, to_path: Path) !void { + const from = from_path.name(); + const to = to_path.name(); + std.fs.deleteTreeAbsolute(to) catch |err| { + if (err != error.FileNotFound) return err; + }; + if (to_path.dir()) |to_parent| try mkdir_all(to_parent); + + std.fs.renameAbsoluteZ(from, to) catch |err| { + if (err != error.RenameAcrossMountPoints) return err; + try std.fs.makeDirAbsoluteZ(to); + try cp_tree(allocator, from, to); + try rm(from_path); + }; +} + +pub fn mv_file(from_path: Path, to_path: Path) !void { + const from = from_path.name(); + const to = to_path.name(); + std.fs.deleteTreeAbsolute(to) catch |err| { + if (err != error.FileNotFound) return err; + }; + if (to_path.dir()) |to_parent| try mkdir_all(to_parent); + + std.fs.renameAbsoluteZ(from, to) catch |err| { + if (err != error.RenameAcrossMountPoints) return err; + try std.fs.copyFileAbsolute(from, to, .{}); + try rm(from_path); + }; +} + +fn cp_tree(allocator: std.mem.Allocator, from: [:0]const u8, to: [:0]const u8) !void { + var from_dir = try std.fs.openDirAbsoluteZ(from, .{ .iterate = true }); + defer from_dir.close(); + + var walker = try from_dir.walk(allocator); + defer walker.deinit(); + while (try walker.next()) |entry| { + const dest: Path = .join(.{ to, entry.path }); + switch (entry.kind) { + .directory => try std.fs.makeDirAbsoluteZ(dest.name()), + .file => { + const src: Path = .join(.{ from, entry.path }); + try std.fs.copyFileAbsolute(src.name(), dest.name(), .{}); + }, + else => |k| fatal("cannot copy {s}", .{@tagName(k)}), + } + } +} + +pub const File_Iterator = struct { + iter: std.fs.Dir.Iterator, + + pub fn deinit(self: *File_Iterator) void { + self.iter.dir.close(); + } + + pub fn next(self: *File_Iterator) ?[]const u8 { + while (true) { + const entry = self.iter.next() catch unreachable orelse return null; + if (entry.kind == .file) return entry.name; + } + } +}; + +/// Iterate files of a directory with the given path. The path must be absolute. +/// If the directory does not exist, null will be returned. +pub fn iterate_files(path: Path) !?File_Iterator { + var dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| { + return switch (err) { + error.FileNotFound => null, + error.BadPathName, error.InvalidUtf8 => error.BadPathName, + error.NameTooLong => unreachable, + else => err, + }; + }; + return .{ + .iter = dir.iterate(), + }; +} + +pub const Dir_Info = struct { + exists: bool, + empty: bool, +}; + +pub fn dir_info(path: Path) !Dir_Info { + var dir = std.fs.openDirAbsoluteZ(path.name(), .{ .iterate = true }) catch |err| { + return switch (err) { + error.FileNotFound => .{ .exists = false, .empty = false }, + error.BadPathName, error.InvalidUtf8 => error.BadPathName, + error.NameTooLong => unreachable, + else => err, + }; + }; + defer dir.close(); + + var iter = dir.iterate(); + const child = iter.next() catch unreachable; + return .{ + .exists = true, + .empty = child == null, + }; +} + +pub fn file_exists(path: Path) !bool { + std.fs.accessAbsoluteZ(path.name(), .{}) catch |err| { + switch (err) { + error.FileNotFound => return false, + error.PermissionDenied => return false, + error.NameTooLong => unreachable, + else => return err, + } + }; + return true; +} + +/// Read the whole file into a buffer and returns the buffer. The caller is responsible +/// to deallocate the memory with the given allocator. +/// If the file does not exist, null will be returned. +pub fn read_file(allocator: std.mem.Allocator, path: Path) !?[]const u8 { + var f = std.fs.openFileAbsoluteZ(path.name(), .{}) catch |err| { + return switch (err) { + error.FileNotFound => null, + error.NameTooLong => unreachable, + else => err, + }; + }; + defer f.close(); + + const stats = try f.stat(); + const buf = try allocator.alloc(u8, @intCast(stats.size)); + errdefer allocator.free(buf); + + var r = f.reader(buf); + var off: usize = 0; + while (off < buf.len) { + const n = try r.readStreaming(buf[off..]); + off += n; + } + return buf; +} + +test "path join" { + var path: Path = undefined; + + path = .join(.{}); + try std.testing.expectEqualStrings("", path.name()); + + path = .join(.{ "relative", "local/dir" }); + try std.testing.expectEqualStrings("relative/local/dir", path.name()); + + path = .join(.{ "relative/", "/local/dir" }); + try std.testing.expectEqualStrings("relative/local/dir", path.name()); + + path = .join(.{ "/absolute", "local/dir" }); + try std.testing.expectEqualStrings("/absolute/local/dir", path.name()); +} diff --git a/src/git.zig b/src/git.zig new file mode 100644 index 0000000..82e19b0 --- /dev/null +++ b/src/git.zig @@ -0,0 +1,179 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const fs = @import("fs.zig"); +const shell = @import("shell.zig"); +const cli = @import("cli.zig"); +const Env = @import("env.zig"); +const fatal = cli.fatal; + +pub const Commit = struct { + hash: []const u8, + timestamp: u64, + + pub fn init_empty() Commit { + return .{ .hash = "", .timestamp = 0 }; + } + + pub fn parse(version_str: []const u8) !Commit { + const s = std.mem.trim(u8, version_str, " \t"); + if (s.len == 0) return .init_empty(); + + const pos = std.mem.indexOfScalar(u8, s, '@') orelse return error.invalid_commit; + return .{ + .hash = s[0..pos], + .timestamp = std.fmt.parseInt(u64, s[pos + 1 ..], 10) catch return error.invalid_commit, + }; + } + + pub fn format(self: Commit, w: *std.io.Writer) std.io.Writer.Error!void { + if (self.hash.len > 0) { + try w.print("{s}@{d}", .{ self.hash, self.timestamp }); + } + } +}; + +pub const Repo = struct { + path: fs.Path, + url: []const u8, + branch: []const u8, + + pub fn clone(self: Repo, env: Env) !void { + const dir = try fs.dir_info(self.path); + if (!dir.exists or dir.empty) { + var branch_buf: [std.fs.max_name_bytes]u8 = undefined; + const branch_param = branch_buf[0 .. 9 + self.branch.len]; + @memcpy(branch_param[0..9], "--branch="); + @memcpy(branch_param[9..], self.branch); + const res = shell.exec(.{ + .env = env, + .argv = &.{ "git", "clone", "--quiet", "--depth=1", branch_param, self.url, self.path.name() }, + }); + switch (res) { + .success => |out| env.dealloc(out), + .failure => |out| { + env.err("failed to clone {s}\n", .{self.url}); + fatal("{s}", .{out}); + }, + } + } + } + + pub fn reset(self: Repo, env: Env) void { + var res = shell.exec(.{ + .env = env, + .argv = &.{ "git", "reset", "--hard" }, + .cwd = self.path, + }); + switch (res) { + .success => |out| env.dealloc(out), + .failure => |out| { + env.err("failed to reset repository: {s}\n", .{self.path.name()}); + fatal("{s}", .{out}); + }, + } + + res = shell.exec(.{ + .env = env, + .argv = &.{ "git", "clean", "-fdx" }, + .cwd = self.path, + }); + switch (res) { + .success => |out| env.dealloc(out), + .failure => |out| { + env.err("failed to clean repository: {s}\n", .{self.path.name()}); + fatal("{s}", .{out}); + }, + } + } + + pub fn update(self: Repo, env: Env) !Commit { + const dir = try fs.dir_info(self.path); + if (!dir.exists or dir.empty) { + try self.clone(env); + } else { + var res = shell.exec(.{ + .env = env, + .argv = &.{ "git", "fetch", "--quiet", "--depth=1", "origin", self.branch }, + .cwd = self.path, + }); + switch (res) { + .success => |out| env.dealloc(out), + .failure => |out| { + env.err("failed to fetch branch {s}: {s}\n", .{ self.branch, self.url }); + fatal("{s}", .{out}); + }, + } + + const branch_spec: fs.Path = .join(.{ "origin", self.branch }); + res = shell.exec(.{ + .env = env, + .argv = &.{ "git", "reset", "--hard", branch_spec.name() }, + .cwd = self.path, + }); + switch (res) { + .success => |out| env.dealloc(out), + .failure => |out| { + env.err("failed to reset branch {s}: {s}\n", .{ self.branch, self.path.name() }); + fatal("{s}", .{out}); + }, + } + } + + // We leak `out` intentionally here. + const out = self.must_run(env, &.{ "git", "show", "--no-patch", "--format=format:%H %at", self.branch }); + const space = std.mem.indexOfScalar(u8, out, ' ') orelse { + fatal("unexpected output for 'git show'", .{}); + }; + return .{ + .hash = out[0..space], + .timestamp = ts: { + var timestamp: u64 = 0; + for (out[space + 1 ..]) |c| { + if (c < '0' or c > '9') { + fatal("unexpected timestamp in 'git show'", .{}); + } + timestamp *= 10; + timestamp += @intCast(c - '0'); + } + break :ts timestamp; + }, + }; + } + + fn must_run(self: Repo, env: Env, argv: []const []const u8) []const u8 { + const res = shell.exec(.{ + .env = env, + .argv = argv, + .cwd = self.path, + }); + switch (res) { + .success => |out| return out, + .failure => |out| { + var errmsg = out; + if (std.mem.indexOfScalar(u8, errmsg, '\n')) |eol| { + errmsg = errmsg[0..eol]; + if (errmsg.len > 0 and errmsg[errmsg.len - 1] == '\r') { + errmsg = errmsg[0 .. errmsg.len - 1]; + } + } + fatal("{s}", .{errmsg}); + }, + } + } +}; + +pub fn is_valid_branch_name(name: []const u8) bool { + var last_c: u8 = 0; + for (name) |c| { + switch (c) { + 0...31 => return false, + ':', '?', '[', '\\', '^', '~', ' ', '*' => return false, + '.' => if (last_c == '.') return false, + '{' => if (last_c == '@') return false, + else => {}, + } + last_c = c; + } + return true; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..bafcbd8 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,1195 @@ +const options = @import("options"); +const std = @import("std"); +const assert = std.debug.assert; + +const cli = @import("cli.zig"); +const fs = @import("fs.zig"); +const conf = @import("conf.zig"); +const tmpl = @import("template.zig"); +const tree = @import("tree.zig"); +const port = @import("port.zig"); +const git = @import("git.zig"); +const Env = @import("env.zig"); +const Time = @import("time.zig").Time; + +const default_tree_name = "default"; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + + var main_cmd: Main_Command = undefined; + var args: cli.Args = try .init(allocator, "porteur", &main_cmd); + defer args.deinit(allocator); + try main_cmd.execute(allocator, &args); +} + +const Main_Command = struct { + pub const help = cli.Command_Help{ + .name = "porteur", + .synopsis = &.{ + "porteur [--etc=⟨path⟩] tree ⟨command⟩ [options] [⟨args⟩]", + "porteur [--etc=⟨path⟩] tmpl ⟨command⟩ [options] [⟨args⟩]", + "porteur [--etc=⟨path⟩] port ⟨command⟩ [options] [⟨args⟩]", + "porteur help [⟨command⟩ [⟨subcommand⟩]]", + }, + .short_description = "Build your own ports.", + .long_description = std.fmt.comptimePrint( + \\Porteur is a tool for managing and creating custom ports from a Git repository. + \\Each port is part of a ports tree and results in a FreeBSD port usable by + \\`make` or `poudriere`. It uses a template to generate the files that describe + \\how to build the application from source code. + \\ + \\To add a new port some basic information (e.g. name, category) needs to be + \\provided including a Git repository where the source code can be found. Porteur + \\always uses the configured branch of the Git repository and operates only on + \\that version of the source code. When a port is updated, porteur checks the + \\latest commit on this branch and updates the port information accordingly. + \\ + \\Each port lives in a ports tree which can be used by the FreeBSD ports system. + \\When operating on ports there must always be a tree specified. If no tree name + \\is provided explicitly the default tree `{[default_tree]s}` will be used. + , .{ .default_tree = default_tree_name }), + .extra_sections = &.{ + .{ + .heading = "TEMPLATES", + .body = + \\Each FreeBSD port consists of a collection of files that automate the + \\compilation of an application. Since each port in the FreeBSD ports + \\tree requires its own set of files, maintaining them can be tedious, + \\especially when large parts of the content is identical. + \\ + \\To make the definition of such files reusable, porteur uses templates, + \\located in the `⟨etc⟩/porteur/tmpl/` directory (see FILES). Each port + \\must reference a template and defines the variables used within it. + \\ + \\A template is a collection of files that will used to render a port + \\usable by FreeBSD. Each file can use variables to render port specific + \\data, where a variable `v` can be referenced with the placeholder + \\`{{v}}`. It is also possible to use variables in filenames. For more + \\information how to define variables, see the VARIABLES section. + \\ + \\Normally, the processed template files are used to create the port + \\files, the distribution file is created, and a distinfo is generated. + \\To have more control over this process, a template can define a file + \\named `.config` in its root directory. For a documentation of all + \\configurable options, see the sample configuration file (see FILES). + , + }, + .{ + .heading = "VARIABLES", + .body = + \\Variables are used to substitute port specific values in templates. + \\Defining variables is file-based and has the following syntax: + \\ + \\ key = value + \\ + \\Keys define the variable name that can later be referenced (e.g. in + \\a template file) and the assigned value is the string that replaces + \\this key. Leading and trailing whitespace in a value is trimmed. + \\Normally, a key/value pair is defined on a single line, but multi-line + \\values are also supported: + \\ + \\ key = value line one + \\ = value line two + \\ = + \\ = value line three + \\ + \\Each continuation line starts with an empty key and is rendered as a + \\separate line in the template. This also allows empty lines to be + \\part of the value. Again, leading and trailing whitespace of each line + \\is trimmed. + \\ + \\To avoid redefining common information already known (e.g., port name, + \\port version), porteur predefines certain variables. All predefined + \\variable names start with a dot (`.`), so it is recommended not to use + \\custom variables starting with a dot. + \\ + \\List of predefined variables: + \\ + \\ .portname - Name of the port + \\ .portversion - Current version of the port + \\ .category - Category of the port + \\ .distdir - Directory containing all distfiles + \\ .distname - Name of the archive file containing the distribution + , + }, + .{ + .heading = "DISTFILES", + .body = + \\To compile a port, a distribution is necessary that contains the source + \\code. Porteur creates a gzipped archive of the Git repository and puts + \\it in the configured `distfiles-dir`. The filename of this archive is + \\provided in the `.dist.name` variable and the directory where its put in + \\is provided in the `.dist.dir` variable (see VARIABLES). So the template + \\can reference the archive's location and tell FreeBSD where to find the + \\source code. + \\ + \\Sometimes it is necessary to do extra steps before the distribution is + \\created (e.g. vendoring). These steps can be defined as a target in the + \\port Makefile of the template. When porteur executes this target it will + \\set ${WRKSRC} to the port's source tree. The target name can be defined + \\with `dist-prepare-target` in the template's configuration file (see + \\TEMPLATES). + , + }, + .{ + .heading = "FILES", + .body = std.fmt.comptimePrint( + \\Porteur uses two locations where data will be stored. The first location + \\is `{[prefix]s}/etc/` and contains all configuration files (ports and + \\templates). This location can be overwritten with the --etc flag. The + \\second location is `{[prefix]s}/porteur/` and contains a copy of the + \\sources and all generated port files that FreeBSD uses. + \\ + \\The following directory structure is used: + \\ + \\ {[prefix]s}/etc/porteur/porteur.conf + \\ Configuration file for porteur. + \\ + \\ {[prefix]s}/etc/porteur/porteur.conf.sample + \\ Sample configuration file for porteur. + \\ + \\ {[prefix]s}/etc/porteur/template.conf.sample + \\ Sample configuration file for a template. + \\ + \\ {[prefix]s}/etc/porteur/ports// + \\ Configuration for each port. + \\ + \\ {[prefix]s}/etc/porteur/tmpl/⟨template⟩/ + \\ Template definitions used by the ports. + \\ + \\ {[prefix]s}/porteur/ports/⟨tree⟩/ + \\ Generated ports tree used by FreeBSD. + \\ + \\ {[prefix]s}/porteur/src/⟨tree⟩/ + \\ Copies of the sources for each port. + \\ + \\ {[prefix]s}/porteur/.wrk/ + \\ Working directory for porteur. + , .{ .prefix = options.path_prefix }), + }, + }, + }; + + options: struct { + etc: cli.Option([]const u8, .{ + .name = "etc", + .argument_name = "path", + .short_description = "The path to porteur's configuration directory.", + .long_description = std.fmt.comptimePrint( + \\The path of the directory porteur is storing its configuration. + \\Defaults to `{[prefix]s}/etc/`. See the FILES section for more + \\information. + , .{ .prefix = options.path_prefix }), + .default_value = options.path_prefix ++ "/etc/", + }), + }, + + pub const Subcommands = union(enum) { + help: Help_Command, + tmpl: Tmpl_Command, + tree: Tree_Command, + port: Port_Command, + }; + + pub fn execute(self: *Main_Command, allocator: std.mem.Allocator, args: *cli.Args) !void { + const subcmd = args.next_subcommand(Main_Command) orelse { + cli.print_usage(cli.stdout, Main_Command); + return; + }; + + const etc_path = self.options.etc.value_or_default(); + const env: Env = .init(allocator, etc_path, args); + switch (subcmd) { + .help => |cmd| cmd.execute(env), + .tmpl => |cmd| cmd.execute(env), + .tree => |cmd| cmd.execute(env), + .port => |cmd| cmd.execute(env), + } + } +}; + +const Help_Command = struct { + pub const help = cli.Command_Help{ + .name = "porteur help", + .synopsis = &.{ + "porteur help [⟨command⟩ [⟨subcommand⟩]]", + }, + .short_description = "Display help information.", + .long_description = + \\Without ⟨command⟩ and ⟨subcommand⟩ given, the synopsis of the porteur command + \\will be printed. This is equivalent to using `porteur` without any arguments. + \\ + \\When ⟨command⟩ is specified without ⟨subcommand⟩, a detailed description of this + \\command is printed. An additional ⟨subcommand⟩ prints the detailed description of + \\the subcommand. + \\ + \\The `porteur help ...` and `porteur ... --help` commands are identical. + \\ + \\This help text can be displayed with `porteur help help` or `porteur help --help`. + , + }; + + pub const Subcommands = Help_Subcommands(Main_Command.Subcommands); + + fn execute(self: Help_Command, env: Env) void { + _ = self; + const subcmd = env.args.next_subcommand(Help_Command) orelse { + cli.print_usage(cli.stdout, Main_Command); + return; + }; + + switch (subcmd) { + inline else => |cmd| execute_subcmd(env.args, @TypeOf(cmd)), + } + } + + fn execute_subcmd(args: *cli.Args, comptime Command: type) void { + if (@hasDecl(Command, "Subcommands")) { + if (args.next_subcommand(Command)) |subcmd| { + switch (subcmd) { + inline else => |cmd| { + execute_subcmd(args, @TypeOf(cmd)); + return; + }, + } + } + } + cli.print_help(Command); + } + + fn Help_Subcommands(comptime Subcmds: type) type { + assert(@typeInfo(Subcmds) == .@"union"); + const union_info = @typeInfo(Subcmds).@"union"; + + var fields: [union_info.fields.len]std.builtin.Type.UnionField = undefined; + for (union_info.fields, 0..) |field, i| { + fields[i] = .{ + .name = field.name, + .alignment = field.alignment, + .type = struct { + pub const help = field.type.help; + }, + }; + } + return @Type(.{ .@"union" = .{ + .layout = union_info.layout, + .tag_type = union_info.tag_type, + .fields = &fields, + .decls = &.{}, + } }); + } +}; + +const Tmpl_Command = struct { + pub const help = cli.Command_Help{ + .name = "porteur tmpl", + .synopsis = &.{ + "porteur tmpl ⟨command⟩ [options] [⟨args⟩]", + }, + .short_description = "Provide management of port templates.", + .long_description = + \\This command manages port templates. A port template defines the files + \\a port uses to automate the compilation of its source code. To use a + \\template with multiple ports variables can be used which are defined + \\for each port and later replaced when the port will be updated. + \\ + \\A variable definition within a template file has the form `{{key}}`, + \\where the value of `key` can be defined for each port using this + \\template. If no such value is defined for a port, it will simply be + \\replaced by an empty string. + \\ + \\For more information about defining and using templates, see the + \\TEMPLATES section in `porteur --help`. + , + }; + + pub const Subcommands = union(enum) { + list: Tmpl_Ls, + conf: Tmpl_Conf, + }; + + pub fn execute(self: Tmpl_Command, env: Env) void { + _ = self; + const subcmd = env.args.next_subcommand(Tmpl_Command) orelse { + cli.fatal_with_usage(Tmpl_Command, "missing subcommand", .{}); + }; + + switch (subcmd) { + .list => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to list templates"), + .conf => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to update template configuration"), + } + } + + const Tmpl_Ls = struct { + pub const help = cli.Command_Help{ + .name = "porteur tmpl ls", + .synopsis = &.{ + "porteur tmpl [options] ls", + }, + .short_description = "List all port templates.", + .long_description = + \\This command lists the names of all port templates that are known by + \\porteur. + , + }; + options: struct { + long: cli.Option(bool, .{ + .name = "long", + .short_name = 'l', + .short_description = "List templates in the long format.", + .long_description = + \\List the templates in a longer format that includes more information + \\about each template. In the long format a template is listed with its + \\path. + , + .default_value = false, + }), + }, + + fn execute(self: Tmpl_Ls, env: Env) !void { + const long = self.options.long.value_or_default(); + const print_opts: cli.Print_Options = .{ .hanging_indent = 4 }; + var iter = try tmpl.iterate(env); + defer iter.deinit(); + if (long) { + while (try iter.next()) |template| { + cli.stdout.printf_line("{s} {s}", .{ template.name, template.path.name() }, print_opts); + } + } else { + while (try iter.next()) |template| { + cli.stdout.print_line(template.name, print_opts); + } + } + cli.stdout.flush(); + } + }; + + const Tmpl_Conf = struct { + pub const help = cli.Command_Help{ + .name = "porteur tmpl conf", + .synopsis = &.{ + "porteur tmpl [options] conf ⟨template⟩", + "porteur tmpl [options] conf (--edit | -e) ⟨template⟩", + "porteur tmpl [options] conf --import=⟨path⟩ ⟨template⟩", + }, + .short_description = "Manage the configuration for a template.", + .long_description = + \\Manage the configuration for a template. Without a flag specified it lists + \\the current configuration values for this template. To edit the configuration + \\either use the --edit or the --import option. With the --edit option set, + \\the existing configuration can be modified interactively using an editor. + \\The --import flag imports an external configuration from a file and overwrites + \\the existing one. The configuration file consists of a list of variables (see + \\the VARIABLES section in `porteur --help`). A documentation of possible + \\configuration values can be found in `⟨etc⟩/porteur/template.conf.sample`. + , + }; + options: struct { + edit: cli.Option(bool, .{ + .name = "edit", + .short_name = 'e', + .short_description = "Edit the template configuration interactively.", + .long_description = + \\Edit the existing configuration for the template interactively. An + \\editor will be invoked where the existing configuration can be updated. + \\After exiting the editor the updates need to be confirmed. This + \\flag is mutual exclusive with --import. + , + }), + import: cli.Option([]const u8, .{ + .name = "import", + .argument_name = "path", + .short_description = "Import new configuration from the given file.", + .long_description = + \\Import the new configuration for the template from the specified file. + \\This file must contain valid variable definitions and will overwrite + \\the existing configuration for the template. This flag is mutual + \\exclusive with --edit. + , + }), + }, + + fn execute(self: Tmpl_Conf, env: Env) !void { + const template_name = env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing template name", .{}); + }; + + const edit = self.options.edit.value; + const import = self.options.import.value; + if (edit != null and import != null) { + cli.fatal("can either edit or import configuration", .{}); + } + + if (edit) |_| { + try tmpl.edit_config(env, template_name); + } else if (import) |filename| { + try tmpl.import_config(env, template_name, .init(filename)); + } else { + const template = try tmpl.must_get(env, template_name); + const config = try tmpl.read_config(env, template); + defer config.deinit(); + var iter = config.raw.iterate(); + while (iter.next()) |c| { + cli.stdout.write_line("{f}", .{c}); + } + cli.stdout.flush(); + } + } + }; +}; + +const Tree_Command = struct { + pub const help = cli.Command_Help{ + .name = "porteur tree", + .synopsis = &.{ + "porteur tree ⟨command⟩ [options] [⟨args⟩]", + }, + .short_description = "Provide management of ports trees.", + .long_description = + \\This command manages ports trees. A ports tree is a collection of your own + \\ports which can be used with the FreeBSD ports system. A port is a collection + \\of files that automate the compilation of an application from its source code. + , + }; + + pub const Subcommands = union(enum) { + list: Tree_Ls, + rm: Tree_Rm, + }; + + pub fn execute(self: Tree_Command, env: Env) void { + _ = self; + const subcmd = env.args.next_subcommand(Tree_Command) orelse { + cli.fatal_with_usage(Tree_Command, "missing subcommand", .{}); + }; + + switch (subcmd) { + .list => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to iterate ports trees"), + .rm => |cmd| cmd.execute(env) catch |err| fatal_err(err, "failed to remove ports trees"), + } + } + + const Tree_Ls = struct { + pub const help = cli.Command_Help{ + .name = "porteur tree ls", + .synopsis = &.{ + "porteur tree ls", + }, + .short_description = "List all existing ports trees.", + .long_description = + \\This command lists the names of all ports trees that are initialized with + \\porteur. The name is used to specify a specific tree when dealing with + \\ports. + \\ + \\It simply prints all tree names line by line. For more information about + \\a specific tree use the `porteur tree info` command. + , + }; + + fn execute(self: Tree_Ls, env: Env) !void { + _ = self; + var iter = try tree.iterate(env); + defer iter.deinit(); + while (try iter.next()) |t| { + cli.stdout.print_line(t.name, .{}); + } + cli.stdout.flush(); + } + }; + + const Tree_Rm = struct { + pub const help = cli.Command_Help{ + .name = "porteur tree rm", + .synopsis = &.{ + "porteur tree rm ⟨tree⟩", + }, + .short_description = "Remove an existing ports tree.", + .long_description = + \\This command removes an existing ports tree and deletes all its ports. + \\It asks for confirmation before deleting a tree. + , + }; + options: struct { + force: cli.Option(bool, .{ + .name = "force", + .short_name = 'f', + .short_description = "Do not prompt for confirmation.", + .long_description = + \\Attempt to remove a ports tree without prompting for confirmation. + \\If the tree does not exist, do not report an error. + , + .default_value = false, + }), + }, + + fn execute(self: Tree_Rm, env: Env) !void { + const force = self.options.force.value_or_default(); + const name = env.args.next_positional() orelse { + if (force) std.process.exit(0); + cli.fatal_with_usage(@This(), "missing tree name", .{}); + }; + + const ports_tree = try tree.get(env, name) orelse { + if (force) return; + cli.fatal("tree not found", .{}); + }; + + if (!force) { + const confirmed = cli.ask_confirmation("Remove tree {s}?", .{ports_tree.name}, .no); + if (!confirmed) return; + } + + try port.remove_all(.{ + .env = env, + .tree = ports_tree, + }); + tree.remove(env, ports_tree) catch {}; + } + }; +}; + +const Port_Command = struct { + pub const help = cli.Command_Help{ + .name = "porteur port", + .synopsis = &.{ + "porteur port [--tree=⟨tree⟩ | -t ⟨tree⟩] ⟨command⟩ [options] [⟨args⟩]", + }, + .short_description = "Provide management of ports in a ports tree.", + .long_description = + \\This command manages the ports of a ports tree. A port is a collection of + \\files that automate the compilation of an application from its source code. + \\It references a Git repository that contains the source code and a template + \\which is used to generate the port files from. + , + }; + + options: struct { + tree: cli.Option([]const u8, .{ + .name = "tree", + .short_name = 't', + .argument_name = "tree", + .short_description = "The name of the ports tree.", + .long_description = std.fmt.comptimePrint( + \\The name of the ports tree. If this option is not specified the default + \\tree `{[default_tree]s}` will be used. + , .{ .default_tree = default_tree_name }), + .default_value = default_tree_name, + }), + }, + + pub const Subcommands = union(enum) { + list: Port_Ls, + info: Port_Info, + add: Port_Add, + vars: Port_Vars, + update: Port_Update, + bump_version: Port_Bump_Version, + history: Port_History, + rollback: Port_Rollback, + rm: Port_Rm, + }; + + pub fn execute(self: Port_Command, env: Env) void { + const subcmd = env.args.next_subcommand(Port_Command) orelse { + cli.fatal_with_usage(Port_Command, "missing subcommand", .{}); + }; + + const tree_name = self.options.tree.value_or_default(); + const ports_tree = tree.get(env, tree_name) catch |err| fatal_err(err, "failed to get tree"); + if (ports_tree == null) cli.fatal("ports tree not found: {s}", .{tree_name}); + + const ctx = port.Ctx{ + .env = env, + .tree = ports_tree.?, + }; + + switch (subcmd) { + .list => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to list ports"), + .info => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to fetch port"), + .add => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to write port"), + .vars => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to update port variables"), + .update => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to update port"), + .bump_version => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to bump the port version"), + .history => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to list distfile history"), + .rollback => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to roll back the port version"), + .rm => |cmd| cmd.execute(ctx) catch |err| fatal_err(err, "failed to delete port"), + } + } + + const Port_Ls = struct { + pub const help = cli.Command_Help{ + .name = "porteur port ls", + .synopsis = &.{ + "porteur port [options] ls", + }, + .short_description = "List all ports of a ports tree.", + .long_description = + \\This command lists all ports the ports tree contains. It simply prints + \\all port names along with the port versions line by line. For more + \\information about a specific port see the `porteur port info` command. + , + }; + options: struct { + long: cli.Option(bool, .{ + .name = "long", + .short_name = 'l', + .short_description = "List ports in the long format.", + .long_description = + \\List the ports in a longer format that includes more information about + \\each port. In the long format a port is listed with its current version + \\and its category. + , + .default_value = false, + }), + }, + + fn execute(self: Port_Ls, ctx: port.Ctx) !void { + const long = self.options.long.value_or_default(); + const print_opts: cli.Print_Options = .{ .hanging_indent = 4 }; + var iter = try port.iterate(ctx); + defer iter.deinit(); + if (long) { + while (try iter.next()) |p| { + const version = try port.read_version(ctx, p); + cli.stdout.printf_line("{s}@{f} {s}", .{ p.name, version, p.category }, print_opts); + } + } else { + while (try iter.next()) |p| { + cli.stdout.print_line(p.name, print_opts); + } + } + cli.stdout.flush(); + } + }; + + const Port_Info = struct { + pub const help = cli.Command_Help{ + .name = "porteur port info", + .synopsis = &.{ + "porteur port [options] info ⟨port⟩", + }, + .short_description = "Show information about a port.", + .long_description = + \\This command prints information about the specified port of a ports + \\tree. Variables for this port are not listed here. To show them use + \\the `porteur port vars` command. If the port cannot be found in the + \\ports tree an error will be reported. + , + }; + fn execute(self: Port_Info, ctx: port.Ctx) !void { + _ = self; + const port_name = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing port name", .{}); + }; + const p = try port.must_get(ctx, port_name); + defer p.deinit(); + const version = try port.read_version(ctx, p); + const source = try port.read_source(ctx, p); + defer source.deinit(); + const distdir = ctx.env.ports_dist_dir(ctx.tree.name, p.name); + cli.stdout.printf_line("Name: {s}", .{p.name}, .{}); + cli.stdout.printf_line("Category: {s}", .{p.category}, .{}); + cli.stdout.printf_line("Template: {s}", .{p.template}, .{}); + cli.stdout.printf_line("Version: {f}", .{version}, .{}); + cli.stdout.printf_line("Git Repo: {s}", .{source.repo.url}, .{}); + cli.stdout.printf_line("Git Branch: {s}", .{source.repo.branch}, .{}); + if (source.commit.hash.len > 0) { + cli.stdout.printf_line("Commit Hash: {s}", .{source.commit.hash}, .{}); + cli.stdout.printf_line("Commit Time: {f}", .{Time.from_timestamp(source.commit.timestamp)}, .{}); + } else { + cli.stdout.printf_line("Commit Hash: -", .{}, .{}); + cli.stdout.printf_line("Commit Time: -", .{}, .{}); + } + cli.stdout.printf_line("Distdir: {s}", .{distdir.name()}, .{}); + cli.stdout.flush(); + } + }; + + const Port_Add = struct { + pub const help = cli.Command_Help{ + .name = "porteur port add", + .synopsis = &.{ + "porteur port [options] add", + }, + .short_description = "Add a new port to the ports tree.", + .long_description = + \\This command adds a new port to the ports tree. A port needs to reference a + \\Git repository that holds the source code for distribution and compilation. + \\Porteur creates a local clone of this repository. + \\ + \\When adding a new port there will be no variables defined and no FreeBSD + \\ports tree files generated. To add variables for the new port, use the + \\`porteur port vars` command. If everything is set up correctly, execute + \\a `porteur port update` to initially render the port. + \\ + \\This command is interactive. + , + }; + + fn execute(self: Port_Add, ctx: port.Ctx) !void { + _ = self; + if (!try tmpl.exists_any(ctx.env)) { + const tmpl_dir = tmpl.etc_dir(ctx.env); + cli.stderr.print_line("No templates found.", .{}); + cli.fatal("Please define a template in `{s}`.", .{tmpl_dir.name()}); + } + + const namebuf = try ctx.env.allocator.alloc(u8, 64); + defer ctx.env.allocator.free(namebuf); + const name = while (true) { + const input = cli.ask("Name: ", .{}, namebuf) orelse { + cli.stderr.print_line("ERROR: The port name is required.", .{}); + cli.stderr.flush(); + continue; + }; + if (std.mem.indexOfAny(u8, input, "/\\\t ")) |_| { + cli.stderr.print_line("ERROR: The port name must not contain slash, backslash, or space.", .{}); + cli.stderr.flush(); + continue; + } + break input; + }; + + var catbuf: [128]u8 = undefined; + var default_cat_buf: [128]u8 = undefined; + const default_cat = if (ctx.env.default_category) |cat| + try std.fmt.bufPrint(&default_cat_buf, " [{s}]", .{cat}) + else + ""; + const category = while (true) { + const input = cli.ask("Category{s}: ", .{default_cat}, &catbuf) orelse default_cat: { + if (ctx.env.default_category) |c| break :default_cat c; + cli.stderr.print_line("ERROR: The port category is required.", .{}); + cli.stderr.flush(); + continue; + }; + if (std.mem.indexOfAny(u8, input, "/\\\t ")) |_| { + cli.stderr.print_line("ERROR: The port category must not contain slash, backslash, or space.", .{}); + cli.stderr.flush(); + continue; + } + break input; + }; + + var verbuf: [64]u8 = undefined; + const version = while (true) { + const input = cli.ask("Initial Version [0.1.0]: ", .{}, &verbuf) orelse "0.1.0"; + const ver = port.Version.parse_semver(input) catch { + cli.stderr.print_line("ERROR: The port version must be in the format `⟨major⟩[[.⟨minor⟩[.⟨patch⟩]`.", .{}); + cli.stderr.flush(); + continue; + }; + break ver; + }; + + var repourlbuf: [std.fs.max_path_bytes]u8 = undefined; + const repo_url = while (true) { + const input = cli.ask("Git Repository: ", .{}, &repourlbuf) orelse { + cli.stderr.print_line("ERROR: The Git repository is required.", .{}); + cli.stderr.flush(); + continue; + }; + break input; + }; + + var repobranchbuf: [64]u8 = undefined; + const repo_branch = while (true) { + const default_branch = ctx.env.default_repo_branch; + const input = cli.ask("Git Branch [{s}]: ", .{default_branch}, &repobranchbuf) orelse default_branch; + if (!git.is_valid_branch_name(input)) { + cli.stderr.print_line("ERROR: Invalid Git branch name.", .{}); + cli.stderr.flush(); + continue; + } + break input; + }; + + var templatebuf: [64]u8 = undefined; + const template = while (true) { + const input = cli.ask("Template: ", .{}, &templatebuf) orelse { + cli.stderr.print_line("ERROR: The template is required.", .{}); + cli.stderr.flush(); + continue; + }; + if (!try tmpl.exists(ctx.env, input)) { + cli.stderr.print_line("ERROR: Template does not exist.", .{}); + cli.stderr.flush(); + continue; + } + break input; + }; + + cli.stderr.print_line("", .{}); + cli.stderr.printf_line("Name: {s}", .{name}, .{}); + cli.stderr.printf_line("Category: {s}", .{category}, .{}); + cli.stderr.printf_line("Initial Version: {f}", .{version}, .{}); + cli.stderr.printf_line("Git Repository: {s}", .{repo_url}, .{}); + cli.stderr.printf_line("Git Branch: {s}", .{repo_branch}, .{}); + cli.stderr.printf_line("Template: {s}", .{template}, .{}); + cli.stderr.flush(); + + const ok = cli.ask_confirmation("OK?", .{}, .yes); + if (ok) { + const new_port: port.Info = .init(ctx.tree, name, category, template); + try port.create(ctx, new_port, .{ + .repo_url = repo_url, + .repo_branch = repo_branch, + .initial_version = version, + }); + + cli.stderr.print_line("", .{}); + cli.stderr.printf_line("port created: {s}", .{new_port.name}, .{}); + cli.stderr.print_line("", .{}); + cli.stderr.print_line("Next steps:", .{}); + cli.stderr.print_line("(1) use `porteur port vars` to define variables", .{ .margin = 2 }); + cli.stderr.print_line("(2) use `porteur port update` to initially render the port", .{ .margin = 2 }); + cli.stderr.flush(); + } + } + }; + + const Port_Vars = struct { + pub const help = cli.Command_Help{ + .name = "porteur port vars", + .synopsis = &.{ + "porteur port [options] vars ⟨port⟩", + "porteur port [options] vars (--edit | -e) ⟨port⟩", + "porteur port [options] vars --import=⟨path⟩ ⟨port⟩", + }, + .short_description = "Manage variables for a port.", + .long_description = + \\Manage the variables for a port. Without a flag specified it lists all + \\existing variables for this port. To edit the variables either use the + \\--edit or the --import option. With the --edit option set, all existing + \\variables can be modified interactively using an editor. The --import + \\flag imports the variables from a file and overwrites all existing ones. + \\For more information on how to format the variables see the VARIABLES + \\section in `porteur --help`. + , + }; + options: struct { + edit: cli.Option(bool, .{ + .name = "edit", + .short_name = 'e', + .short_description = "Edit the port variables interactively.", + .long_description = + \\Edit the existing variables for the port interactively. An editor + \\will be invoked where the existing variables can be updated. After + \\exiting the editor the updates need to be confirmed. This flag is + \\mutual exclusive with --import. + , + }), + import: cli.Option([]const u8, .{ + .name = "import", + .argument_name = "path", + .short_description = "Import new port variables from the given file.", + .long_description = + \\Import the new variables for the port from the specified file. This + \\file must contain valid variable definitions. It will overwrite all + \\existing port variables. This flag is mutual exclusive with --edit. + , + }), + }, + + fn execute(self: Port_Vars, ctx: port.Ctx) !void { + const port_name = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing port name", .{}); + }; + + const edit = self.options.edit.value; + const import = self.options.import.value; + if (edit != null and import != null) { + cli.fatal("can either edit or import variables", .{}); + } + + if (edit) |_| { + try port.edit_vars(ctx, port_name); + } else if (import) |filename| { + try port.import_vars(ctx, port_name, .init(filename)); + } else { + const p = try port.must_get(ctx, port_name); + defer p.deinit(); + const vars = try port.read_vars(ctx, p); + defer vars.deinit(); + var iter = vars.iterate(); + while (iter.next()) |v| { + cli.stdout.write_line("{f}", .{v}); + } + cli.stdout.flush(); + } + } + }; + + const Port_Update = struct { + pub const help = cli.Command_Help{ + .name = "porteur port update", + .synopsis = &.{ + "porteur port [options] update [⟨port⟩ ...]", + }, + .short_description = "Update ports in a ports tree.", + .long_description = + \\This command updates one or more ports in the ports tree. For each port, it + \\checks if there is a new commit for the configured Git branch. If so, the + \\files in the ports tree will be generated from the template and the new + \\distribution files will be created. A Git commit is considered new if it has + \\a newer date than the last commit from which the port files were generated + \\or if the date is the same, but the commit hash is different. + \\ + \\If no port name is provided it updates all existing ports in the ports tree. + , + }; + options: struct { + force: cli.Option(bool, .{ + .name = "force", + .short_name = 'f', + .short_description = "Force the port update.", + .long_description = + \\Attempt to do the port update although the current commit for the branch + \\is not newer than the last one. This option forces a rerender for the + \\port based on the branch's latest commit. + , + .default_value = false, + }), + }, + + fn execute(self: Port_Update, ctx: port.Ctx) !void { + const opts: port.Update_Options = .{ + .force = self.options.force.value_or_default(), + }; + var count: usize = 0; + while (ctx.env.args.next_positional()) |port_name| { + count += 1; + try update_single_port(ctx, port_name, opts); + } + if (count == 0) { + var iter = try port.iterate(ctx); + defer iter.deinit(); + while (try iter.next()) |p| { + try update_single_port(ctx, p.name, opts); + } + } + } + + fn update_single_port(ctx: port.Ctx, port_name: []const u8, opts: port.Update_Options) !void { + if (try port.update_version(ctx, port_name, opts)) |new_version| { + cli.stderr.printf_line("updated {s} to {f}", .{ port_name, new_version }, .{}); + } else { + cli.stderr.printf_line("already up to date: {s}", .{port_name}, .{}); + } + cli.stderr.flush(); + } + }; + + const Port_Bump_Version = struct { + pub const help = cli.Command_Help{ + .name = "porteur port bump-version", + .synopsis = &.{ + "porteur port [options] bump-version [--major | --minor | --patch] ⟨port⟩", + }, + .short_description = "Increment the version of a port.", + .long_description = + \\This command increments the version of a port without updating its sources. + \\It is possible to increment the major version, the minor version, or the + \\patch version. By default, the patch version will be incremented. + , + }; + options: struct { + major: cli.Option(bool, .{ + .name = "major", + .short_description = "Increment the major version.", + .long_description = + \\Increment the major version of the port by one and reset the minor and patch + \\version to zero. This flag in mutual exclusive with --minor and --patch. + , + .default_value = false, + }), + minor: cli.Option(bool, .{ + .name = "minor", + .short_description = "Increment the minor version.", + .long_description = + \\Increment the minor version of the port by one and reset the patch version + \\to zero. The major version stays untouched. This flag in mutual exclusive + \\with --major and --patch. + , + .default_value = false, + }), + patch: cli.Option(bool, .{ + .name = "patch", + .short_description = "Increment the patch version.", + .long_description = + \\Increment the patch version of the port by one. The major and minior version + \\stays untouched. This flag in mutual exclusive with --major and --minor. + , + .default_value = false, + }), + }, + + fn execute(self: Port_Bump_Version, ctx: port.Ctx) !void { + const bump_major: u8 = @intFromBool(self.options.major.value_or_default()); + const bump_minor: u8 = @intFromBool(self.options.minor.value_or_default()); + const bump_patch: u8 = @intFromBool(self.options.patch.value_or_default()); + switch (bump_major + bump_minor + bump_patch) { + 0, 1 => {}, + else => cli.fatal("can either bump major, minor, or patch version", .{}), + } + + const port_name = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing port name", .{}); + }; + + const new_version = try port.bump_version(ctx, port_name, .{ + .which = if (bump_major != 0) .major else if (bump_minor != 0) .minor else .patch, + }); + + cli.stderr.printf_line("port version bumped to {f}", .{new_version}, .{}); + cli.stderr.flush(); + } + }; + + const Port_History = struct { + pub const help = cli.Command_Help{ + .name = "porteur port history", + .synopsis = &.{ + "porteur port [options] history ⟨port⟩", + }, + .short_description = "List all existing distfile version of a port.", + .long_description = + \\This command lists all existing distfile versions for the given port. + \\The number of distfiles kept can be configured in `porteur.conf`. + , + }; + options: struct { + long: cli.Option(bool, .{ + .name = "long", + .short_name = 'l', + .short_description = "List distfile version in the long format.", + .long_description = + \\List the distfile version in a longer format that also includes the + \\distfile locations. + , + .default_value = false, + }), + }, + + fn execute(self: Port_History, ctx: port.Ctx) !void { + const long = self.options.long.value_or_default(); + const port_name = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing port name", .{}); + }; + var distfiles = try port.list_distfiles(ctx, port_name); + defer distfiles.deinit(ctx.env.allocator); + + if (long) { + const version_strings = try ctx.env.allocator.alloc(port.Version_String, distfiles.items.len); + defer ctx.env.allocator.free(version_strings); + var max_version_len: usize = 0; + for (distfiles.items, 0..) |distfile, i| { + version_strings[i] = distfile.version.to_string(); + max_version_len = @max(max_version_len, version_strings[i].len); + } + + const spaces = [_]u8{' '} ** 64; + for (distfiles.items, version_strings) |distfile, version| { + const fill = spaces[0 .. max_version_len - version.len]; + cli.stdout.write_line("{s}{s} {s}", .{ version.slice(), fill, distfile.path.name() }); + } + } else { + for (distfiles.items) |d| { + cli.stdout.write_line("{f}", .{d.version}); + } + } + cli.stdout.flush(); + } + }; + + const Port_Rollback = struct { + pub const help = cli.Command_Help{ + .name = "porteur port rollback", + .synopsis = &.{ + "porteur port [options] rollback ⟨port⟩ ⟨version⟩", + }, + .short_description = "Roll back a port to a specific version.", + .long_description = + \\This command rolls back an existing ports to the given version. + \\The distfile of this version must still be in the port's history. + \\It asks for confirmation before rolling back a port. + , + }; + + fn execute(self: Port_Rollback, ctx: port.Ctx) !void { + _ = self; + const port_name = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing port name", .{}); + }; + const version_str = ctx.env.args.next_positional() orelse { + cli.fatal_with_usage(@This(), "missing version", .{}); + }; + const version = port.Version.parse(version_str) catch { + cli.fatal("incorrect version: {s}", .{version_str}); + }; + + try port.rollback(ctx, port_name, version); + } + }; + + const Port_Rm = struct { + pub const help = cli.Command_Help{ + .name = "porteur port rm", + .synopsis = &.{ + "porteur port [options] rm ⟨port⟩ [⟨port⟩ ...]", + }, + .short_description = "Remove ports from a ports tree.", + .long_description = + \\This command removes one or more existing ports from the ports tree. + \\It asks for confirmation before deleting a port. + , + }; + options: struct { + force: cli.Option(bool, .{ + .name = "force", + .short_name = 'f', + .short_description = "Do not prompt for confirmation.", + .long_description = + \\Attempt to remove ports without prompting for confirmation. If the port + \\does not exist, do not report an error. + , + .default_value = false, + }), + }, + + fn execute(self: Port_Rm, ctx: port.Ctx) !void { + const force = self.options.force.value_or_default(); + + var count: usize = 0; + var exit_code: u8 = 0; + while (ctx.env.args.next_positional()) |port_name| { + count += 1; + if (!force) { + const confirmed = cli.ask_confirmation("Remove port {s}?", .{port_name}, .no); + if (!confirmed) continue; + } + const deleted = try port.remove(ctx, port_name); + if (!deleted and !force) { + cli.stderr.printf_line("port not found: {s}", .{port_name}, .{}); + cli.stderr.flush(); + exit_code = 1; + } + } + if (count == 0 and !force) { + cli.fatal_with_usage(Port_Rm, "missing port name", .{}); + } else if (exit_code != 0) { + std.process.exit(exit_code); + } + } + }; +}; + +fn fatal_err(err: anyerror, comptime errmsg: []const u8) noreturn { + cli.fatal(errmsg ++ ": {s}", .{@errorName(err)}); +} + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/port.zig b/src/port.zig new file mode 100644 index 0000000..81aa034 --- /dev/null +++ b/src/port.zig @@ -0,0 +1,846 @@ +//! A port's configuration is stored file-based with the following +//! directory structure: +//! +//! * info - category, template +//! * src - repo, branch, commit hash, commit timestamp +//! * version - version, commit hash, commit timestamp +//! * vars - variables used by the port + +const std = @import("std"); +const assert = std.debug.assert; + +const cli = @import("cli.zig"); +const fs = @import("fs.zig"); +const conf = @import("conf.zig"); +const git = @import("git.zig"); +const tree = @import("tree.zig"); +const tmpl = @import("template.zig"); +const Env = @import("env.zig"); +const time = @import("time.zig"); +const shell = @import("shell.zig"); +const Time = time.Time; +const fatal = cli.fatal; + +const distname_ext = ".tar.gz"; + +pub const Ctx = struct { + env: Env, + tree: tree.Tree, +}; + +pub const Info = struct { + root: fs.Path, + info: conf.Config, + + name: []const u8, + category: []const u8, + template: []const u8, + + /// Create a new port info with the given values. Infos created with this function + /// must not call `deinit`. + pub fn init(t: tree.Tree, name: []const u8, category: []const u8, template: []const u8) Info { + assert(name.len > 0); + assert(category.len > 0); + assert(template.len > 0); + return .{ + .root = t.port_path(name), + .info = undefined, + .name = name, + .category = category, + .template = template, + }; + } + + pub fn deinit(self: Info) void { + self.info.deinit(); + } + + fn from_file(ctx: Ctx, port_name: []const u8) !?Info { + const port_root = ctx.tree.port_path(port_name); + const info_file: fs.Path = .join(.{ port_root.name(), "info" }); + + const info_res = conf.Config.from_file(ctx.env.allocator, info_file.name()) catch { + port_corrupted(port_name, "invalid info file"); + }; + const info_conf = switch (info_res) { + .conf => |c| c, + .err => port_corrupted(port_name, "invalid info file"), + }; + + var port_cat: ?[]const u8 = null; + var port_tmpl: ?[]const u8 = null; + + var info_conf_iter = info_conf.iterate(); + while (info_conf_iter.next()) |v| { + if (std.mem.eql(u8, v.key, "category")) { + port_cat = v.val; + } else if (std.mem.eql(u8, v.key, "template")) { + port_tmpl = v.val; + } + } + + return .{ + .root = port_root, + .info = info_conf, + .name = port_name, + .category = port_cat orelse port_corrupted(port_name, "missing category"), + .template = port_tmpl orelse port_corrupted(port_name, "missing template"), + }; + } + + fn to_file(self: Info) !void { + const path: fs.Path = .join(.{ self.root.name(), "info" }); + const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); + defer file.close(); + + var write_buf: [256]u8 = undefined; + var writer = file.writer(&write_buf); + inline for (&[_]conf.Var{ + .{ .key = "category", .val = self.category }, + .{ .key = "template", .val = self.template }, + }) |v| { + try v.format(&writer.interface); + try writer.interface.writeByte('\n'); + } + try writer.interface.flush(); + } + + fn version_file(self: Info) fs.Path { + return .join(.{ self.root.name(), "version" }); + } + + fn src_file(self: Info) fs.Path { + return .join(.{ self.root.name(), "src" }); + } + + fn vars_file(self: Info) fs.Path { + return .join(.{ self.root.name(), "vars" }); + } +}; + +pub const Version_String = struct { + buf: [64]u8, + len: usize, + + pub fn slice(self: *const Version_String) []const u8 { + return self.buf[0..self.len]; + } +}; + +pub const Version = packed struct { + revision: u32 = 0, + date: u32 = 0, // yyyymmdd or 0 + patch: u32, + minor: u16, + major: u16, + + /// Parse a version in the semver format "x.y.z", "x.y", or "x". + pub fn parse_semver(text: []const u8) !Version { + var major: []const u8 = text; + var minor: []const u8 = ""; + var patch: []const u8 = ""; + if (std.mem.indexOfScalar(u8, major, '.')) |dot1| { + minor = major[dot1 + 1 ..]; + major = major[0..dot1]; + if (std.mem.indexOfScalar(u8, minor, '.')) |dot2| { + patch = minor[dot2 + 1 ..]; + minor = minor[0..dot2]; + } + } + + return .{ + .major = parse_int(u16, major) orelse return error.invalid_version, + .minor = parse_int(u16, minor) orelse return error.invalid_version, + .patch = parse_int(u32, patch) orelse return error.invalid_version, + }; + } + + /// Parse the version as it is written to a file: 0.0.0[.d00000000[.r0]] + pub fn parse(text: []const u8) !Version { + const dot1 = std.mem.indexOfScalar(u8, text, '.') orelse return error.invalid_version; + const dot2 = std.mem.indexOfScalarPos(u8, text, dot1 + 1, '.') orelse return error.invalid_version; + const dot3 = std.mem.indexOfScalarPos(u8, text, dot2 + 1, '.') orelse text.len; + + const major = text[0..dot1]; + const minor = text[dot1 + 1 .. dot2]; + const patch = text[dot2 + 1 .. dot3]; + var date: ?[]const u8 = null; + var revision: ?[]const u8 = null; + if (dot3 < text.len) { + if (dot3 + 1 == text.len or text[dot3 + 1] != 'd') return error.invalid_version; + date = text[dot3 + 2 ..]; + if (std.mem.indexOfScalarPos(u8, text, dot3 + 2, '.')) |dot4| { + if (dot4 + 1 == text.len or text[dot4 + 1] != 'r') return error.invalid_version; + date = text[dot3 + 2 .. dot4]; + revision = text[dot4 + 2 ..]; + } + } + + return .{ + .major = parse_int(u16, major) orelse return error.invalid_version, + .minor = parse_int(u16, minor) orelse return error.invalid_version, + .patch = parse_int(u32, patch) orelse return error.invalid_version, + .date = if (date) |d| parse_int(u32, d) orelse return error.invalid_version else 0, + .revision = if (revision) |r| parse_int(u32, r) orelse return error.invalid_version else 0, + }; + } + + fn from_file(ctx: Ctx, info: Info) !Version { + const text = try fs.read_file(ctx.env.allocator, info.version_file()) orelse return error.missing_version; + defer ctx.env.dealloc(text); + + const s = std.mem.trim(u8, text, " \t\n"); + + var major: []const u8 = s; + var minor: []const u8 = ""; + var patch: []const u8 = ""; + if (std.mem.indexOfScalar(u8, major, '.')) |dot1| { + minor = major[dot1 + 1 ..]; + major = major[0..dot1]; + if (std.mem.indexOfScalar(u8, minor, '.')) |dot2| { + patch = minor[dot2 + 1 ..]; + minor = minor[0..dot2]; + } + } + + return try parse(std.mem.trim(u8, text, " \t\n")); + } + + fn to_file(self: Version, path: fs.Path) !void { + const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); + defer file.close(); + + var write_buf: [64]u8 = undefined; + var writer = file.writer(&write_buf); + try self.format(&writer.interface); + try writer.interface.writeByte('\n'); + try writer.interface.flush(); + } + + pub fn to_string(self: Version) Version_String { + var str: Version_String = undefined; + const printed = std.fmt.bufPrint(&str.buf, "{f}", .{self}) catch unreachable; + str.len = printed.len; + return str; + } + + pub fn format(self: Version, w: *std.io.Writer) std.io.Writer.Error!void { + try w.print("{}.{}.{}", .{ self.major, self.minor, self.patch }); + if (self.date != 0) { + try w.print(".d{}", .{self.date}); + if (self.revision != 0) { + try w.print(".r{}", .{self.revision}); + } + } + } + + fn less(left: Version, right: Version) bool { + return @as(u128, @bitCast(left)) < @as(u128, @bitCast(right)); + } + + fn date_from_timestamp(timestamp: u64) u32 { + const tm = Time.from_timestamp(timestamp); + const y: u32 = tm.year; + const m: u32 = tm.month; + const d: u32 = tm.day; + return y * 10000 + m * 100 + d; + } +}; + +pub const Source = struct { + raw: conf.Config, + + repo: git.Repo, + commit: git.Commit, + + fn from_file(ctx: Ctx, info: Info) !Source { + const src_file = info.src_file(); + const raw_res = try conf.Config.from_file(ctx.env.allocator, src_file.name()); + const raw = switch (raw_res) { + .conf => |c| c, + .err => port_corrupted(info.name, "invalid source file"), + }; + + var repo_url: ?[]const u8 = null; + var repo_branch: ?[]const u8 = null; + var repo_commit: ?[]const u8 = null; + + var iter = raw.iterate(); + while (iter.next()) |v| { + if (std.mem.eql(u8, v.key, "repo")) { + repo_url = v.val; + } else if (std.mem.eql(u8, v.key, "branch")) { + repo_branch = v.val; + } else if (std.mem.eql(u8, v.key, "commit")) { + repo_commit = v.val; + } + } + + const commit_str = repo_commit orelse port_corrupted(info.name, "missing commit"); + const commit = git.Commit.parse(commit_str) catch port_corrupted(info.name, "invalid commit"); + return .{ + .raw = raw, + .repo = .{ + .path = ctx.env.ports_src_path(ctx.tree.name, .{info.name}), + .url = repo_url orelse port_corrupted(info.name, "missing repo"), + .branch = repo_branch orelse port_corrupted(info.name, "missing branch"), + }, + .commit = commit, + }; + } + + fn to_file(self: Source, path: fs.Path) !void { + var commit_buf: [64]u8 = undefined; + var commit_str: std.io.Writer = .fixed(&commit_buf); + try commit_str.print("{f}", .{self.commit}); + + const file = try std.fs.createFileAbsoluteZ(path.name(), .{ .mode = 0o644 }); + defer file.close(); + + var write_buf: [256]u8 = undefined; + var writer = file.writer(&write_buf); + inline for (&[_]conf.Var{ + .{ .key = "repo", .val = self.repo.url }, + .{ .key = "branch", .val = self.repo.branch }, + .{ .key = "commit", .val = commit_str.buffered() }, + }) |v| { + try v.format(&writer.interface); + try writer.interface.writeByte('\n'); + } + try writer.interface.flush(); + } + + pub fn deinit(self: Source) void { + self.raw.deinit(); + } +}; + +pub const Iterator = struct { + ctx: Ctx, + dir: ?std.fs.Dir = null, + iter: ?std.fs.Dir.Iterator = null, + current: ?Info = null, + + pub fn deinit(self: *Iterator) void { + if (self.dir) |*dir| dir.close(); + if (self.current) |p| p.deinit(); + } + + pub fn next(self: *Iterator) !?Info { + if (self.current) |p| { + p.deinit(); + self.current = null; + } + if (self.iter) |*iter| { + while (try iter.next()) |entry| { + if (entry.kind == .directory) { + self.current = try .from_file(self.ctx, entry.name); + return self.current; + } + } + } + return null; + } +}; + +pub fn iterate(ctx: Ctx) !Iterator { + const dir = std.fs.openDirAbsoluteZ(ctx.tree.root.name(), .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) { + return .{ .ctx = ctx }; + } + return err; + }; + + return .{ + .ctx = ctx, + .dir = dir, + .iter = dir.iterate(), + }; +} + +pub fn exists(ctx: Ctx, port_name: []const u8) !bool { + const port_root = ctx.tree.port_path(port_name); + const info = try fs.dir_info(port_root); + return info.exists; +} + +pub fn get(ctx: Ctx, port_name: []const u8) !?Info { + return .from_file(ctx, port_name); +} + +pub fn must_get(ctx: Ctx, port_name: []const u8) !Info { + return try get(ctx, port_name) orelse { + fatal("port not found: {s}", .{port_name}); + }; +} + +pub fn read_version(ctx: Ctx, info: Info) !Version { + return .from_file(ctx, info); +} + +pub fn read_source(ctx: Ctx, info: Info) !Source { + return try .from_file(ctx, info); +} + +pub fn read_vars(ctx: Ctx, info: Info) !conf.Config { + const vars_file = info.vars_file(); + const conf_res = try conf.Config.from_file(ctx.env.allocator, vars_file.name()); + return switch (conf_res) { + .conf => |c| c, + .err => |e| { + ctx.env.err("{s} (line {d})", .{ e.msg, e.line }); + fatal("invalid variable file for port {s}", .{info.name}); + }, + }; +} + +pub const Create_Options = struct { + repo_url: []const u8, + repo_branch: []const u8, + initial_version: Version, +}; + +pub fn create(ctx: Ctx, info: Info, opts: Create_Options) !void { + const repo: git.Repo = .{ + .path = ctx.env.ports_src_path(ctx.tree.name, .{info.name}), + .url = opts.repo_url, + .branch = opts.repo_branch, + }; + + try repo.clone(ctx.env); + errdefer fs.rm(repo.path) catch {}; + + const source: Source = .{ + .raw = undefined, + .repo = repo, + .commit = .init_empty(), + }; + + const port_root = ctx.tree.port_path(info.name); + try fs.mkdir_all(port_root); + try info.to_file(); + try opts.initial_version.to_file(info.version_file()); + try source.to_file(info.src_file()); +} + +pub fn edit_vars(ctx: Ctx, port_name: []const u8) !void { + const info = try must_get(ctx, port_name); + defer info.deinit(); + try shell.edit_config_file_noverify(ctx.env, info.vars_file()); +} + +pub fn import_vars(ctx: Ctx, port_name: []const u8, new_vars_file: fs.Path) !void { + const info = try must_get(ctx, port_name); + defer info.deinit(); + + const parsed = try conf.Config.from_file(ctx.env.allocator, new_vars_file.name()); + switch (parsed) { + .conf => |c| c.deinit(), + .err => |e| { + ctx.env.err("{s} (line {d})", .{ e.msg, e.line }); + }, + } + + try fs.mv_file(new_vars_file, info.vars_file()); +} + +pub const Update_Options = struct { + force: bool, +}; + +/// Returns null if the port was up-to-date. +pub fn update_version(ctx: Ctx, port_name: []const u8, options: Update_Options) !?Version { + const info = try must_get(ctx, port_name); + defer info.deinit(); + + var source: Source = try .from_file(ctx, info); + defer source.deinit(); + + const old_version: Version = try .from_file(ctx, info); + const old_commit = source.commit; + const new_commit = try source.repo.update(ctx.env); + if (!options.force) { + if (new_commit.timestamp < old_commit.timestamp or std.mem.eql(u8, old_commit.hash, new_commit.hash)) { + return null; + } + } + + var new_version = old_version; + new_version.date = Version.date_from_timestamp(time.now()); + if (new_version.date == old_version.date) { + new_version.revision += 1; + } else { + assert(old_version.date < new_version.date); + new_version.revision = 0; + } + + source.commit = new_commit; + try source.to_file(info.src_file()); + try new_version.to_file(info.version_file()); + + try render_port(ctx, .{ + .info = info, + .version = new_version, + .source = source, + }); + return new_version; +} + +pub const Bump_Options = struct { + which: enum { major, minor, patch }, +}; + +pub fn bump_version(ctx: Ctx, port_name: []const u8, options: Bump_Options) !Version { + const info = try must_get(ctx, port_name); + defer info.deinit(); + + const version: Version = try .from_file(ctx, info); + + const source: Source = try .from_file(ctx, info); + defer source.deinit(); + + const new_version: Version = switch (options.which) { + .major => .{ .major = version.major + 1, .minor = 0, .patch = 0 }, + .minor => .{ .major = version.major, .minor = version.minor + 1, .patch = 0 }, + .patch => .{ .major = version.major, .minor = version.minor, .patch = version.patch + 1 }, + }; + try new_version.to_file(info.version_file()); + + try render_port(ctx, .{ + .info = info, + .version = new_version, + .source = source, + }); + return new_version; +} + +pub const Distfile = struct { + version: Version, + path: fs.Path, +}; + +pub fn list_distfiles(ctx: Ctx, port_name: []const u8) !std.ArrayList(Distfile) { + const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, port_name); + return try list_distfiles_sorted(ctx.env.allocator, port_name, dist_dir); +} + +pub fn rollback(ctx: Ctx, port_name: []const u8, old_version: Version) !void { + const info = try must_get(ctx, port_name); + defer info.deinit(); + + var version_buf: [64]u8 = undefined; + const version_str = std.fmt.bufPrint(&version_buf, "{f}", .{old_version}) catch unreachable; + + var distname_buf: [std.fs.max_name_bytes]u8 = undefined; + const distname = assemble_distname(&distname_buf, info.name, version_str); + var dist_file = ctx.env.ports_dist_dir(ctx.tree.name, info.name); + dist_file.append(.{distname}); + if (!try fs.file_exists(dist_file)) { + fatal("distfile {s} cannot be found", .{distname}); + } + + try render_port(ctx, .{ + .info = info, + .version = old_version, + .source = null, + }); +} + +pub fn remove(ctx: Ctx, port_name: []const u8) !bool { + const p = try get(ctx, port_name) orelse return false; + try remove_existing_port(ctx, p); + return true; +} + +pub inline fn remove_all(ctx: Ctx) !void { + var dir = std.fs.openDirAbsoluteZ(ctx.tree.root.name(), .{ .iterate = true }) catch |err| { + return switch (err) { + error.FileNotFound => return, + error.BadPathName, error.InvalidUtf8 => error.BadPathName, + error.NameTooLong => unreachable, + else => err, + }; + }; + defer dir.close(); + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + if (entry.kind == .directory) { + if (try get(ctx, entry.name)) |p| { + try remove_existing_port(ctx, p); + } + } + } +} + +fn remove_existing_port(ctx: Ctx, info: Info) !void { + const port_dir = ctx.env.ports_tree_path(ctx.tree.name, .{ info.category, info.name }); + const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, info.name); + const src_dir = ctx.env.ports_src_path(ctx.tree.name, .{info.name}); + try fs.rm(port_dir); + try fs.rm(dist_dir); + try fs.rm(src_dir); + try fs.rm(ctx.tree.port_path(info.name)); +} + +const Render_Options = struct { + info: Info, + version: Version, + source: ?Source, // null if it already exists +}; + +fn render_port(ctx: Ctx, opts: Render_Options) !void { + if (opts.source) |src| { + if (src.commit.hash.len == 0) { + ctx.env.info("port {s} does not reference a commit (skipping rendering)", .{opts.info.name}); + return; + } + } + + var version_buf: [64]u8 = undefined; + const version_str = std.fmt.bufPrint(&version_buf, "{f}", .{opts.version}) catch unreachable; + + var distname_buf: [std.fs.max_name_bytes]u8 = undefined; + const distname = assemble_distname(&distname_buf, opts.info.name, version_str); + + // While generating the port files we write to the following working directories: + // + // * port files: //ports//* + // * dist file: //dists/ + // + const port_dir = ctx.env.ports_tree_path(ctx.tree.name, .{ opts.info.category, opts.info.name }); + const dist_dir = ctx.env.ports_dist_dir(ctx.tree.name, opts.info.name); + const dist_file: fs.Path = .join(.{ dist_dir.name(), distname }); + const wrk_port_dir = ctx.env.work_path(.{ ctx.tree.name, "ports", opts.info.name }); + const wrk_dist_file = ctx.env.work_path(.{ ctx.tree.name, "dist", distname }); + try fs.rm(wrk_port_dir); + try fs.rm(wrk_dist_file.dir().?); + try fs.mkdir_all(wrk_port_dir); + try fs.mkdir_all(wrk_dist_file.dir().?); + + const template: tmpl.Template = .init(ctx.env, opts.info.template); + const template_conf = try tmpl.read_config(ctx.env, template); + defer template_conf.deinit(); + + const port_vars = try read_vars(ctx, opts.info); + defer port_vars.deinit(); + + var predefined_vars: conf.Config = .init(ctx.env.allocator); + defer predefined_vars.deinit(); + try predefined_vars.add_var(".portname", opts.info.name); + try predefined_vars.add_var(".portversion", version_str); + try predefined_vars.add_var(".category", opts.info.category); + try predefined_vars.add_var(".distdir", dist_dir.name()); + try predefined_vars.add_var(".distname", distname); + + var make_portsdir_buf: [std.fs.max_path_bytes]u8 = undefined; + var make_distdir_buf: [std.fs.max_path_bytes]u8 = undefined; + const make_portsdir = concat(&make_portsdir_buf, &.{ "PORTSDIR=", ctx.env.ports_dir }); + const make_distdir = concat(&make_distdir_buf, &.{ "DISTDIR=", dist_dir.name() }); + + ctx.env.info("===> {s}: writing port files", .{opts.info.name}); + const vars = [_]conf.Config{ predefined_vars, port_vars }; + try template.render(ctx.env, &vars, wrk_port_dir); + try fs.mv_dir(ctx.env.allocator, wrk_port_dir, port_dir); + + if (opts.source) |src| { + var prepared_dist = false; + if (template_conf.prepare_dist_target) |prepare_dist_target| { + ctx.env.info("===> {s}: prepare distribution", .{opts.info.name}); + + var make_target_buf: [std.fs.max_name_bytes]u8 = undefined; + const res = shell.exec(.{ + .env = ctx.env, + .argv = &.{ "make", "-V", concat(&make_target_buf, &.{ "${.ALLTARGETS:M", prepare_dist_target, "}" }), make_portsdir }, + .cwd = port_dir, + }); + const has_target = switch (res) { + .success => |out| has: { + defer ctx.env.dealloc(out); + const target_found = std.mem.trim(u8, out, " \t\n"); + break :has std.mem.eql(u8, target_found, prepare_dist_target); + }, + .failure => |out| { + ctx.env.err("failed to check for makefile target `{s}`\n", .{prepare_dist_target}); + fatal("{s}", .{out}); + }, + }; + if (has_target) { + var make_wrksrc_buf: [std.fs.max_path_bytes]u8 = undefined; + const make_wrksrc = concat(&make_wrksrc_buf, &.{ "WRKSRC=", src.repo.path.name() }); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "make", make_portsdir, make_distdir, make_wrksrc, prepare_dist_target }, + .cwd = port_dir, + }); + prepared_dist = true; + } else { + ctx.env.info("Target `{s}` not found (skipping)", .{prepare_dist_target}); + } + } + + ctx.env.info("===> {s}: creating distfiles", .{opts.info.name}); + var tar_rename_buf: [std.fs.max_path_bytes]u8 = undefined; + const tar_rename = concat(&tar_rename_buf, &.{ ",^,", distname[0 .. distname.len - distname_ext.len], "/," }); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "tar", "-czf", wrk_dist_file.name(), "-C", src.repo.path.name(), "--exclude-vcs", "-s", tar_rename, "." }, + }); + if (prepared_dist) src.repo.reset(ctx.env); + try fs.mv_file(wrk_dist_file, dist_file); + ctx.env.info("distfile: {s}", .{dist_file.name()}); + } + + ctx.env.info("===> {s}: creating distinfo", .{opts.info.name}); + shell.run(.{ + .env = ctx.env, + .argv = &.{ "make", make_portsdir, make_distdir, "makesum" }, + .cwd = port_dir, + }); + + if (opts.source != null) { + ctx.env.info("===> {s}: cleaning up old distfiles", .{opts.info.name}); + var history = list_distfiles_sorted(ctx.env.allocator, opts.info.name, dist_dir) catch |err| { + ctx.env.warn("cannot remove old distfiles from {s} ({s})", .{ dist_dir.name(), @errorName(err) }); + return; + }; + defer history.deinit(ctx.env.allocator); + + if (history.items.len > ctx.env.distfiles_history) { + var to_delete: usize = history.items.len - ctx.env.distfiles_history; + var i: usize = history.items.len; + while (i > 0 and to_delete > 0) { + i -= 1; + const dist: *const Distfile = &history.items[i]; + if (!std.mem.endsWith(u8, dist.path.name(), distname)) { + fs.rm(dist.path) catch |err| { + ctx.env.warn("cannot remove {s} ({s})", .{ dist.path.name(), @errorName(err) }); + }; + to_delete -= 1; + } + } + } + } +} + +/// Get a list of all distfiles sorted from new to old. +fn list_distfiles_sorted(allocator: std.mem.Allocator, port_name: []const u8, dist_dir: fs.Path) !std.ArrayList(Distfile) { + var iter = try fs.iterate_files(dist_dir) orelse return .empty; + defer iter.deinit(); + + var distfiles: std.ArrayList(Distfile) = .empty; + while (iter.next()) |distfile| { + if (std.mem.startsWith(u8, distfile, port_name) and std.mem.endsWith(u8, distfile, distname_ext)) { + const version = Version.parse(distfile[port_name.len + 1 .. distfile.len - distname_ext.len]) catch continue; + try distfiles.append(allocator, .{ + .version = version, + .path = .join(.{ dist_dir.name(), distfile }), + }); + } + } + + std.mem.sortUnstable(Distfile, distfiles.items, {}, struct { + pub fn gt(_: void, left: Distfile, right: Distfile) bool { + return Version.less(right.version, left.version); + } + }.gt); + return distfiles; +} + +/// Build a distname that matches FreeBSD's default value for ${DISTNAME}. +fn assemble_distname(buf: []u8, port_name: []const u8, version: []const u8) []const u8 { + return concat(buf, &.{ port_name, "-", version, distname_ext }); +} + +fn parse_int(comptime Int: type, text: []const u8) ?Int { + var res: Int = 0; + for (text) |c| { + if (c < '0' or c > '9') return null; + res *= 10; + res += @intCast(c - '0'); + } + return res; +} + +fn concat(buf: []u8, parts: []const []const u8) []const u8 { + var off: usize = 0; + for (parts) |part| { + @memcpy(buf[off .. off + part.len], part); + off += part.len; + } + return buf[0..off]; +} + +fn port_corrupted(port_name: []const u8, comptime reason: []const u8) noreturn { + fatal("port {s} corrupted (" ++ reason ++ ")", .{port_name}); +} + +test "parse version" { + var version: Version = undefined; + + version = try .parse("1.2.3"); + try std.testing.expectEqual(@as(u16, 1), version.major); + try std.testing.expectEqual(@as(u16, 2), version.minor); + try std.testing.expectEqual(@as(u16, 3), version.patch); + try std.testing.expectEqual(@as(u32, 0), version.date); + try std.testing.expectEqual(@as(u32, 0), version.revision); + + version = try .parse("3.2.1.d20180709"); + try std.testing.expectEqual(@as(u16, 3), version.major); + try std.testing.expectEqual(@as(u16, 2), version.minor); + try std.testing.expectEqual(@as(u16, 1), version.patch); + try std.testing.expectEqual(@as(u32, 20180709), version.date); + try std.testing.expectEqual(@as(u32, 0), version.revision); + + version = try .parse("2.1.3.d20210429.r7"); + try std.testing.expectEqual(@as(u16, 2), version.major); + try std.testing.expectEqual(@as(u16, 1), version.minor); + try std.testing.expectEqual(@as(u16, 3), version.patch); + try std.testing.expectEqual(@as(u32, 20210429), version.date); + try std.testing.expectEqual(@as(u32, 7), version.revision); +} + +test "compare version" { + var lhs: Version = undefined; + var rhs: Version = undefined; + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3 }; + try std.testing.expect(!Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 4 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 3, .patch = 3 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 2, .minor = 2, .patch = 3 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250429 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250709 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); + + lhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20250926, .revision = 1 }; + rhs = .{ .major = 1, .minor = 2, .patch = 3, .date = 20251222 }; + try std.testing.expect(Version.less(lhs, rhs)); + try std.testing.expect(!Version.less(rhs, lhs)); +} diff --git a/src/shell.zig b/src/shell.zig new file mode 100644 index 0000000..d361b09 --- /dev/null +++ b/src/shell.zig @@ -0,0 +1,191 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const cli = @import("cli.zig"); +const fs = @import("fs.zig"); +const conf = @import("conf.zig"); +const Env = @import("env.zig"); +const fatal = @import("cli.zig").fatal; + +pub const Exec_Args = struct { + env: Env, + argv: []const []const u8, + cwd: ?fs.Path = null, +}; + +pub const Exec_Result = union(enum) { + success: []const u8, + failure: []const u8, +}; + +pub fn exec(args: Exec_Args) Exec_Result { + assert(args.argv.len > 0); + + const res = std.process.Child.run(.{ + .allocator = args.env.allocator, + .argv = args.argv, + .cwd = if (args.cwd) |cwd| cwd.name() else null, + .cwd_dir = if (args.cwd == null) std.fs.cwd() else null, + }) catch |err| { + fatal("error executing {s}: {s}", .{ args.argv[0], @errorName(err) }); + }; + switch (res.term) { + .Exited => |code| { + if (code == 0) { + args.env.dealloc(res.stderr); + return .{ .success = res.stdout }; + } else if (res.stderr.len > 0) { + args.env.dealloc(res.stdout); + return .{ .failure = res.stderr }; + } else { + args.env.dealloc(res.stderr); + return .{ .failure = res.stdout }; + } + }, + .Signal => fatal("signal received executing {s}", .{args.argv[0]}), + .Stopped => fatal("stopped executing {s}", .{args.argv[0]}), + .Unknown => fatal("unknown error executing {s}", .{args.argv[0]}), + } +} + +pub fn run(args: Exec_Args) void { + assert(args.argv.len > 0); + + var child = std.process.Child.init(args.argv, args.env.allocator); + if (args.cwd) |cwd| child.cwd = cwd.name(); + child.cwd_dir = if (args.cwd == null) std.fs.cwd() else null; + child.spawn() catch |err| { + fatal("error executing {s}: {s}", .{ args.argv[0], @errorName(err) }); + }; + errdefer _ = child.kill() catch {}; + + const term = child.wait() catch |err| { + fatal("error waiting for {s}: {s}", .{ args.argv[0], @errorName(err) }); + }; + switch (term) { + .Exited => |code| if (code != 0) std.process.exit(code), + .Signal => fatal("signal received executing {s}", .{args.argv[0]}), + .Stopped => fatal("stopped executing {s}", .{args.argv[0]}), + .Unknown => fatal("unknown error executing {s}", .{args.argv[0]}), + } +} + +pub fn edit_config_file(env: Env, file: fs.Path, comptime verify: fn (env: Env, vars: conf.Config) bool) !void { + const tmp_file = tmp_edit_file(env, file); + + fs.cp_file(file, tmp_file) catch |err| { + switch (err) { + error.FileNotFound => try fs.touch(tmp_file), + else => return err, + } + }; + + edit: while (true) { + const vars: ?conf.Config = parse: while (true) { + try exec_editor(env, tmp_file); + const parsed = try conf.Config.from_file(env.allocator, file.name()); + switch (parsed) { + .conf => |c| break :parse c, + .err => |e| { + env.err("{s} (line {d})", .{ e.msg, e.line }); + switch (ask_edit_again()) { + .again => continue, + .cancel => break :parse null, + } + }, + } + }; + + if (vars) |v| { + defer v.deinit(); + if (verify(env, v)) { + try fs.mv_file(tmp_file, file); + return; + } + switch (ask_edit_again()) { + .again => continue, + .cancel => break :edit, + } + } else { + break :edit; + } + } + try fs.rm(tmp_file); +} + +pub fn edit_config_file_noverify(env: Env, file: fs.Path) !void { + try edit_config_file(env, file, struct { + fn noverify(_: Env, _: conf.Config) bool { + return true; + } + }.noverify); +} + +fn ask_edit_again() enum { again, cancel } { + while (true) { + var again_buf: [16]u8 = undefined; + const again = cli.ask("Edit again? (yes/no) [yes] ", .{}, &again_buf) orelse "yes"; + for (again, 0..) |c, i| again_buf[i] = std.ascii.toLower(c); + if (std.mem.eql(u8, again, "y") or std.mem.eql(u8, again, "yes")) { + return .again; + } + if (std.mem.eql(u8, again, "n") or std.mem.eql(u8, again, "no")) { + cli.stderr.print_line("cancelled (changes discarded)", .{}); + cli.stderr.flush(); + return .cancel; + } + } +} + +fn exec_editor(env: Env, file: fs.Path) !void { + const pid = std.posix.fork() catch |err| { + fatal("cannot fork editor: {s}", .{@errorName(err)}); + }; + + if (pid == 0) { + // editor process + var editor_env: [512:null]?[*:0]const u8 = undefined; + var editor_envlen: usize = 0; + const environ = if (std.os.environ.len < 512) std.os.environ else std.os.environ[0..511]; + for (environ) |e| { + editor_env[editor_envlen] = e; + editor_envlen += 1; + } + editor_env[editor_envlen] = null; + + var configured_editor_buf: [std.fs.max_path_bytes]u8 = undefined; + var configured_editor: ?[:0]u8 = null; + if (env.editor_cmd) |cmd| { + @memcpy(configured_editor_buf[0..cmd.len], cmd); + configured_editor_buf[cmd.len] = 0; + configured_editor = configured_editor_buf[0..cmd.len :0]; + } + + const editor = configured_editor orelse std.posix.getenv("VISUAL") orelse std.posix.getenv("EDITOR") orelse "vi"; + const editor_argv: [*:null]const ?[*:0]const u8 = &.{ editor, file.name(), null }; + std.posix.execvpeZ(editor_argv[0].?, editor_argv, &editor_env) catch unreachable; + std.process.exit(1); + } + + _ = std.posix.waitpid(pid, 0); +} + +/// Derive the filename for the temporary file of `file`. +/// The filename is "{relname}.{timestamp}", where `relname` is the relative +/// part of file regarding to etc. +fn tmp_edit_file(env: Env, file: fs.Path) fs.Path { + const relname = file.relname(env.etc_path(.{})).?; + + var tmp_filename_buf: [std.fs.max_name_bytes]u8 = undefined; + var tmp_filename_len: usize = 0; + + tmp_filename_len += std.fmt.printInt(tmp_filename_buf[tmp_filename_len..], std.time.timestamp(), 16, .lower, .{}); + tmp_filename_buf[tmp_filename_len] = '.'; + tmp_filename_len += 1; + for (relname) |c| { + tmp_filename_buf[tmp_filename_len] = if (c == std.fs.path.sep) '_' else c; + tmp_filename_len += 1; + } + + return env.work_path(.{ "edit", tmp_filename_buf[0..tmp_filename_len] }); +} 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()); +} diff --git a/src/time.zig b/src/time.zig new file mode 100644 index 0000000..290c9de --- /dev/null +++ b/src/time.zig @@ -0,0 +1,134 @@ +const std = @import("std"); + +pub const Time = struct { + year: u16, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + + pub fn from_timestamp(timestamp: u64) Time { + // Source: https://git.musl-libc.org/cgit/musl/tree/src/time/__secs_to_tm.c + // Copyright: https://git.musl-libc.org/cgit/musl/tree/COPYRIGHT + + const leapoch = 946684800 + 86400 * (31 + 29); // 2000-03-01 (mod 400 year, immediately after feb29) + const days_per_400y = 365 * 400 + 97; + const days_per_100y = 365 * 100 + 24; + const days_per_4y = 365 * 4 + 1; + const days_in_month = [_]i64{ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, 31, 29 }; + + var days: i64 = undefined; + var remsecs: i64 = undefined; + if (timestamp < leapoch) { + const secs: i64 = @as(i64, @intCast(timestamp)) - leapoch; + days = @divTrunc(secs, 86400); + remsecs = @rem(secs, 86400); + } else { + const secs: u64 = timestamp - leapoch; + days = @intCast(secs / 86400); + remsecs = @intCast(@rem(secs, 86400)); + } + if (remsecs < 0) { + remsecs += 86400; + days -= 1; + } + + var qc_cycles = @divTrunc(days, days_per_400y); + var remdays = @rem(days, days_per_400y); + if (remdays < 0) { + remdays += days_per_400y; + qc_cycles = -1; + } + + var c_cycles = @divTrunc(remdays, days_per_100y); + if (c_cycles == 4) { + c_cycles -= 1; + } + remdays -= c_cycles * days_per_100y; + + var q_cycles = @divTrunc(remdays, days_per_4y); + if (q_cycles == 25) { + q_cycles -= 1; + } + remdays -= q_cycles * days_per_4y; + + var remyears = @divTrunc(remdays, 365); + if (remyears == 4) { + remyears -= 1; + } + remdays -= remyears * 365; + + var years = remyears + 4 * q_cycles + 100 * c_cycles + 400 * qc_cycles; + var months: u64 = 0; + while (remdays >= days_in_month[months]) { + remdays -= days_in_month[months]; + months += 1; + } + + if (months < 10) { + months += 3; + } else { + months -= 9; + years += 1; + } + + return .{ + .year = @intCast(years + 2000), + .month = @intCast(months), + .day = @intCast(remdays + 1), + .hour = @intCast(@divTrunc(remsecs, 3600)), + .minute = @intCast(@rem(@divTrunc(remsecs, 60), 60)), + .second = @intCast(@rem(remsecs, 60)), + }; + } + + pub fn format(self: Time, w: *std.io.Writer) std.io.Writer.Error!void { + try w.print( + "{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} UTC", + .{ self.year, self.month, self.day, self.hour, self.minute, self.second }, + ); + } +}; + +pub fn now() u64 { + const ts = std.posix.clock_gettime(.REALTIME) catch return 0; + return if (ts.sec < 0) 0 else @intCast(ts.sec); +} + +test "time from timestamp" { + var t: Time = undefined; + const leapoch = 946684800 + 86400 * (31 + 29); + + t = .from_timestamp(0); + try std.testing.expectEqual(1970, t.year); + try std.testing.expectEqual(1, t.month); + try std.testing.expectEqual(1, t.day); + try std.testing.expectEqual(0, t.hour); + try std.testing.expectEqual(0, t.minute); + try std.testing.expectEqual(0, t.second); + + t = .from_timestamp(leapoch - 1); + try std.testing.expectEqual(2000, t.year); + try std.testing.expectEqual(2, t.month); + try std.testing.expectEqual(29, t.day); + try std.testing.expectEqual(23, t.hour); + try std.testing.expectEqual(59, t.minute); + try std.testing.expectEqual(59, t.second); + + t = .from_timestamp(leapoch); + try std.testing.expectEqual(2000, t.year); + try std.testing.expectEqual(3, t.month); + try std.testing.expectEqual(1, t.day); + try std.testing.expectEqual(0, t.hour); + try std.testing.expectEqual(0, t.minute); + try std.testing.expectEqual(0, t.second); + + t = .from_timestamp(leapoch + 1); + try std.testing.expectEqual(2000, t.year); + try std.testing.expectEqual(3, t.month); + try std.testing.expectEqual(1, t.day); + try std.testing.expectEqual(0, t.hour); + try std.testing.expectEqual(0, t.minute); + try std.testing.expectEqual(1, t.second); +} diff --git a/src/tree.zig b/src/tree.zig new file mode 100644 index 0000000..8599c7c --- /dev/null +++ b/src/tree.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const assert = std.debug.assert; + +const fs = @import("fs.zig"); +const Env = @import("env.zig"); + +pub const Tree = struct { + name: []const u8, + root: fs.Path, + + pub fn port_path(self: Tree, port_name: []const u8) fs.Path { + return .join(.{ self.root.name(), port_name }); + } +}; + +pub const Iterator = struct { + root: 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) !?Tree { + if (self.iter) |*iter| { + while (try iter.next()) |entry| { + if (entry.kind == .directory) { + return .{ + .name = entry.name, + .root = .join(.{ self.root.name(), entry.name }), + }; + } + } + } + return null; + } +}; + +pub inline fn iterate(env: Env) !Iterator { + const root = tree_root_dir(env); + const dir = std.fs.openDirAbsoluteZ(root.name(), .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) return .{ .root = undefined }; + return err; + }; + return .{ + .root = root, + .dir = dir, + .iter = dir.iterate(), + }; +} + +pub fn get(env: Env, tree_name: []const u8) !?Tree { + var tree_root = tree_root_dir(env); + tree_root.append(.{tree_name}); + return .{ .name = tree_name, .root = tree_root }; +} + +pub fn remove(env: Env, tree: Tree) !void { + try fs.rm(tree.root); + fs.rm(env.ports_tree_path(tree.name, .{})) catch {}; + fs.rm(env.ports_src_path(tree.name, .{})) catch {}; +} + +fn tree_root_dir(env: Env) fs.Path { + return env.etc_path(.{"ports"}); +} -- cgit v1.2.3