diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b285d2d..2029d34 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,40 +26,29 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: haskell-actions/setup@v2 + - uses: mlugg/setup-zig@v1 with: - ghc-version: "9.4.8" - cabal-version: "3.10.3.0" - - name: Install dependencies - if: runner.os == 'Windows' - run: | - choco install upx + version: 0.14.0-dev.2563+af5e73172 + - name: Build - run: | - cabal update - cabal configure \ - --enable-optimization=2 \ - --enable-static --enable-executable-static \ - --enable-executable-stripping - cabal build - echo "binpath=$(cabal list-bin maid)" >> $GITHUB_ENV + run: zig build-exe src/main.zig --name maid + - name: Publish if: runner.os == 'Linux' run: | - pkg=maid-$("$binpath" -q version) - dstdir=$pkg "$binpath" install - strip "$pkg/bin/maid" - tar --remove-files -cf "$pkg.tar.gz" "$pkg" + pkg=maid-$(./maid -q version).tar.gz + ./maid install --dstdir=package + tar --remove-files -cf "$pkg" package - gh release upload "$GITHUB_REF_NAME" "$pkg.tar.gz" + gh release upload "$GITHUB_REF_NAME" "$pkg" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish if: runner.os == 'Windows' run: | - exe=maid-$("$binpath" -q version).exe - cp "$binpath" "$exe" - strip "$exe" && upx "$exe" + exe=maid-$(./maid.exe -q version).exe + cp maid.exe "$exe" gh release upload "$GITHUB_REF_NAME" "$exe" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8e1113..7f26e38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,31 +24,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: haskell-actions/setup@v2 + - uses: mlugg/setup-zig@v1 with: - ghc-version: "9.4.8" - cabal-version: "3.10.3.0" - - name: Install dependencies - if: runner.os == 'Windows' - run: | - choco install upx + version: 0.14.0-dev.2563+af5e73172 + - name: Build - run: | - cabal update - cabal configure \ - --enable-optimization=2 \ - --enable-static --enable-executable-static \ - --enable-executable-stripping - cabal build - echo "binpath=$(cabal list-bin maid)" >> $GITHUB_ENV - - name: Check binary size - run: | - cp "$binpath" maid - strip maid -o maid-s - upx maid-s -o maid-u - echo "No strip, no compression" - du -hs maid - echo "Stripped, no compression" - du -hs maid-s - echo "Stripped, compressed" - du -hs maid-u + run: zig build-exe src/main.zig --name maid diff --git a/README.md b/README.md index 64b04f5..a557ff3 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Of course, you can use maid to run its own tasks! Build the executable ```sh -cabal build maid +zig build-exe src/main.zig --name maid ``` ```` @@ -76,7 +76,7 @@ Of course, you can use maid to run its own tasks! Build the executable ```sh -cabal build maid +zig build-exe src/main.zig --name maid ``` ### install @@ -84,26 +84,17 @@ cabal build maid Install project onto `$dstdir` ```sh -install -Dm755 "$(cabal list-bin maid)" "$dstdir/bin/maid" -install -Dm644 LICENSE -t "$dstdir/share/licenses/maid" -install -Dm644 extras/completion/zsh "$dstdir/share/zsh/site-functions/_maid" +install -Dm755 maid "$dstdir/bin/maid" +install -Dm644 LICENSE -t "$dstdir/share/licenses/maid" +install -Dm644 extras/completion/zsh "$dstdir/share/zsh/site-functions/_maid" install -Dm644 extras/completion/fish "$dstdir/share/fish/vendor_completions.d/maid.fish" install -Dm644 extras/completion/bash "$dstdir/share/bash-completion/completions/maid" -ln -s maid "$dstdir/bin/made" ``` ### version Display the current version -This is used in build scripts - -```hs -import Data.Maybe -import qualified Data.Text as T -import qualified Data.Text.IO as I - -main = I.readFile "maid-build.cabal" - >>= (I.putStrLn . T.strip) - . (head . mapMaybe (T.stripPrefix $ T.pack "version:") . T.lines) +```sh +git describe --abbrev=0 ``` diff --git a/src/Parser.zig b/src/Parser.zig new file mode 100644 index 0000000..ef169f3 --- /dev/null +++ b/src/Parser.zig @@ -0,0 +1,147 @@ +alloc: std.mem.Allocator, +line: std.ArrayList(u8), +block: std.ArrayList(u8), + +headingNr: usize = 0, +fenceNr: usize = 0, +lineNr: usize = 0, +lang: ?Task.Lang = null, + +pub fn init(alloc: std.mem.Allocator) !Parser { + const line = try std.ArrayList(u8).initCapacity(alloc, 32); + const block = try std.ArrayList(u8).initCapacity(alloc, 128); + + return .{ .alloc = alloc, .line = line, .block = block }; +} + +pub fn deinit(self: *Parser) void { + self.line.deinit(); + self.block.deinit(); +} + +pub fn parseMarkdown(self: *Parser, file: anytype) !std.ArrayList(Task) { + var tasks = std.ArrayList(Task).init(self.alloc); + var task: Task = undefined; + + var tasksHeading: usize = 0; + var state: enum { tasklist, magic, task, desc, code } = .tasklist; + + while (self.nextBlock(file) catch null) |_| sw: switch (state) { + .tasklist => { + if (self.headingNr == 0) continue; + + state = .magic; + tasksHeading = self.headingNr; + }, + .magic => { + state = if (isMagic(self.block.items)) .task else .tasklist; + }, + .task => { + if (self.headingNr == 0) continue; + if (self.headingNr <= tasksHeading) break; + + state = .desc; + task.name = try std.ascii.allocLowerString(self.alloc, self.block.items); + }, + .desc => { + if (self.headingNr > 0) continue :sw .task; + + state = .code; + task.desc = try self.alloc.dupe(u8, if (self.fenceNr == 0) + trim(u8, self.block.items, " \t") + else + "[No description]"); + continue :sw state; + }, + .code => { + if (self.headingNr > 0) continue :sw .task; + if (self.fenceNr == 0) continue; + + state = .task; + task.code.lang = self.lang orelse return error.UnknownLanguage; + task.code.text = try self.alloc.dupe(u8, self.block.items); + try tasks.append(task); + }, + }; + + return tasks; +} + +fn nextBlock(self: *Parser, file: anytype) !void { + self.block.clearRetainingCapacity(); + + while (true) { + try self.nextLine(file); + if (!isBlank(self.line.items)) break; + } + + self.headingNr = heading(self.line.items); + self.fenceNr = fence(self.line.items); + + if (self.headingNr > 0) { + try self.block.appendSlice(trim(u8, self.line.items[self.headingNr..], " \t")); + } else if (self.fenceNr > 0) { + self.lang = Task.Lang.fromId(trim(u8, self.line.items[self.fenceNr..], " \t")); + + while (true) : ({ + try self.block.append('\n'); + try self.block.appendSlice(self.line.items); + }) { + try self.nextLine(file); + if (fence(self.line.items) >= self.fenceNr) break; + } + } else { + try self.block.appendSlice(self.line.items); + + while (true) : ({ + try self.block.append(' '); + try self.block.appendSlice(self.line.items); + }) { + try self.nextLine(file); + if (isBlank(self.line.items)) break; + } + } +} + +fn nextLine(self: *Parser, file: anytype) !void { + self.line.clearRetainingCapacity(); + self.lineNr += 1; + + return file.streamUntilDelimiter(self.line.writer(), '\n', null) catch |err| + if (self.line.items.len == 0) err; +} + +fn isMagic(text: []u8) bool { + return std.mem.startsWith(u8, text, ""); +} + +fn isBlank(text: []u8) bool { + for (text) |c| if (c != ' ' and c != '\t') return false; + + return true; +} + +fn fence(text: []u8) usize { + if (text.len < 3) + return 0; + if (text[0] == '`' or text[0] == '~') { + for (text[1..], 1..) |c, i| { + if (c != text[0]) return if (i < 3) 0 else i; + } + return text.len; + } + return 0; +} + +fn heading(text: []u8) usize { + for (text, 0..) |c, i| { + if (i > 6) return 0; + if (c != '#') return i; + } + return 0; +} + +const std = @import("std"); +const trim = std.mem.trim; +const Parser = @This(); +const Task = @import("Task.zig"); diff --git a/src/Style.zig b/src/Style.zig new file mode 100644 index 0000000..da8c43f --- /dev/null +++ b/src/Style.zig @@ -0,0 +1,46 @@ +primary: Color, +secondary: Color, +reset: Color, +err: Color, + +pub fn default() Self { + return if (std.process.hasEnvVarConstant("NO_COLOR") or + !std.io.getStdOut().getOrEnableAnsiEscapeSupport()) + .{ + .primary = .none, + .secondary = .none, + .reset = .none, + .err = .none, + } + else + .{ + .primary = Color.make("95;1"), + .secondary = Color.make("0;1"), + .reset = Color.make(""), + .err = Color.make("31;1"), + }; +} + +pub const Color = union(enum) { + esc: []const u8, + none, + + fn make(esc: []const u8) Color { + return .{ .esc = esc }; + } + + pub fn format( + self: Color, + comptime _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + switch (self) { + .esc => |esc| try std.fmt.format(writer, "\x1b[{s}m", .{esc}), + else => {}, + } + } +}; + +const std = @import("std"); +const Self = @This(); diff --git a/src/Task.zig b/src/Task.zig new file mode 100644 index 0000000..80a1860 --- /dev/null +++ b/src/Task.zig @@ -0,0 +1,21 @@ +name: []u8, +desc: []u8, +code: struct { lang: Lang, text: []u8 }, + +pub const Lang = enum { + shell, + haskell, + javascript, + + pub fn fromId(id: []const u8) ?Lang { + return langIds.get(id); + } +}; + +const langIds = std.StaticStringMap(Lang).initComptime(.{ + .{ "sh", .shell }, .{ "bash", .shell }, + .{ "hs", .haskell }, .{ "haskell", .haskell }, + .{ "js", .javascript }, .{ "javascript", .javascript }, +}); + +const std = @import("std"); diff --git a/src/getopt.zig b/src/getopt.zig new file mode 100644 index 0000000..082ba3c --- /dev/null +++ b/src/getopt.zig @@ -0,0 +1,255 @@ +//! getopt(3)-style command-line parsing +//! +//! https://git.transistor.house/rini/getopt.zig +//! +//! COPYRIGHT +//! This file is provided in the same terms as Zig itself. +//! +//! https://github.com/ziglang/zig/blob/master/LICENSE + +pub var no_permute = false; + +pub fn parse(comptime T: type, args: anytype, opts: anytype) Parser(T, @TypeOf(args), opts) { + return .{ .args = args }; +} + +pub fn parseAlloc( + comptime T: type, + alloc: std.mem.Allocator, + args: anytype, + opts: anytype, +) ![]T { + const buffer = try alloc.alloc(T, args.len); + var parser = parse(T, args, opts); + + var i: usize = 0; + while (try parser.next()) |opt| : (i += 1) + buffer[i] = opt; + + return try alloc.realloc(buffer, i); +} + +const ArgDescr = enum { noArg, optArg, reqArg }; + +const OptDescr = struct { + short: ?u8, + long: ?[]const u8, + field: []const u8, + arg: ArgDescr, +}; + +pub const ParseError = error{ + RequiredArgument, + UnknownOption, + UnexpectedArgument, +}; + +pub const ErrorMessage = struct { + err: ParseError, + opt: []const u8, + + pub fn format( + self: ErrorMessage, + _: []const u8, + _: std.fmt.FormatOptions, + writer: anytype, + ) !void { + const prefix = if (self.opt.len > 1) "--" else "-"; + const args = .{ prefix, self.opt }; + + try switch (self.err) { + error.RequiredArgument => std.fmt.format(writer, "Option {s}{s} requires an argument\n", args), + error.UnknownOption => std.fmt.format(writer, "Unknown option {s}{s}\n", args), + error.UnexpectedArgument => std.fmt.format(writer, "Option {s}{s} doesn't allow arguments\n", args), + }; + } +}; + +fn Parser(comptime T: type, comptime Args: type, comptime opts: anytype) type { + comptime var descrs: [@divExact(opts.len, 3)]OptDescr = undefined; + comptime { + var i = 0; + while (i < opts.len) : (i += 3) { + const short = switch (opts[i + 0].len) { + 0 => null, + 1 => opts[i + 0][0], + else => unreachable, + }; + const long = switch (opts[i + 1].len) { + 0 => null, + 1 => unreachable, + else => opts[i + 1], + }; + const field = @tagName(opts[i + 2]); + const arg = switch (@FieldType(T, field)) { + ?[]const u8 => .optArg, + []const u8 => .reqArg, + void => .noArg, + else => unreachable, + }; + + descrs[@divExact(i, 3)] = + .{ .short = short, .long = long, .arg = arg, .field = field }; + } + } + + return struct { + const Self = @This(); + + args: Args, + index: usize = 0, + opt_index: usize = 0, + + errored_opt: []const u8 = &.{}, + + pub fn nextArg(self: *Self) ?[]const u8 { + if (self.index < self.args.len) { + defer self.index += 1; + return self.args[self.index]; + } + return null; + } + + pub fn next(self: *Self) ParseError!?T { + var index_shift: usize = 1; + defer self.index += index_shift; + + if (self.opt_index > 0 and self.opt_index + 1 >= self.args[self.index].len) { + self.opt_index = 0; + self.index += 1; + } + + if (self.index >= self.args.len) return null; + + if (self.args[self.index].len < 2 or self.args[self.index][0] != '-') { + index_shift = 0; + return null; + + // non-option; TODO: rotate arguments? ? ?? + } + + if (self.opt_index > 0 or self.args[self.index][1] != '-') { + self.opt_index += 1; + + // short option + const opt = self.args[self.index][self.opt_index]; + const arg = self.args[self.index][self.opt_index + 1 ..]; + + inline for (descrs) |desc| if (desc.short) |short| { + if (opt == short) switch (desc.arg) { + .reqArg => { + self.opt_index = 0; + if (arg.len > 0) { + return @unionInit(T, desc.field, arg); + } else if (self.index + 1 < self.args.len) { + self.index += 1; + return @unionInit(T, desc.field, self.args[self.index]); + } else { + self.errored_opt = &.{opt}; + return ParseError.RequiredArgument; + } + }, + .optArg => { + self.opt_index = 0; + return @unionInit(T, desc.field, if (arg.len > 0) arg else null); + }, + .noArg => { + index_shift = 0; + return @unionInit(T, desc.field, {}); + }, + }; + }; + + self.errored_opt = &.{opt}; + return ParseError.UnknownOption; + } + + if (self.args[self.index].len == 2) return null; // end of options + + // long option + var opt_ = self.args[self.index]; + var opt = opt_[2..opt_.len]; // might be sentinel array + var arg: ?[]const u8 = null; + + if (std.mem.indexOfScalar(u8, opt, '=')) |eq| { + arg = opt[eq + 1 ..]; + opt = opt[0..eq]; + } + + inline for (descrs) |desc| if (desc.long) |long| { + if (std.mem.eql(u8, opt, long)) switch (desc.arg) { + .reqArg => { + if (arg) |a| { + return @unionInit(T, desc.field, a); + } else if (self.index + 1 < self.args.len) { + self.index += 1; + return @unionInit(T, desc.field, self.args[self.index]); + } else { + self.errored_opt = opt; + return ParseError.RequiredArgument; + } + }, + .optArg => return @unionInit(T, desc.field, arg), + .noArg => { + if (arg) |_| { + self.errored_opt = opt; + return ParseError.UnexpectedArgument; + } else { + return @unionInit(T, desc.field, {}); + } + }, + }; + }; + + self.errored_opt = opt; + return ParseError.UnknownOption; + } + + pub fn errorMessage(self: *Self, err: ParseError) ErrorMessage { + return .{ .err = err, .opt = self.errored_opt }; + } + }; +} + +inline fn ArgType(T: type) ArgDescr { + return switch (T) { + ?[]const u8 => .optArg, + []const u8 => .reqArg, + void => .noArg, + else => unreachable, + }; +} + +test "getopt" { + const Opts = union(enum) { + help, + version, + output: []const u8, + }; + + const args = [_][]const u8{ + "-omeow.a", "-o", "meow.b", "--output=meow.c", "--output", "meow.d", "foo", + "--help", "-vvv", + }; + var opts = parse(Opts, args, .{ + "h", "help", .help, + "v", "version", .version, + "o", "output", .output, + }); + + try expectEqualDeep(opts.next(), Opts{ .output = "meow.a" }); + try expectEqualDeep(opts.next(), Opts{ .output = "meow.b" }); + try expectEqualDeep(opts.next(), Opts{ .output = "meow.c" }); + try expectEqualDeep(opts.next(), Opts{ .output = "meow.d" }); + try expectEqualDeep(opts.next(), null); + try expectEqualDeep(opts.nextArg(), "foo"); + try expectEqualDeep(opts.next(), Opts.help); + try expectEqualDeep(opts.next(), Opts.version); + try expectEqualDeep(opts.next(), Opts.version); + try expectEqualDeep(opts.next(), Opts.version); + try expectEqualDeep(opts.next(), null); + try expectEqualDeep(opts.nextArg(), null); +} + +const std = @import("std"); +const expectEqualDeep = std.testing.expectEqualDeep; diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..f6c519b --- /dev/null +++ b/src/main.zig @@ -0,0 +1,148 @@ +const options = + \\ -h --help Display this message + \\ -l --list List tasks concisely + \\ -n --dry-run Don't run anything, only display commands + \\ -q --quiet Don't display anything + \\ -f FILE --taskfile=FILE Use tasks in FILE +; + +const Opts = union(enum) { + help, + list, + dryRun, + quiet, + taskfile: []const u8, +}; + +var style: Style = undefined; + +inline fn fatal(fmt: []const u8, args: anytype) noreturn { + std.debug.print("{}error: {}", .{ style.err, style.secondary }); + std.debug.print(fmt, args); + std.debug.print("\n", .{}); + std.process.exit(1); +} + +pub fn main() !void { + style = Style.default(); + + const args = try std.process.argsAlloc(alloc); + defer std.process.argsFree(alloc, args); + + var doList = false; + var dryRun = false; + var quiet = false; + var taskName: ?[]const u8 = null; + var tasksArg: ?Taskfile = null; + var opts = getopt.parse(Opts, args, .{ + "h", "help", .help, + "l", "list", .list, + "n", "dry-run", .dryRun, + "q", "quiet", .quiet, + "f", "taskfile", .taskfile, + }); + + _ = opts.nextArg(); + + while (true) { + while (opts.next() catch |err| { + fatal("{}", .{opts.errorMessage(err)}); + }) |opt| switch (opt) { + .help => { + std.debug.print("{}Usage: {}maid [options] [task]\n\n", .{ style.primary, style.secondary }); + std.debug.print("{}Options:{}\n", .{ style.primary, style.reset }); + std.debug.print("{s}\n", .{options}); + return; + }, + .list => doList = true, + .dryRun => dryRun = true, + .quiet => quiet = true, + .taskfile => |f| tasksArg = try readTaskfile(std.fs.cwd(), f), + }; + + if (opts.nextArg()) |arg| { + if (taskName != null or doList) fatal("Unexpected argument {s}", .{arg}); + + taskName = arg; + } else { + const tasks = tasksArg orelse try findTaskfile(); + + if (doList) return listTasks(tasks); + if (taskName) |a| return runTask(tasks, a); + + std.debug.print("{}Tasks in {s}\n\n", .{ style.primary, tasks.path }); + + for (tasks.list) |task| { + std.debug.print("{} {s}\n", .{ style.secondary, task.name }); + std.debug.print("{} {s}\n\n", .{ style.reset, task.desc }); + } + + return; + } + } +} + +const Taskfile = struct { + path: []const u8, + list: []Task, +}; + +fn runTask(tasks: Taskfile, name: []const u8) !void { + const task = for (tasks.list) |task| { + if (std.mem.eql(u8, name, task.name)) + break task; + } else fatal("No such task: {s}", .{name}); + + std.debug.print("{}maid {s}{}", .{ style.secondary, name, style.reset }); + + switch (task.code.lang) { + .shell => {}, + .haskell => {}, + .javascript => {}, + } +} + +fn listTasks(tasks: Taskfile) !void { + const out = std.io.getStdOut().writer(); + + for (tasks.list) |task| + try out.print("{s} {s}\n", .{ task.name, task.desc }); +} + +fn readTaskfile(dir: std.fs.Dir, path: []const u8) !?Taskfile { + const file = try dir.openFile(path, .{}); + + var parser = try Parser.init(alloc); + defer parser.deinit(); + + var tasks = try parser.parseMarkdown(file.reader()); + defer tasks.deinit(); + + if (tasks.items.len == 0) return null; + + return .{ + .path = path, + .list = try tasks.toOwnedSlice(), + }; +} + +fn findTaskfile() !Taskfile { + var dir = std.fs.cwd(); + + for (0..16) |_| { + for ([_][]const u8{ "README.md", "CONTRIBUTING.md" }) |name| + return try readTaskfile(dir, name) orelse continue; + + dir = try dir.openDir("..", .{}); + } + + fatal("No taskfile", .{}); +} + +const alloc = std.heap.page_allocator; + +const std = @import("std"); +const getopt = @import("getopt.zig"); +const Parser = @import("Parser.zig"); +const Style = @import("Style.zig"); +const Task = @import("Task.zig");