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")); }