diff --git a/build.zig b/build.zig index ca4bc89..0cdfc88 100644 --- a/build.zig +++ b/build.zig @@ -69,23 +69,23 @@ pub fn build(b: *std.Build) void { test_step.dependOn(&run_lib_unit_tests.step); test_step.dependOn(&run_exe_unit_tests.step); - const diff = b.addSystemCommand(&.{ - "git", - "diff", - "--cached", // see git_add comment - "--exit-code", + const snapshot_check_mod = b.createModule(.{ + .root_source_file = b.path("tools/check_snapshots.zig"), + .target = target, + .optimize = optimize, }); - diff.addDirectoryArg(b.path("snapshots/")); - - test_step.dependOn(&diff.step); - const git_add = b.addSystemCommand(&.{ - "git", - "add", - "snapshots/", + const snapshot_check = b.addExecutable(.{ + .name = "check-snapshots", + .root_module = snapshot_check_mod, }); - diff.step.dependOn(&git_add.step); + const run_snapshot_check = b.addRunArtifact(snapshot_check); + run_snapshot_check.stdio = .inherit; + run_snapshot_check.has_side_effects = true; + run_snapshot_check.addDirectoryArg(b.path("snapshots")); + run_snapshot_check.step.dependOn(&run_lib_unit_tests.step); + test_step.dependOn(&run_snapshot_check.step); } const std = @import("std"); diff --git a/snapshots/md/single_line_mixed.md b/snapshots/md/single_line_mixed.md index 44dbf9e..c157fa4 100644 --- a/snapshots/md/single_line_mixed.md +++ b/snapshots/md/single_line_mixed.md @@ -1 +1 @@ -**Hello**, world! *italic* underline [s]strike \ No newline at end of file +**Hello**, world! *italic* underline ~~strike~~ \ No newline at end of file diff --git a/snapshots/md/single_line_strikethrough.md b/snapshots/md/single_line_strikethrough.md new file mode 100644 index 0000000..3212354 --- /dev/null +++ b/snapshots/md/single_line_strikethrough.md @@ -0,0 +1 @@ +~~strike~~ \ No newline at end of file diff --git a/src/formatters/markdown.zig b/src/formatters/markdown.zig index 2eb0024..d3b405b 100644 --- a/src/formatters/markdown.zig +++ b/src/formatters/markdown.zig @@ -7,6 +7,7 @@ //! ## Supported Conversions //! - `[b]text[/b]` → `**text**` //! - `[i]text[/i]` → `*text*` +//! - `[s]text[/s]` → `~~text~~` //! - `[url=link]text[/url]` → `[text](link)` //! - `[email=addr]text[/email]` → `[text](mailto:addr)` //! - `[code]text[/code]` → `` `text` `` @@ -116,6 +117,7 @@ pub const MarkdownElement = enum { list, listItem, underline, + strikethrough, noOp, }; @@ -131,6 +133,7 @@ const element_map = std.StaticStringMap(MarkdownElement).initComptime(&.{ .{ "list", .list }, .{ "*", .listItem }, .{ "u", .underline }, + .{ "s", .strikethrough }, }); /// Renders a BBCode document as Markdown text. @@ -224,6 +227,7 @@ pub fn writeElement(node: Node, element: MarkdownElement, ctx: *const WriteConte .list => try writeListElement(node, ctx), .listItem => try writeListItemElement(node, ctx), .underline => try writeUnderlineElement(node, ctx), + .strikethrough => try writeStrikethroughElement(node, ctx), .noOp => try writeNoOpElement(node, ctx), } } @@ -342,6 +346,12 @@ pub fn writeItalicElement(node: Node, ctx: *const WriteContext) !void { try ctx.writer.writeAll("*"); } +pub fn writeStrikethroughElement(node: Node, ctx: *const WriteContext) !void { + try ctx.writer.writeAll("~~"); + try render(node, ctx); + try ctx.writer.writeAll("~~"); +} + pub fn writeCodeElement(node: Node, ctx: *const WriteContext) !void { try ctx.writer.writeAll("`"); try render(node, ctx); @@ -392,6 +402,31 @@ pub fn writeEmailElement(node: Node, ctx: *const WriteContext) !void { } } +test "strikethrough" { + const cases = [_]struct { + bbcode: []const u8, + markdown: []const u8, + }{ + .{ .bbcode = "[s]strike[/s]", .markdown = "~~strike~~" }, + .{ .bbcode = "before [s]strike[/s] after", .markdown = "before ~~strike~~ after" }, + .{ + .bbcode = "[s]strike [b]bold[/b] and [i]italic[/i][/s]", + .markdown = "~~strike **bold** and *italic*~~", + }, + }; + + for (cases) |case| { + var document = try Document.loadFromBuffer(testing.allocator, case.bbcode, .{}); + defer document.deinit(); + + var output = std.Io.Writer.Allocating.init(testing.allocator); + defer output.deinit(); + + try renderDocument(testing.allocator, document, &output.writer, .{}); + try testing.expectEqualStrings(case.markdown, output.written()); + } +} + test render { const bbcode_document = \\[b]Hello, World![/b] @@ -489,6 +524,20 @@ test "single lines" { try writer.flush(); } + { + var document = try Document.loadFromBuffer(testing.allocator, "[s]strike[/s]", .{}); + defer document.deinit(); + + var file = try std.Io.Dir.cwd().createFile(testing.io, "snapshots/md/single_line_strikethrough.md", .{}); + defer file.close(testing.io); + + var buf: [1024]u8 = undefined; + var file_writer = file.writer(testing.io, &buf); + var writer = &file_writer.interface; + try renderDocument(testing.allocator, document, writer, .{}); + try writer.flush(); + } + { var document = try Document.loadFromBuffer(testing.allocator, "[b]Hello[/b], world! [i]italic[/i] [u]underline[/u] [s]strike[/s]", .{}); defer document.deinit(); diff --git a/tools/check_snapshots.zig b/tools/check_snapshots.zig new file mode 100644 index 0000000..069941d --- /dev/null +++ b/tools/check_snapshots.zig @@ -0,0 +1,166 @@ +const std = @import("std"); + +const RepositoryKind = enum { jj, git, unknown }; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + var arena = std.heap.ArenaAllocator.init(init.gpa); + defer arena.deinit(); + const allocator = arena.allocator(); + const cwd = std.Io.Dir.cwd(); + + const repository_kind: RepositoryKind = repository_kind: { + cwd.access(io, ".jj", .{}) catch { + cwd.access(io, ".git", .{}) catch break :repository_kind .unknown; + break :repository_kind .git; + }; + break :repository_kind .jj; + }; + + switch (repository_kind) { + .jj => try checkJjSnapshots(allocator, io, cwd), + .git => try checkGitSnapshots(allocator, io), + .unknown => { + try fatal(io, "No .git or .jj directory found; cannot verify snapshots.\n", .{}); + std.process.exit(1); + }, + } +} + +fn checkGitSnapshots(allocator: std.mem.Allocator, io: std.Io) !void { + _ = try runCapture(allocator, io, &.{ "git", "add", "snapshots/" }); + _ = try runCapture(allocator, io, &.{ "git", "diff", "--cached", "--exit-code", "snapshots/" }); +} + +fn checkJjSnapshots(allocator: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir) !void { + const committed_snapshot_paths = try collectCommittedJjSnapshotPaths(allocator, io); + const worktree_snapshot_paths = try collectWorktreeSnapshotPaths(allocator, io, cwd); + + const comparePaths = struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.mem.lessThan(u8, lhs, rhs); + } + }.lessThan; + std.sort.pdq([]const u8, committed_snapshot_paths.items, {}, comparePaths); + std.sort.pdq([]const u8, worktree_snapshot_paths.items, {}, comparePaths); + + const snapshot_path_sets_match = match: { + if (committed_snapshot_paths.items.len != worktree_snapshot_paths.items.len) break :match false; + for (committed_snapshot_paths.items, worktree_snapshot_paths.items) |committed_path, worktree_path| { + if (!std.mem.eql(u8, committed_path, worktree_path)) break :match false; + } + break :match true; + }; + + if (!snapshot_path_sets_match) { + try fatal(io, "Snapshot file set differs from the current jj commit.\n", .{}); + try fatal(io, "Committed snapshots:\n", .{}); + for (committed_snapshot_paths.items) |path| try fatal(io, " {s}\n", .{path}); + try fatal(io, "Worktree snapshots:\n", .{}); + for (worktree_snapshot_paths.items) |path| try fatal(io, " {s}\n", .{path}); + std.process.exit(1); + } + + for (committed_snapshot_paths.items) |path| { + try compareJjSnapshot(allocator, io, cwd, path); + } +} + +fn collectCommittedJjSnapshotPaths(allocator: std.mem.Allocator, io: std.Io) !std.ArrayList([]const u8) { + const result = try runCapture(allocator, io, &.{ + "jj", + "--ignore-working-copy", + "--no-pager", + "file", + "list", + "-r", + "@", + "snapshots/", + }); + + var paths: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, result.stdout, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + try paths.append(allocator, try allocator.dupe(u8, line)); + } + + return paths; +} + +fn collectWorktreeSnapshotPaths(allocator: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir) !std.ArrayList([]const u8) { + var snapshots_dir = try cwd.openDir(io, "snapshots", .{ .iterate = true }); + defer snapshots_dir.close(io); + + var walker = try snapshots_dir.walk(allocator); + defer walker.deinit(); + + var paths: std.ArrayList([]const u8) = .empty; + while (try walker.next(io)) |entry| { + if (entry.kind != .file) continue; + + // jj paths always use '/', while the directory walker uses the platform separator. + const normalized_relative_path = try allocator.dupe(u8, entry.path); + if (std.fs.path.sep != '/') { + std.mem.replaceScalar(u8, normalized_relative_path, std.fs.path.sep, '/'); + } + + const snapshot_path = try std.mem.concat(allocator, u8, &.{ "snapshots/", normalized_relative_path }); + try paths.append(allocator, snapshot_path); + } + + return paths; +} + +fn compareJjSnapshot(allocator: std.mem.Allocator, io: std.Io, cwd: std.Io.Dir, path: []const u8) !void { + const expected_result = try runCapture(allocator, io, &.{ + "jj", + "--ignore-working-copy", + "--no-pager", + "file", + "show", + "-r", + "@", + path, + }); + const actual = try cwd.readFileAlloc(io, path, allocator, .unlimited); + + if (!std.mem.eql(u8, expected_result.stdout, actual)) { + try fatal(io, "Snapshot differs from the current jj commit: {s}\n", .{path}); + std.process.exit(1); + } +} + +const RunCaptureResult = struct { + stdout: []u8, + stderr: []u8, +}; + +fn runCapture(allocator: std.mem.Allocator, io: std.Io, argv: []const []const u8) !RunCaptureResult { + const result = try std.process.run(allocator, io, .{ + .argv = argv, + .stdout_limit = .unlimited, + .stderr_limit = .unlimited, + }); + + // TODO(zig-0.16): Use result.term.success() once Zig 0.16 is removed from CI. + const command_succeeded = switch (result.term) { + .exited => |code| code == 0, + else => false, + }; + if (!command_succeeded) { + try fatal(io, "Command failed: {s}\n", .{argv[0]}); + if (result.stdout.len > 0) try fatal(io, "{s}", .{result.stdout}); + if (result.stderr.len > 0) try fatal(io, "{s}", .{result.stderr}); + std.process.exit(1); + } + + return .{ .stdout = result.stdout, .stderr = result.stderr }; +} + +fn fatal(io: std.Io, comptime fmt: []const u8, args: anytype) !void { + var buf: [1024]u8 = undefined; + var writer = std.Io.File.stderr().writerStreaming(io, &buf); + try writer.interface.print(fmt, args); + try writer.interface.flush(); +}