Files
mim/src/repo.zig
T
2026-06-21 05:38:52 +02:00

547 lines
23 KiB
Zig

const std = @import("std");
const diagnostics = @import("diagnostics.zig");
// Minimal repo index for file picker/tree behavior.
// req: repo/001, ui/002, testing/001, testing/002, testing/003, testing/004
test {
_ = Index;
}
pub const Error = error{
InvalidPath,
InvalidPattern,
InvalidQuery,
InvalidSearchRow,
InvalidStatusRow,
FileNotFound,
};
pub const Entry = struct {
path: []u8,
content: []u8,
};
pub const Index = struct {
allocator: std.mem.Allocator,
entries: std.ArrayList(Entry) = .empty,
ignore_patterns: std.ArrayList([]u8) = .empty,
pub fn init(allocator: std.mem.Allocator) Index {
return .{ .allocator = allocator };
}
pub fn deinit(self: *Index) void {
for (self.entries.items) |entry| {
self.allocator.free(entry.path);
self.allocator.free(entry.content);
}
self.entries.deinit(self.allocator);
for (self.ignore_patterns.items) |pattern| self.allocator.free(pattern);
self.ignore_patterns.deinit(self.allocator);
self.* = undefined;
}
pub fn clear(self: *Index) void {
for (self.entries.items) |entry| {
self.allocator.free(entry.path);
self.allocator.free(entry.content);
}
self.entries.clearRetainingCapacity();
for (self.ignore_patterns.items) |pattern| self.allocator.free(pattern);
self.ignore_patterns.clearRetainingCapacity();
}
pub fn addIgnorePattern(self: *Index, pattern: []const u8) !void {
try validatePattern(pattern);
try self.ignore_patterns.append(self.allocator, try self.allocator.dupe(u8, pattern));
}
pub fn addFile(self: *Index, path: []const u8, file_content: []const u8) !void {
try validatePath(path);
if (!std.unicode.utf8ValidateSlice(file_content)) return Error.InvalidPath;
try self.entries.append(self.allocator, .{
.path = try self.allocator.dupe(u8, path),
.content = try self.allocator.dupe(u8, file_content),
});
}
pub fn pickerItemsAlloc(self: *const Index, allocator: std.mem.Allocator, include_ignored: bool) ![][]const u8 {
var items = std.ArrayList([]const u8).empty;
errdefer items.deinit(allocator);
for (self.entries.items) |entry| {
if (!include_ignored and self.isIgnored(entry.path)) continue;
try items.append(allocator, entry.path);
}
return items.toOwnedSlice(allocator);
}
pub fn treeItemsAlloc(self: *const Index, allocator: std.mem.Allocator, include_ignored: bool) ![][]const u8 {
// Tree rows are path-shaped for now; rendering is still through the shared list primitive.
return self.pickerItemsAlloc(allocator, include_ignored);
}
pub fn content(self: *const Index, path: []const u8) ![]const u8 {
for (self.entries.items) |entry| {
if (std.mem.eql(u8, entry.path, path)) return entry.content;
}
return Error.FileNotFound;
}
pub fn searchRowsAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool) ![][]const u8 {
return self.searchRowsLimitedAlloc(allocator, query, include_ignored, diagnostics.max_search_rows);
}
pub fn searchRowsLimitedAlloc(self: *const Index, allocator: std.mem.Allocator, query: []const u8, include_ignored: bool, limit: usize) ![][]const u8 {
try validateQuery(query);
if (limit == 0) return Error.InvalidQuery;
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
var truncated = false;
for (self.entries.items) |entry| {
if (!include_ignored and self.isIgnored(entry.path)) continue;
try appendSearchRowsForEntry(allocator, &rows, entry, query, limit, &truncated);
if (truncated) break;
}
if (truncated) try rows.append(allocator, try diagnostics.searchTruncatedRowAlloc(allocator, rows.items.len, limit));
return rows.toOwnedSlice(allocator);
}
pub fn searchOffsetFromRow(self: *const Index, row: []const u8) !struct { path: []const u8, offset: usize } {
const first = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidSearchRow;
const rest = row[first + 1 ..];
const second_rel = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidSearchRow;
const third_rel = std.mem.indexOfScalar(u8, rest[second_rel + 1 ..], ':') orelse return Error.InvalidSearchRow;
const path = row[0..first];
const line_no = std.fmt.parseUnsigned(usize, rest[0..second_rel], 10) catch return Error.InvalidSearchRow;
const column = std.fmt.parseUnsigned(usize, rest[second_rel + 1 .. second_rel + 1 + third_rel], 10) catch return Error.InvalidSearchRow;
if (line_no == 0 or column == 0) return Error.InvalidSearchRow;
const file_content = try self.content(path);
return .{ .path = path, .offset = try byteOffsetForLineColumn(file_content, line_no, column) };
}
pub fn isIgnored(self: *const Index, path: []const u8) bool {
for (self.ignore_patterns.items) |pattern| {
if (matchesPattern(pattern, path)) return true;
}
return false;
}
};
fn validatePath(path: []const u8) !void {
if (path.len == 0) return Error.InvalidPath;
if (!std.unicode.utf8ValidateSlice(path)) return Error.InvalidPath;
if (std.mem.startsWith(u8, path, "/") or std.mem.indexOf(u8, path, "..") != null) return Error.InvalidPath;
for (path) |byte| {
if (byte <= 0x20 or byte == '|') return Error.InvalidPath;
}
}
fn validatePattern(pattern: []const u8) !void {
if (pattern.len == 0) return Error.InvalidPattern;
if (!std.unicode.utf8ValidateSlice(pattern)) return Error.InvalidPattern;
for (pattern) |byte| {
if (byte <= 0x20 or byte == '|') return Error.InvalidPattern;
}
}
fn validateQuery(query: []const u8) !void {
if (query.len == 0) return Error.InvalidQuery;
if (!std.unicode.utf8ValidateSlice(query)) return Error.InvalidQuery;
for (query) |byte| {
if (byte <= 0x20 or byte == '|') return Error.InvalidQuery;
}
}
fn appendSearchRowsForEntry(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), entry: Entry, query: []const u8, limit: usize, truncated: *bool) !void {
var line_no: usize = 1;
var lines = std.mem.splitScalar(u8, entry.content, '\n');
while (lines.next()) |line| : (line_no += 1) {
if (std.mem.indexOf(u8, line, query)) |column_zero| {
if (rows.items.len >= limit) {
truncated.* = true;
return;
}
const preview = try previewAlloc(allocator, line);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(
allocator,
"{s}:{d}:{d}:{s}",
.{ entry.path, line_no, column_zero + 1, preview },
));
}
}
}
fn previewAlloc(allocator: std.mem.Allocator, line: []const u8) ![]u8 {
var out = std.ArrayList(u8).empty;
errdefer out.deinit(allocator);
var i: usize = 0;
while (i < line.len and out.items.len < 48) {
const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
const end = @min(line.len, i + len);
const slice = line[i..end];
if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) {
try out.append(allocator, '_');
} else {
try out.appendSlice(allocator, slice);
}
i = end;
}
if (out.items.len == 0) try out.append(allocator, '_');
return out.toOwnedSlice(allocator);
}
fn byteOffsetForLineColumn(content_bytes: []const u8, line_no: usize, column: usize) !usize {
var current_line: usize = 1;
var line_start: usize = 0;
var i: usize = 0;
while (i <= content_bytes.len) : (i += 1) {
if (i == content_bytes.len or content_bytes[i] == '\n') {
if (current_line == line_no) {
const line = content_bytes[line_start..i];
if (column - 1 > line.len) return Error.InvalidSearchRow;
return line_start + column - 1;
}
current_line += 1;
line_start = i + 1;
}
}
return Error.InvalidSearchRow;
}
pub fn gitStatusRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8) ![][]const u8 {
const argv = [_][]const u8{ "git", "-C", cwd, "status", "--short" };
const result = std.process.run(allocator, io, .{
.argv = &argv,
.stdout_limit = .limited(64 * 1024),
.stderr_limit = .limited(16 * 1024),
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
return parseStatusRowsAlloc(allocator, result.stdout);
}
pub fn gitDiffRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![][]const u8 {
try validatePath(path);
const argv = [_][]const u8{ "git", "-C", cwd, "diff", "--unified=0", "--", path };
const result = std.process.run(allocator, io, .{
.argv = &argv,
.stdout_limit = .limited(256 * 1024),
.stderr_limit = .limited(16 * 1024),
}) catch |err| return singleOwnedRow(allocator, try std.fmt.allocPrint(allocator, "git_error:{s}", .{@errorName(err)}));
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
if (!termSucceeded(result.term)) return singleOwnedRow(allocator, try gitErrorRowAlloc(allocator, result.stderr));
return parseDiffRowsAlloc(allocator, result.stdout);
}
pub fn gitPathFromStatusRow(row: []const u8) ![]const u8 {
const colon = std.mem.indexOfScalar(u8, row, ':') orelse return Error.InvalidStatusRow;
const path = row[colon + 1 ..];
if (path.len == 0 or std.mem.startsWith(u8, row, "git_error:") or std.mem.eql(u8, row, "clean")) return Error.InvalidStatusRow;
try validatePath(path);
return path;
}
pub fn readRepoFileAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, path: []const u8) ![]u8 {
try validatePath(path);
const full_path = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ cwd, path });
defer allocator.free(full_path);
return std.Io.Dir.cwd().readFileAlloc(io, full_path, allocator, .limited(1024 * 1024));
}
fn termSucceeded(term: std.process.Child.Term) bool {
return switch (term) {
.exited => |code| code == 0,
else => false,
};
}
fn parseStatusRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
var lines = std.mem.splitScalar(u8, stdout, '\n');
while (lines.next()) |raw_line| {
const line = std.mem.trim(u8, raw_line, "\r");
if (line.len == 0) continue;
if (line.len < 4) continue;
const code = line[0..2];
var path = std.mem.trim(u8, line[3..], " ");
if (std.mem.indexOf(u8, path, " -> ")) |rename_arrow| path = path[rename_arrow + 4 ..];
try validatePath(path);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}:{s}", .{ statusName(code), path }));
}
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "clean"));
return rows.toOwnedSlice(allocator);
}
fn statusName(code: []const u8) []const u8 {
if (std.mem.indexOfScalar(u8, code, '?') != null) return "untracked";
if (std.mem.indexOfScalar(u8, code, 'D') != null) return "deleted";
if (std.mem.indexOfScalar(u8, code, 'A') != null) return "added";
if (std.mem.indexOfScalar(u8, code, 'M') != null) return "modified";
if (std.mem.indexOfScalar(u8, code, 'R') != null) return "renamed";
return "changed";
}
fn parseDiffRowsAlloc(allocator: std.mem.Allocator, stdout: []const u8) ![][]const u8 {
var rows = std.ArrayList([]const u8).empty;
errdefer {
for (rows.items) |row| allocator.free(row);
rows.deinit(allocator);
}
var lines = std.mem.splitScalar(u8, stdout, '\n');
while (lines.next()) |raw_line| {
const line = std.mem.trim(u8, raw_line, "\r");
if (line.len == 0) continue;
if (std.mem.startsWith(u8, line, "@@")) {
const preview = try previewAlloc(allocator, line);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "hunk:{s}", .{preview}));
} else if (std.mem.startsWith(u8, line, "+") and !std.mem.startsWith(u8, line, "+++")) {
const preview = try previewAlloc(allocator, line[1..]);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "add:{s}", .{preview}));
} else if (std.mem.startsWith(u8, line, "-") and !std.mem.startsWith(u8, line, "---")) {
const preview = try previewAlloc(allocator, line[1..]);
defer allocator.free(preview);
try rows.append(allocator, try std.fmt.allocPrint(allocator, "del:{s}", .{preview}));
}
}
if (rows.items.len == 0) try rows.append(allocator, try allocator.dupe(u8, "no diff"));
return rows.toOwnedSlice(allocator);
}
fn gitErrorRowAlloc(allocator: std.mem.Allocator, stderr: []const u8) ![]u8 {
const trimmed = std.mem.trim(u8, stderr, " \t\r\n");
if (trimmed.len == 0) return allocator.dupe(u8, "git_error:command_failed");
const limit = @min(trimmed.len, 120);
const preview = try previewAlloc(allocator, trimmed[0..limit]);
defer allocator.free(preview);
return std.fmt.allocPrint(allocator, "git_error:{s}", .{preview});
}
fn singleOwnedRow(allocator: std.mem.Allocator, row: []u8) ![][]const u8 {
errdefer allocator.free(row);
const rows = try allocator.alloc([]const u8, 1);
rows[0] = row;
return rows;
}
fn matchesPattern(pattern: []const u8, path: []const u8) bool {
if (std.mem.endsWith(u8, pattern, "/")) return std.mem.startsWith(u8, path, pattern);
if (std.mem.startsWith(u8, pattern, "*.")) return std.mem.endsWith(u8, path, pattern[1..]);
if (std.mem.indexOfScalar(u8, pattern, '/') != null) return std.mem.eql(u8, pattern, path) or std.mem.startsWith(u8, path, pattern);
var parts = std.mem.splitScalar(u8, path, '/');
while (parts.next()) |part| {
if (std.mem.eql(u8, part, pattern)) return true;
}
return false;
}
fn freeItems(allocator: std.mem.Allocator, items: [][]const u8) void {
allocator.free(items);
}
fn freeOwnedItems(allocator: std.mem.Allocator, items: []const []const u8) void {
for (items) |item| allocator.free(item);
allocator.free(items);
}
test "regular: picker respects gitignore by default and can include ignored" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addIgnorePattern("zig-out/");
try repo.addIgnorePattern("*.o");
try repo.addIgnorePattern("secret.txt");
try repo.addFile("src/main.zig", "pub fn main() void {}");
try repo.addFile("zig-out/bin/mim", "binary");
try repo.addFile("build.o", "object");
try repo.addFile("secret.txt", "hidden");
{
const items = try repo.pickerItemsAlloc(std.testing.allocator, false);
defer freeItems(std.testing.allocator, items);
try std.testing.expectEqual(@as(usize, 1), items.len);
try std.testing.expectEqualStrings("src/main.zig", items[0]);
}
{
const items = try repo.pickerItemsAlloc(std.testing.allocator, true);
defer freeItems(std.testing.allocator, items);
try std.testing.expectEqual(@as(usize, 4), items.len);
}
}
test "regular: tree rows use the same gitignore boundary as picker rows" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addIgnorePattern("tmp/");
try repo.addFile("src/main.zig", "main");
try repo.addFile("tmp/log.txt", "log");
const rows = try repo.treeItemsAlloc(std.testing.allocator, false);
defer freeItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqualStrings("src/main.zig", rows[0]);
}
test "regular: selected file content is retrievable" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addFile("src/main.zig", "const std = @import(\"std\");");
try std.testing.expectEqualStrings("const std = @import(\"std\");", try repo.content("src/main.zig"));
}
test "adversarial: invalid paths and patterns are rejected" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try std.testing.expectError(Error.InvalidPath, repo.addFile("../secret", "x"));
try std.testing.expectError(Error.InvalidPath, repo.addFile("two words", "x"));
try std.testing.expectError(Error.InvalidPattern, repo.addIgnorePattern("bad pattern"));
try std.testing.expectError(Error.FileNotFound, repo.content("missing.zig"));
}
test "regular: project search returns file line column and preview rows" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addIgnorePattern("zig-out/");
try repo.addFile("src/main.zig", "pub fn main() void {}\nconst needle = 1;");
try repo.addFile("src/lib.zig", "needle here");
try repo.addFile("zig-out/log.txt", "needle ignored");
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false);
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 2), rows.len);
try std.testing.expectEqualStrings("src/main.zig:2:7:const_needle_=_1;", rows[0]);
try std.testing.expectEqualStrings("src/lib.zig:1:1:needle_here", rows[1]);
}
test "regular: project search can include ignored files explicitly" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addIgnorePattern("zig-out/");
try repo.addFile("src/main.zig", "needle");
try repo.addFile("zig-out/log.txt", "needle");
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", true);
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 2), rows.len);
}
test "regular: search row resolves back to file and byte offset" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addFile("src/main.zig", "abc\nlet needle = 1;\n");
const rows = try repo.searchRowsAlloc(std.testing.allocator, "needle", false);
defer freeOwnedItems(std.testing.allocator, rows);
const resolved = try repo.searchOffsetFromRow(rows[0]);
try std.testing.expectEqualStrings("src/main.zig", resolved.path);
try std.testing.expectEqual(@as(usize, 8), resolved.offset);
}
test "adversarial: project search rejects bad queries and rows" {
var repo = Index.init(std.testing.allocator);
defer repo.deinit();
try repo.addFile("src/main.zig", "needle");
try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "", false));
try std.testing.expectError(Error.InvalidQuery, repo.searchRowsAlloc(std.testing.allocator, "two words", false));
try std.testing.expectError(Error.InvalidSearchRow, repo.searchOffsetFromRow("bad-row"));
try std.testing.expectError(Error.FileNotFound, repo.searchOffsetFromRow("missing.zig:1:1:needle"));
}
fn makeGitFixture(allocator: std.mem.Allocator) !struct { tmp: std.testing.TmpDir, cwd: []u8 } {
var tmp = std.testing.tmpDir(.{});
errdefer tmp.cleanup();
var src = try tmp.dir.createDirPathOpen(std.testing.io, "src", .{});
src.close(std.testing.io);
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const old = 1;\n" });
const cwd = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
errdefer allocator.free(cwd);
try runGitTest(allocator, cwd, &.{ "init", "--quiet" });
try runGitTest(allocator, cwd, &.{ "add", "src/main.zig" });
try runGitTest(allocator, cwd, &.{ "-c", "user.email=test@example.invalid", "-c", "user.name=Test", "commit", "--quiet", "-m", "init" });
try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "src/main.zig", .data = "const new = 2;\n" });
return .{ .tmp = tmp, .cwd = cwd };
}
fn runGitTest(allocator: std.mem.Allocator, cwd: []const u8, args: []const []const u8) !void {
var argv = try allocator.alloc([]const u8, args.len + 3);
defer allocator.free(argv);
argv[0] = "git";
argv[1] = "-C";
argv[2] = cwd;
@memcpy(argv[3..], args);
const result = try std.process.run(allocator, std.testing.io, .{
.argv = argv,
.stdout_limit = .limited(64 * 1024),
.stderr_limit = .limited(64 * 1024),
});
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
try std.testing.expect(termSucceeded(result.term));
}
test "regular: git status rows expose changed files" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd);
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expectEqualStrings("modified:src/main.zig", rows[0]);
try std.testing.expectEqualStrings("src/main.zig", try gitPathFromStatusRow(rows[0]));
}
test "regular: git diff rows expose hunks and changed lines" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const rows = try gitDiffRowsAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expect(rows.len >= 3);
try std.testing.expect(std.mem.startsWith(u8, rows[0], "hunk:@@"));
try std.testing.expect(std.mem.indexOf(u8, rows[1], "const_old_=_1;") != null);
try std.testing.expect(std.mem.indexOf(u8, rows[2], "const_new_=_2;") != null);
}
test "regular: changed git file can be read for editor navigation" {
var fixture = try makeGitFixture(std.testing.allocator);
defer {
std.testing.allocator.free(fixture.cwd);
fixture.tmp.cleanup();
}
const content = try readRepoFileAlloc(std.testing.allocator, std.testing.io, fixture.cwd, "src/main.zig");
defer std.testing.allocator.free(content);
try std.testing.expectEqualStrings("const new = 2;\n", content);
}
test "adversarial: git errors are visible rows and status rows are validated" {
const rows = try gitStatusRowsAlloc(std.testing.allocator, std.testing.io, "/definitely/not/a/mim/repo");
defer freeOwnedItems(std.testing.allocator, rows);
try std.testing.expectEqual(@as(usize, 1), rows.len);
try std.testing.expect(std.mem.startsWith(u8, rows[0], "git_error:"));
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow(rows[0]));
try std.testing.expectError(Error.InvalidStatusRow, gitPathFromStatusRow("clean"));
try std.testing.expectError(Error.InvalidPath, gitPathFromStatusRow("modified:../secret"));
}