From 8c4b8643af070fc70044a352a7804506490fa054 Mon Sep 17 00:00:00 2001 From: Yves Biener Date: Sat, 29 Nov 2025 12:19:07 +0100 Subject: [PATCH] WIP: first working version It still has a minor memory leak and has at least two hacks implemented that I would like to improve on. --- build.zig.zon | 4 +- src/elements.zig | 269 ++++++++++++++++++++++++++++++++ src/lexer.zig | 395 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 26 ++-- src/model.zig | 69 +++++++-- src/root.zig | 8 +- 6 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 src/elements.zig create mode 100644 src/lexer.zig diff --git a/build.zig.zon b/build.zig.zon index 99bede4..a039487 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,8 +3,8 @@ .version = "0.0.1", .dependencies = .{ .zterm = .{ - .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#855594a8c836723f0230bfd6ad24f47613a147b1", - .hash = "zterm-0.3.0-1xmmEM8eHAB0cA7KLXGC7C8Nt7YEJcyoTme4domF1Yty", + .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#e972a2ea0f7a9f8caffd439ef206474b46475f91", + .hash = "zterm-0.3.0-1xmmENkhHAB2rmNJFH-9rRqiRLnT673xwuMrqLwOnlT_", }, }, .minimum_zig_version = "0.16.0-dev.1254+bf15c791f", diff --git a/src/elements.zig b/src/elements.zig new file mode 100644 index 0000000..c0225ae --- /dev/null +++ b/src/elements.zig @@ -0,0 +1,269 @@ +//! Applications `Element` implementations to be used in *zterm* `Container`'s + +pub fn Root(App: type) type { + return struct { + gpa: Allocator, + diffs: std.ArrayListUnmanaged(App.Scrollable) = .empty, + container: *App.Container = undefined, + + pub fn init(gpa: Allocator) @This() { + return .{ .gpa = gpa }; + } + + fn deinit(ctx: *anyopaque) void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + for (this.diffs.items) |*scrollable| scrollable.element().deinit(); + this.diffs.deinit(this.gpa); + // the container is the root container, which is calling this function, do not deinitialize it! + } + + pub fn element(this: *@This()) App.Element { + return .{ + .ptr = this, + .vtable = &.{ + .deinit = deinit, + .handle = handle, + }, + }; + } + + fn handle(ctx: *anyopaque, _: *App.Model, event: App.Event) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + switch (event) { + // NOTE assume that there is at least one diff + .init => this.container.elements.items[this.container.elements.items.len - 1].element = this.diffs.items[0].element(), + .file => |idx| this.container.elements.items[this.container.elements.items.len - 1].element = this.diffs.items[idx].element(), + .quit => this.container.elements.items[this.container.elements.items.len - 1].element = .{}, + else => {}, + } + } + }; +} + +// NOTE base contents from the model! +// -> assume that the model does not change! it is created initially and then never changed! +pub fn Tree(App: type) type { + return struct { + scrollback: usize = 0, // scrollback index for the changed files + idx: usize = 0, // index for the currently selected entry + len: usize = 0, // total amount of changed files + size: Point = .{}, // current available size for the `Element` + queue: *App.Queue, + + pub fn init(queue: *App.Queue) @This() { + return .{ .queue = queue }; + } + + pub fn element(this: *@This()) App.Element { + return .{ + .ptr = this, + .vtable = &.{ + .resize = resize, + .handle = handle, + .content = content, + }, + }; + } + + fn resize(ctx: *anyopaque, _: *const App.Model, size: Point) void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + this.size = size; + } + + fn handle(ctx: *anyopaque, model: *App.Model, event: App.Event) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + switch (event) { + .init => { + this.len = model.changes.keys().len; + this.scrollback = 0; + this.idx = 0; + }, + // TODO also support key inputs to change the current file? + .mouse => |mouse| if (this.len > 0) switch (mouse.button) { + .left => if (mouse.y + this.scrollback < this.len) { + this.idx = mouse.y + this.scrollback; + this.queue.push(.{ .file = this.idx }); + }, + .wheel_up => this.scrollback -|= 3, + .wheel_down => this.scrollback = @min(this.len - 1, this.scrollback + 3), + else => {}, + }, + else => {}, + } + } + + fn content(ctx: *anyopaque, model: *const App.Model, cells: []Cell, size: Point) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); + if (this.len == 0) return; + + var cell_idx: usize = 0; + const changes: []const Model.Index = model.changes.keys(); + const files = changes[this.scrollback..]; + + for (0..size.y) |row| { + if (row >= files.len) break; + const idx = this.scrollback + row; + + const file = files[row]; + const value = model.content[file.idx .. file.idx + file.len]; + var value_idx: usize = 0; + if (value_idx >= value.len) break; + const row_color: zterm.Color = if (this.idx == idx) .blue else .default; + cell_idx = row * size.x; + for (0..size.x) |_| { + cell_idx += 1; + if (value_idx >= value.len) break; + const cp = value[value_idx]; + defer value_idx += 1; + + if (cell_idx >= cells.len) break; + switch (cp) { + '\n' => cell_idx += 1, + '\t' => cell_idx += 2, + else => {}, + } + cells[cell_idx].cp = switch (cp) { + '\n', '\t' => continue, + else => cp, + }; + cells[cell_idx].style.fg = row_color; + } + } + } + }; +} + +/// Individual `Change` associated to a parsed diff header. +pub fn Change(App: type) type { + return struct { + change: Model.Index, + // how does an individual `Change` knows it relation to the `Model` containing it's contents? + pub fn init(change: Model.Index) @This() { + return .{ .change = change }; + } + + pub fn element(this: *@This()) App.Element { + return .{ + .ptr = this, + .vtable = &.{ + .minSize = minSize, + .content = content, + }, + }; + } + + fn minSize(ctx: *anyopaque, model: *const App.Model, size: Point) Point { + const this: *@This() = @ptrCast(@alignCast(ctx)); + // TODO handle `\n`, '\t', etc. correctly in the fill for the `cells` + switch (model.render_mode) { + .side_by_side => {}, + .stacked => {}, + } + var y: u16 = 0; + const value = model.content[this.change.idx .. this.change.idx + this.change.len]; + if (this.change.idx + this.change.len == model.content.len) y += 1; + for (value) |cp| if (cp == '\n') { + y += 1; + }; + return .{ .x = size.x, .y = y }; + } + + fn content(ctx: *anyopaque, model: *const App.Model, cells: []Cell, size: Point) !void { + const this: *@This() = @ptrCast(@alignCast(ctx)); + assert(cells.len == @as(usize, size.x) * @as(usize, size.y)); + // TODO render different modes accordingly + var cell_idx: usize = 0; + const value = model.content[this.change.idx .. this.change.idx + this.change.len]; + var value_idx: usize = 0; + switch (model.render_mode) { + .side_by_side => for (0..size.y) |row| { + if (value_idx >= value.len) break; + const row_color: zterm.Color = switch (value[value_idx]) { + '+' => .green, + '-' => .red, + '@' => .blue, + else => .default, + }; + + cell_idx = row * size.x; + for (0..size.x) |_| { + cell_idx += 1; + if (value_idx >= value.len) break; + const cp = value[value_idx]; + defer value_idx += 1; + + if (cell_idx >= cells.len) break; + switch (cp) { + '\n' => {}, + '\t' => cell_idx += 2, + else => {}, + } + cells[cell_idx].cp = switch (cp) { + '\n' => break, + '\t' => continue, + else => cp, + }; + cells[cell_idx].style.fg = row_color; + } + }, + .stacked => for (0..size.y) |row| { + if (value_idx >= value.len) break; + const row_color: zterm.Color = switch (value[value_idx]) { + '+' => .green, + '-' => .red, + '@' => .blue, + else => .default, + }; + + cell_idx = row * size.x; + for (0..size.x) |_| { + cell_idx += 1; + if (value_idx >= value.len) break; + const cp = value[value_idx]; + defer value_idx += 1; + + if (cell_idx >= cells.len) break; + switch (cp) { + '\n' => {}, + '\t' => cell_idx += 2, + else => {}, + } + cells[cell_idx].cp = switch (cp) { + '\n' => break, + '\t' => continue, + else => cp, + }; + cells[cell_idx].style.fg = row_color; + } + }, + } + } + }; +} + +/// `Diff` describes a collection of `Change`'s (i.e. through a root `Container` inside an `App.Scrollable`) +pub fn Diff(App: type, gpa: Allocator, changes: []Change(App)) !App.Scrollable { + var diff: App.Container = try .init(gpa, .{ + .layout = .{ + .direction = .vertical, + .separator = .{ + .enabled = true, + }, + }, + }, .{}); + for (changes) |*change| try diff.append(try .init(gpa, .{}, change.element())); + return .init(diff, .disabled); +} + +var random_alg = std.Random.DefaultCsprng.init(@splat(1)); +var random = random_alg.random(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const zterm = @import("zterm"); +const Model = @import("model.zig"); + +const Cell = zterm.Cell; +const Point = zterm.Point; diff --git a/src/lexer.zig b/src/lexer.zig new file mode 100644 index 0000000..45a1c87 --- /dev/null +++ b/src/lexer.zig @@ -0,0 +1,395 @@ +///! Lexer for *unified diff* format to tokenize input sources accordningly. +pub const Token = struct { + tag: Tag, + loc: Location, + + pub const Location = struct { + idx: usize, + len: usize, + }; + + pub const Tag = enum(u8) { + /// File information; contains the content of: + /// ``` + /// --- a/xxx + /// --- b/xxx + /// ``` + /// *NOTE* includes trailing newline character + file, + /// Hunk header information; contains content of: + /// ```@@ -x,y +z,y @@``` + header, + /// may be diff content or filler content of the tools output + content, + /// invalid contents that could not be parsed correctly + invalid, + /// End of file + eof, + + pub fn lexeme(tag: Tag) ?[]const u8 { + return switch (tag) { + .header => "@@ -x,y +z,y @@", + .content => "..", + .file => "diff --git a/xxx b/xxx", + }; + } + + pub fn symbol(tag: Tag) []const u8 { + return tag.lexeme() orelse switch (tag) { + .eof => "EOF", + .invalid => "invalid", + else => unreachable, + }; + } + }; +}; + +pub const Tokenizer = struct { + buffer: [:0]const u8, + index: usize, + + /// For debugging purposes + pub fn dump(self: *const Tokenizer, token: *const Token) void { + print(".{s} \"{s}\"\n", .{ @tagName(token.tag), self.buffer[token.loc.idx .. token.loc.idx + token.loc.len] }); + } + + pub fn init(buffer: [:0]const u8) Tokenizer { + return .{ + .buffer = buffer, + // skip the UTF-8 BOM if present + .index = if (mem.startsWith(u8, buffer, "\xEF\xBB\xBF")) 3 else 0, + }; + } + + const State = enum { + default, + invalid, + at_sign, + minus, + header, + plus, + file, + }; + + /// state fsm (finite state machine) describing the syntax of `nf` + /// TODO I need to draw one for all the possible states for tokenization! + /// -> for that I can create test cases! + /// -> detect valid and invalid syntax uses! this is however the job of the parser? + /// + /// TODO points to improve on: + /// -> reduce duplicated code sections + /// -> make tags more explicit (i.e. remove unnecessary newlines, whitespaces, etc.) + /// -> streamline catching the common cases for tokens + /// -> reduce state machine + /// -> do not group tokens, instead this should be done by the parser when deriving the ast from the token stream + /// then the parser can identify missing parts and even point to the corresponding location in the file! + pub fn next(this: *Tokenizer) Token { + const token = this.next_token(); + this.index = token.loc.idx + token.loc.len; + return token; + } + + fn next_token(this: *const Tokenizer) Token { + var index = this.index; + var result: Token = .{ + .tag = undefined, + .loc = .{ + .idx = this.index, + .len = undefined, + }, + }; + state: switch (State.default) { + .default => switch (this.buffer[index]) { + 0 => if (index == this.buffer.len) { + if (result.loc.idx != index) { + result.tag = .content; + } else { + return .{ + .tag = .eof, + .loc = .{ + .idx = index, + .len = 0, + }, + }; + } + } else { + continue :state .invalid; + }, + '@' => continue :state .at_sign, + '-' => continue :state .minus, + else => { + index += 1; + continue :state .default; + }, + }, + .invalid => { + switch (this.buffer[index]) { + 0 => result.tag = .invalid, + else => { + index += 1; + result.tag = .invalid; + }, + } + }, + .at_sign => { + index += 1; + switch (this.buffer[index]) { + '@' => if (result.loc.idx != index - 1) { + index -= 1; + result.tag = .content; + } else continue :state .header, + else => continue :state .default, + } + }, + .header => { + index += 1; + switch (this.buffer[index]) { + '@' => if (this.buffer[index + 1] == '@') { + result.tag = .header; + index += 2; + } else continue :state .invalid, + 0 => continue :state .invalid, + else => continue :state .header, + } + }, + .minus => { + index += 1; + switch (this.buffer[index]) { + // assuming that we start with a minus! + '-' => if (this.buffer[index + 1] == '-') { + if (result.loc.idx != index - 1) { + index -= 1; + result.tag = .content; + } else { + index += 1; + continue :state .file; + } + } else continue :state .default, + 0 => continue :state .invalid, + else => continue :state .default, + } + }, + .file => { + // std.log.err(".file: {s}", .{this.buffer[index - 2 .. @min(index + 3, this.buffer.len)]}); + index += 1; + switch (this.buffer[index]) { + '+' => if (this.buffer[index + 1] == '+' and this.buffer[index + 2] == '+') { + index += 2; + continue :state .plus; + } else continue :state .file, + 0 => continue :state .invalid, + else => continue :state .file, + } + }, + .plus => { + // std.log.err(".plus", .{}); + index += 1; + switch (this.buffer[index]) { + '\n' => { + index += 1; // include newline + result.tag = .file; + }, + 0 => continue :state .invalid, + else => continue :state .plus, + } + }, + } + + result.loc.len = index - result.loc.idx; + return result; + } +}; + +const std = @import("std"); +const mem = std.mem; +const debug = std.debug; +const testing = std.testing; +const assert = debug.assert; +const print = debug.print; + +test "individual change" { + try testTokenize( + \\diff --git a/build.zig.zon b/build.zig.zon + \\index 99bede4..a039487 100644 + \\--- a/build.zig.zon + \\+++ b/build.zig.zon + \\@@ -3,8 +3,8 @@ + \\ .version = "0.0.1", + \\ .dependencies = .{ + \\ .zterm = .{ + \\- .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#855594a8c836723f0230bfd6ad24f47613a147b1", + \\- .hash = "zterm-0.3.0-1xmmEM8eHAB0cA7KLXGC7C8Nt7YEJcyoTme4domF1Yty", + \\+ .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#e972a2ea0f7a9f8caffd439ef206474b46475f91", + \\+ .hash = "zterm-0.3.0-1xmmENkhHAB2rmNJFH-9rRqiRLnT673xwuMrqLwOnlT_", + \\ }, + \\ }, + \\ .minimum_zig_version = "0.16.0-dev.1254+bf15c791f", + , &.{ .content, .file, .header, .content, .eof }); +} + +test "individual changes in the different files" { + try testTokenize( + \\diff --git a/build.zig.zon b/build.zig.zon + \\index 99bede4..a039487 100644 + \\--- a/build.zig.zon + \\+++ b/build.zig.zon + \\@@ -3,8 +3,8 @@ + \\ .version = "0.0.1", + \\ .dependencies = .{ + \\ .zterm = .{ + \\- .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#855594a8c836723f0230bfd6ad24f47613a147b1", + \\- .hash = "zterm-0.3.0-1xmmEM8eHAB0cA7KLXGC7C8Nt7YEJcyoTme4domF1Yty", + \\+ .url = "git+https://gitea.yves-biener.de/yves-biener/zterm#e972a2ea0f7a9f8caffd439ef206474b46475f91", + \\+ .hash = "zterm-0.3.0-1xmmENkhHAB2rmNJFH-9rRqiRLnT673xwuMrqLwOnlT_", + \\ }, + \\ }, + \\ .minimum_zig_version = "0.16.0-dev.1254+bf15c791f", + \\diff --git a/src/model.zig b/src/model.zig + \\index b402c51..defd874 100644 + \\--- a/src/model.zig + \\+++ b/src/model.zig + \\@@ -30,3 +30,9 @@ pub const Change = struct { + \\ + \\ const Model = @This(); + \\ const std = @import("std"); + \\+const lexer = @import("lexer.zig"); + \\+ + \\+test { + \\+ std.testing.refAllDeclsRecursive(@This()); + \\+ _ = @import("lexer.zig"); + \\+} + , &.{ .content, .file, .header, .content, .file, .header, .content, .eof }); +} + +test "multiple changes in same file" { + try testTokenize( + \\diff --git a/src/queue.zig b/src/queue.zig + \\index aae7ddf..2591b0a 100644 + \\--- a/src/queue.zig + \\+++ b/src/queue.zig + \\@@ -215,7 +215,7 @@ fn sleepyPop(q: *Queue(u8, 2)) !void { + \\ // still full and the push in the other thread is still blocked + \\ // waiting for space. + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s); + \\+ // std.Thread.sleep(std.time.ns_per_s); + \\ // Finally, let that other thread go. + \\ try testing.expectEqual(1, q.pop()); + \\ + \\@@ -225,7 +225,7 @@ fn sleepyPop(q: *Queue(u8, 2)) !void { + \\ try Thread.yield(); + \\ // But we want to ensure that there's a second push waiting, so + \\ // here's another sleep. + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ + \\ // Another spurious wake... + \\ q.not_full.signal(); + \\@@ -233,7 +233,7 @@ fn sleepyPop(q: *Queue(u8, 2)) !void { + \\ // And another chance for the other thread to see that it's + \\ // spurious and go back to sleep. + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ + \\ // Pop that thing and we're done. + \\ try testing.expectEqual(2, q.pop()); + \\@@ -250,13 +250,13 @@ test "Fill, block, fill, block" { + \\ const thread = try Thread.spawn(cfg, sleepyPop, .{&queue}); + \\ queue.push(1); + \\ queue.push(2); + \\- const now = std.time.milliTimestamp(); + \\+ // const now = std.time.milliTimestamp(); + \\ queue.push(3); // This one should block. + \\- const then = std.time.milliTimestamp(); + \\+ // const then = std.time.milliTimestamp(); + \\ + \\ // Just to make sure the sleeps are yielding to this thread, make + \\ // sure it took at least 900ms to do the push. + \\- try testing.expect(then - now > 900); + \\+ // try testing.expect(then - now > 900); + \\ + \\ // This should block again, waiting for the other thread. + \\ queue.push(4); + \\@@ -270,14 +270,14 @@ test "Fill, block, fill, block" { + \\ fn sleepyPush(q: *Queue(u8, 1)) !void { + \\ // Try to ensure the other thread has already started trying to pop. + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ + \\ // Spurious wake + \\ q.not_full.signal(); + \\ q.not_empty.signal(); + \\ + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ + \\ // Stick something in the queue so it can be popped. + \\ q.push(1); + \\@@ -286,7 +286,7 @@ fn sleepyPush(q: *Queue(u8, 1)) !void { + \\ try Thread.yield(); + \\ // Give the other thread time to block again. + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ + \\ // Spurious wake + \\ q.not_full.signal(); + \\@@ -317,7 +317,7 @@ test "2 readers" { + \\ const t1 = try Thread.spawn(cfg, readerThread, .{&queue}); + \\ const t2 = try Thread.spawn(cfg, readerThread, .{&queue}); + \\ try Thread.yield(); + \\- std.Thread.sleep(std.time.ns_per_s / 2); + \\+ // std.Thread.sleep(std.time.ns_per_s / 2); + \\ queue.push(1); + \\ queue.push(1); + \\ t1.join(); + \\ ); + , &.{ + .content, + .file, + .header, + .content, + .header, + .content, + .header, + .content, + .header, + .content, + .header, + .content, + .header, + .content, + .header, + .content, + .eof, + }); +} + +/// Test tokenizer's iterator outputs for the provided source. It should +/// match the expected token tags, except the very last .eof tag which shall +/// be omitted from the argument of expected_token_tags, as this function +/// explicitly tests for the .eof tag (with corresponding location information). +fn testTokenize(source: [:0]const u8, expected_token_tags: []const Token.Tag) !void { + var tokenizer = Tokenizer.init(source); + for (0.., expected_token_tags) |i, expected| { + const token = tokenizer.next(); + testing.expectEqual(expected, token.tag) catch |err| { + print("Got token: ", .{}); + tokenizer.dump(&token); + print("Expected .{s} at index {d}\n", .{ @tagName(expected), i }); + return err; + }; + } + const last_token = tokenizer.next(); + testing.expectEqual(Token.Tag.eof, last_token.tag) catch |err| { + print("Got token: ", .{}); + tokenizer.dump(&last_token); + print("Expected .{s}\n", .{@tagName(Token.Tag.eof)}); + return err; + }; + try testing.expectEqual(source.len, last_token.loc.idx); + try testing.expectEqual(0, last_token.loc.len); +} diff --git a/src/main.zig b/src/main.zig index f877f82..8e13d68 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,8 @@ // FIX known issues: +const diff = ""; + pub fn main() !void { // argument handling var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; @@ -38,21 +40,27 @@ pub fn main() !void { var renderer = zterm.Renderer.Buffered.init(allocator); defer renderer.deinit(); - var app: App = .init(io, .init); + var app: App = .init(io, try .init(allocator, diff)); + defer app.model.deinit(allocator); - var element_root: tui_diff.elements.Root(App) = .init; - var element_tree: tui_diff.elements.Tree(App) = .init; - var changes: [3]tui_diff.elements.Change(App) = @splat(.init); - var scrollable_diffs = try tui_diff.elements.Diff(App, allocator, &changes); - // NOTE scrollable should provide deinit function (*zterm*) - // -> `Container` of `Scrollable` does not pass through the `minSize` request to its children! + var element_root: tui_diff.elements.Root(App) = .init(allocator); + var element_tree: tui_diff.elements.Tree(App) = .init(&app.queue); - var root = try tui_diff.Container(App, allocator, &element_root, &element_tree, &scrollable_diffs); + var iter = app.model.changes.iterator(); + var entry = iter.next(); + while (entry != null) : (entry = iter.next()) if (entry) |e| { + var changes = try allocator.alloc(tui_diff.elements.Change(App), e.value_ptr.items.len); + for (0.., e.value_ptr.items) |i, diff_index| + changes[i] = .init(diff_index); + try element_root.diffs.append(allocator, try tui_diff.elements.Diff(App, allocator, changes)); + }; + + var root = try tui_diff.Container(App, allocator, &element_root, &element_tree); defer root.deinit(); // also de-initializes the children + element_root.container = &root; try app.start(); defer app.stop() catch |err| log.err("Failed to stop application: {any}", .{err}); - // defer app.model.deinit(); // event loop loop: while (true) { diff --git a/src/model.zig b/src/model.zig index b402c51..07fee52 100644 --- a/src/model.zig +++ b/src/model.zig @@ -7,26 +7,77 @@ // FIX known issues: content: []const u8, -changes: std.MultiArrayList(Change) = .empty, +changes: std.AutoArrayHashMapUnmanaged(Index, std.ArrayList(Index)) = .empty, render_mode: enum { side_by_side, stacked, } = .stacked, // TODO alloc-free parsing? (similar to how I've implemented the parser for `smd`?) -pub const init: Model = .{ - .content = &.{}, -}; +// - parsing would tokenize and create a structure for the following patterns +// ``` +// .content -> // initial content is ignored! +// (.file -> (.header -> .content)*)* // these are relevant +// -> .eof +// ``` +// For each .file collect each pair of following .header and .content to a single hunk, if a new file follows then there there are no more changes for the previous file and the new .file changes will be collected till an .eof is found +pub fn init(gpa: Allocator, content: [:0]const u8) !Model { + if (content.len == 0) return error.EmptyDiff; + + var this: Model = .{ .content = content }; + var tokenizer = lexer.Tokenizer.init(content); + var token = tokenizer.next(); + var file: Index = undefined; + var diff: Index = undefined; + var last_tag: lexer.Token.Tag = .invalid; + while (token.tag != .eof) : (token = tokenizer.next()) { + defer last_tag = token.tag; + switch (token.tag) { + .file => { + // create file + file = .{ + .idx = token.loc.idx, + .len = token.loc.len, + }; + continue; + }, + .header => if (last_tag == .file or last_tag == .content) { + diff = .{ + .idx = token.loc.idx, + .len = token.loc.len, + }; + }, + .content => if (last_tag == .header) { + diff.len += token.loc.len; + const entry = try this.changes.getOrPut(gpa, file); + if (!entry.found_existing) entry.value_ptr.* = .empty; + try entry.value_ptr.append(gpa, diff); + }, + else => unreachable, + } + } + if (this.changes.entries.len == 0) return error.UnexpectedFormat; + return this; +} + +pub fn deinit(this: *Model, gpa: Allocator) void { + for (this.changes.values()) |*diff| diff.deinit(gpa); + this.changes.deinit(gpa); +} pub const Index = struct { idx: usize, len: usize, }; -pub const Change = struct { - file: Index, - diff: Index, -}; - const Model = @This(); + const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const lexer = @import("lexer.zig"); + +test { + std.testing.refAllDeclsRecursive(@This()); + _ = @import("lexer.zig"); +} diff --git a/src/root.zig b/src/root.zig index 3d2c246..29aa832 100644 --- a/src/root.zig +++ b/src/root.zig @@ -4,9 +4,11 @@ // FIX known issues: -pub const Event = union(enum) {}; +pub const Event = union(enum) { + file: usize, +}; -pub fn Container(App: type, gpa: Allocator, element: *elements.Root(App), tree: *elements.Tree(App), diffs: *App.Scrollable) !App.Container { +pub fn Container(App: type, gpa: Allocator, element: *elements.Root(App), tree: *elements.Tree(App)) !App.Container { var root: App.Container = try .init(gpa, .{ .layout = .{ .direction = .horizontal, @@ -16,7 +18,7 @@ pub fn Container(App: type, gpa: Allocator, element: *elements.Root(App), tree: }, }, element.element()); try root.append(try .init(gpa, .{}, tree.element())); - try root.append(try .init(gpa, .{}, diffs.element())); + try root.append(try .init(gpa, .{}, .{})); // empty container holding the scrollable element for the diff of each file return root; }