Repository navigation
Expand file tree
/
Copy pathmod.zig
More file actions
3149 lines (2836 loc) · 134 KB
/
Copy pathmod.zig
File metadata and controls
3149 lines (2836 loc) · 134 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Git Unified Diffs: reading one, applying one, and drawing one.
//!
//! The format is the one `git diff` and `diff -u` write, and what `git apply`
//! reads: `diff --git a/old b/new` sections carrying `index`, `similarity
//! index`, `old mode`/`new mode`, `new file mode`, `deleted file mode` and
//! `rename from`/`rename to`; `--- a/path` / `+++ b/path` headers whose paths
//! may be git-quoted (`"a/caf\303\251.txt"`, with the C escapes undoing back to
//! bytes); and `@@ -l,c +l,c @@` hunks of space, `-` and `+` lines with their
//! `\ No newline at end of file` markers.
//!
//! The agent edits files by writing these, so the parser is lenient about
//! everything that does not change what a patch means — git's metadata headers,
//! a count left out of a hunk header, the blank line a model writes where a
//! blank *context* line should be, the fence a patch arrives wrapped in — and
//! strict about the one thing that does: the text a hunk says it replaces has
//! to be the text the file holds. A hunk that does not match is reported, never
//! guessed at, and one that would fit in two places is reported rather than
//! placed in the nearer of them, because a patch landing in the wrong place is
//! worse than a patch that did not land at all.
//!
//! Nothing is written until every file of the patch has been rebuilt in memory,
//! so a patch whose last hunk fails leaves the tree exactly as it was.
//!
//! The permissions git prints are carried out: a `new file mode 100755` creates
//! an executable, `old mode`/`new mode` changes one, and a section that changes
//! nothing but the mode is applied as that change on its own. What is
//! deliberately not supported: binary patches (`GIT binary patch`, `Binary
//! files … differ`), copies, symlinks and submodules (mode 120000/160000), and
//! the fuzz that lets `patch(1)` apply a hunk to text it does not quite match.
//!
//! A rename is read the way git writes one — a `diff --git a/old b/new` section
//! with `rename from`/`rename to`, and either no `---`/`+++` pair at all
//! (nothing but the name changes) or one whose hunks apply to the old file —
//! because that is the form that says "rename" rather than "these two headers
//! disagree". A bare `--- old` / `+++ new` with no such section is still
//! refused: that is what a mistyped path looks like, and the pair of headers is
//! the one part of a patch whose mismatch is more likely a typo than an intent.
const std = @import("std");
const sgr = @import("../ui/style.zig");
const sanitize = @import("../util/terminal/sanitize.zig");
/// Longest file this will patch. The whole file is read into memory and rebuilt
/// there, so this cap is what keeps a mistyped path from taking the process out.
const max_file_bytes: usize = 8 * 1024 * 1024;
/// Longest patch text a single tool call may carry.
const max_patch_bytes: usize = 2 * 1024 * 1024;
/// Most files one patch may touch.
const max_files: usize = 256;
/// Patch lines drawn before the rest are left out. A runaway patch should not
/// push the conversation out of the terminal's scrollback.
const render_max_lines: usize = 400;
/// Longest patch line drawn, for the same reason.
const render_max_line_bytes: usize = 2000;
pub const Action = enum { modified, created, deleted, renamed, mode_changed };
/// One file the patch changed, as it was changed. Owns `path`.
pub const FileStat = struct {
/// The file's path after the patch: the new name of a rename.
path: []const u8,
/// For a rename, the path the file had before. Owned; null otherwise.
from: ?[]const u8 = null,
action: Action,
added: usize,
removed: usize,
/// The mode the patch set on the file, as git prints one — `100755` — when
/// it said anything about it at all. Nothing owns it: it is a number.
mode: ?u32 = null,
};
/// What applying a patch came to.
pub const Outcome = union(enum) {
/// The files that were written, in the order the patch named them.
applied: []const FileStat,
/// Nothing was written, and this says why. Owns its text.
failed: []const u8,
pub fn deinit(self: Outcome, allocator: std.mem.Allocator) void {
switch (self) {
.applied => |stats| freeStats(allocator, stats),
.failed => |message| allocator.free(message),
}
}
};
fn freeStats(allocator: std.mem.Allocator, stats: []const FileStat) void {
for (stats) |stat| {
allocator.free(stat.path);
if (stat.from) |from| allocator.free(from);
}
allocator.free(stats);
}
/// Parse `patch_text` and apply it to the files it names, relative to the
/// working directory.
///
/// A patch that cannot be applied is an outcome rather than an error: the
/// caller's job is to show the model why, and an error would carry a name
/// instead of a reason. Errors out of here are the ones with nothing to say —
/// running out of memory.
pub fn apply(allocator: std.mem.Allocator, io: std.Io, patch_text: []const u8) !Outcome {
if (patch_text.len > max_patch_bytes) {
return fail(allocator, "the patch is {d} bytes; this tool reads at most {d}", .{ patch_text.len, max_patch_bytes });
}
var diag: Diagnostic = .init(allocator);
defer diag.deinit();
var patch = parse(allocator, patch_text, &diag) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.BadPatch => return failedWith(allocator, &diag),
};
defer patch.deinit(allocator);
// Every file is rebuilt before any of them is written.
var plans: std.ArrayList(Plan) = .empty;
defer {
for (plans.items) |plan| plan.deinit(allocator);
plans.deinit(allocator);
}
for (patch.files) |file| {
const plan = planFile(allocator, io, file, &diag) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
error.BadPatch => return failedWith(allocator, &diag),
};
plans.append(allocator, plan) catch |err| {
plan.deinit(allocator);
return err;
};
}
const stats = try allocator.alloc(FileStat, plans.items.len);
var filled: usize = 0;
errdefer {
// Only the entries that were built: the rest of the allocation is
// uninitialised, and freeing a pointer read out of it is not a thing
// that can be undone.
for (stats[0..filled]) |stat| {
allocator.free(stat.path);
if (stat.from) |from| allocator.free(from);
}
allocator.free(stats);
}
for (plans.items, 0..) |plan, i| {
stats[i] = .{
.path = try allocator.dupe(u8, plan.path),
.from = if (plan.old_path) |old| try allocator.dupe(u8, old) else null,
.action = plan.action,
.added = plan.added,
.removed = plan.removed,
.mode = plan.mode,
};
filled = i + 1;
}
for (plans.items, 0..) |plan, i| {
writePlan(io, plan) catch |err| {
// Reported as it happened rather than softened: the files before
// this one are already written, and the model has to know which.
const message = if (i == 0)
try std.fmt.allocPrint(allocator, "could not write '{s}': {s}", .{ plan.path, @errorName(err) })
else
try std.fmt.allocPrint(
allocator,
"wrote {d} file(s), then could not write '{s}': {s}. The tree is part-patched; check those files",
.{ i, plan.path, @errorName(err) },
);
freeStats(allocator, stats);
return .{ .failed = message };
};
}
return .{ .applied = stats };
}
/// Write one planned file: a new body for a modified or created file, the same
/// bytes under a new name for a rename, nothing at all but a removal for a
/// deletion, and nothing but the permissions for a patch that changes only
/// those.
fn writePlan(io: std.Io, plan: Plan) !void {
const cwd = std.Io.Dir.cwd();
switch (plan.action) {
.deleted => try cwd.deleteFile(io, plan.path),
.mode_changed => try setMode(io, plan.path, plan.mode.?),
.renamed => {
const old = plan.old_path.?;
const content = plan.content orelse {
// Nothing but the name changes, so nothing has to be rewritten:
// a plain rename keeps the bytes, the mode and the timestamps —
// unless the patch named a mode of its own.
if (std.Io.Dir.path.dirname(plan.path)) |dir_path| {
cwd.createDirPath(io, dir_path) catch {};
}
try cwd.rename(old, cwd, plan.path, io);
if (plan.mode) |mode| try setMode(io, plan.path, mode);
return;
};
// The body is rewritten, so the mode has to be carried across by
// hand: an executable script that is renamed stays executable.
var permissions: ?std.Io.Dir.Permissions = null;
if (plan.mode) |mode| {
permissions = permissionsOf(mode);
} else if (cwd.statFile(io, old, .{})) |stat| {
permissions = stat.permissions;
} else |_| {}
try writeBytes(io, plan.path, content, permissions);
// On a case-insensitive file system a rename that only changes case
// writes the file it is about to delete, so the deletion is skipped
// when both names turn out to be the one file.
if (!sameFile(io, old, plan.path)) try cwd.deleteFile(io, old);
},
else => try writeBytes(io, plan.path, plan.content.?, if (plan.mode) |mode| permissionsOf(mode) else null),
}
}
/// The permissions a git mode stands for. Git prints the whole `st_mode` as
/// octal — `100644`, `100755` — so what a file gets is the low nine bits of it.
fn permissionsOf(mode: u32) std.Io.File.Permissions {
return .fromMode(@intCast(mode & 0o777));
}
/// Change one file's permissions, for a patch whose whole change is the mode.
fn setMode(io: std.Io, path: []const u8, mode: u32) !void {
const file = try std.Io.Dir.cwd().openFile(io, path, .{});
defer file.close(io);
try file.setPermissions(io, permissionsOf(mode));
}
fn writeBytes(io: std.Io, path: []const u8, content: []const u8, permissions: ?std.Io.Dir.Permissions) !void {
if (std.Io.Dir.path.dirname(path)) |dir_path| {
std.Io.Dir.cwd().createDirPath(io, dir_path) catch {};
}
const file = try std.Io.Dir.cwd().createFile(io, path, .{});
defer file.close(io);
try file.writeStreamingAll(io, content);
if (permissions) |perms| file.setPermissions(io, perms) catch {};
}
/// Whether two paths name one file on disk. Only a rename asks: it is what keeps
/// a case-only rename on a case-insensitive file system from deleting the file
/// it has just written.
fn sameFile(io: std.Io, a: []const u8, b: []const u8) bool {
var buf_a: [std.Io.Dir.max_path_bytes]u8 = undefined;
var buf_b: [std.Io.Dir.max_path_bytes]u8 = undefined;
const len_a = std.Io.Dir.cwd().realPathFile(io, a, &buf_a) catch return false;
const len_b = std.Io.Dir.cwd().realPathFile(io, b, &buf_b) catch return false;
return std.mem.eql(u8, buf_a[0..len_a], buf_b[0..len_b]);
}
/// What one file of a patch came to, once every hunk of it has been matched.
const Plan = struct {
/// The path the file has after the patch.
path: []const u8,
/// For a rename, the path it had before. Owned; null otherwise.
old_path: ?[]const u8,
action: Action,
/// The file's new contents, or null for a deletion, and for a rename that
/// changes nothing but the name, and for a patch that changes only the
/// permissions. Owned.
content: ?[]u8,
added: usize,
removed: usize,
/// The mode the patch named, as git prints one. Null when it named none.
mode: ?u32 = null,
fn deinit(self: Plan, allocator: std.mem.Allocator) void {
allocator.free(self.path);
if (self.old_path) |old| allocator.free(old);
if (self.content) |content| allocator.free(content);
}
};
/// Rebuild one file in memory, or explain why it cannot be rebuilt.
///
/// Nothing is written from here: the caller writes once every file of the patch
/// has come through this function, which is what makes a failed patch a no-op.
fn planFile(
allocator: std.mem.Allocator,
io: std.Io,
file: FilePatch,
diag: *Diagnostic,
) (error{ OutOfMemory, BadPatch })!Plan {
// Two names that are not the same name are a rename, and a rename read out
// of two headers alone is how a mistyped path turns into a lost file. So the
// difference is only taken as a rename when the patch said so in git's own
// words; a bare pair that disagrees is refused, and the agent can delete and
// create instead, in two patches whose results it can check.
const rename = file.renamed and file.old_path != null and file.new_path != null and
!std.mem.eql(u8, file.old_path.?, file.new_path.?);
if (!rename) {
if (file.old_path) |old| {
if (file.new_path) |new| {
if (!std.mem.eql(u8, old, new)) {
diag.say("the patch names two different files, '{s}' and '{s}'. A rename is sent the way git writes it — 'diff --git a/{s} b/{s}' with 'rename from' and 'rename to', or with '--- a/{s}' and '+++ b/{s}' hunks — and anything else is a create of the new file plus a delete of the old one", .{ old, new, old, new, old, new });
return error.BadPatch;
}
}
}
}
const action: Action = if (file.old_path == null)
.created
else if (file.new_path == null)
.deleted
else if (rename)
.renamed
else
.modified;
// The name the file had before the patch: the old side of a rename, and the
// file a patch against one name changes.
const original_path = file.old_path orelse file.new_path.?;
// The name the file has after it.
const path = file.new_path orelse file.old_path.?;
// The permissions the section asked for, if it asked for any.
const mode = file.mode;
if (action == .created and pathExists(io, path)) {
diag.say("'{s}' already exists, so it cannot be created: send it as a patch against the file it is now", .{path});
return error.BadPatch;
}
if (action == .renamed) {
if (!pathExists(io, original_path)) {
diag.say("'{s}' cannot be renamed because it does not exist: a rename has to start from a file the tree has", .{original_path});
return error.BadPatch;
}
// Checking before reading rather than while writing: the new name is
// what the patch would otherwise overwrite without saying so.
if (pathExists(io, path)) {
diag.say("the rename target '{s}' already exists, so '{s}' cannot be renamed onto it: move it away first, or patch it in place", .{ path, original_path });
return error.BadPatch;
}
}
// A rename that changes nothing but the name is not read at all: the bytes
// are the old file's, and moving them is cheaper and more exact than
// rebuilding them from the lines they split into.
if (action == .renamed and file.hunks.len == 0) {
return buildPlan(allocator, path, original_path, action, null, 0, 0, mode);
}
// A section that changes nothing but the permissions has no text to apply:
// git writes 'old mode' and 'new mode' and no headers at all for one.
if (action == .modified and file.hunks.len == 0 and mode != null) {
if (!pathExists(io, path)) {
diag.say("'{s}' cannot have its mode changed because it does not exist: a mode change names a file the tree has", .{path});
return error.BadPatch;
}
return buildPlan(allocator, path, null, .mode_changed, null, 0, 0, mode);
}
const original: []u8 = if (action == .created)
try allocator.dupe(u8, "")
else
std.Io.Dir.cwd().readFileAlloc(io, original_path, allocator, .limited(max_file_bytes)) catch |err| {
if (err == error.StreamTooLong) {
diag.say("'{s}' is larger than the {d} MB this tool reads", .{ original_path, max_file_bytes / (1024 * 1024) });
return error.BadPatch;
}
diag.say("'{s}' could not be read ({s}): it has to exist before it can be patched, and a new file is sent with '--- /dev/null'", .{ original_path, @errorName(err) });
return error.BadPatch;
};
defer allocator.free(original);
var source = try readLines(allocator, original);
defer source.deinit(allocator);
var out: std.ArrayList([]const u8) = .empty;
defer out.deinit(allocator);
// How far the hunks have read into the original, and what the rebuilt file
// ends with — which is what decides whether it ends with a newline.
var consumed: usize = 0;
var ending: Ending = .original;
var added: usize = 0;
var removed: usize = 0;
// The last matched hunk anchors stale line numbers in the same file.
var anchor: ?HunkAnchor = null;
for (file.hunks) |hunk| {
// A zero-length old range names the line *before* an insertion.
const stated = if (oldSideLen(hunk) == 0)
hunk.header.old_start
else if (hunk.header.old_start > 0)
hunk.header.old_start - 1
else
0;
// `locate` has more than one way to find nothing, and each explains
// itself; `describeMiss` is for the one that has nothing to say yet.
const already_said = diag.written().len;
const at = locate(diag, original_path, source.lines.items, consumed, hunk, stated, anchor) orelse {
if (diag.written().len == already_said) describeMiss(diag, original_path, source.lines.items, hunk, stated);
return error.BadPatch;
};
if (oldSideLen(hunk) > 0) anchor = .{ .stated = stated, .actual = at };
try out.appendSlice(allocator, source.lines.items[consumed..at]);
if (at > consumed) ending = .original;
for (hunk.lines) |line| switch (line.kind) {
.context => {
try out.append(allocator, line.text);
ending = if (line.no_newline) .patched_without_newline else .patched_with_newline;
},
.remove => removed += 1,
.add => {
added += 1;
try out.append(allocator, line.text);
ending = if (line.no_newline) .patched_without_newline else .patched_with_newline;
},
};
consumed = at + oldSideLen(hunk);
}
if (consumed < source.lines.items.len) {
try out.appendSlice(allocator, source.lines.items[consumed..]);
ending = .original;
}
if (action == .deleted and out.items.len != 0) {
diag.say("the patch deletes '{s}' without removing all of it: a deletion has to account for every line", .{path});
return error.BadPatch;
}
// A deletion writes nothing, so its contents are not built at all.
var content: ?[]u8 = null;
if (action != .deleted) {
var joined: std.Io.Writer.Allocating = .init(allocator);
errdefer joined.deinit();
const newline: []const u8 = if (source.crlf) "\r\n" else "\n";
for (out.items, 0..) |line, i| {
if (i > 0) try writeOrFail(&joined.writer, newline);
try writeOrFail(&joined.writer, line);
}
if (out.items.len > 0 and switch (ending) {
.original => source.trailing_newline,
.patched_with_newline => true,
.patched_without_newline => false,
}) {
try writeOrFail(&joined.writer, newline);
}
content = try joined.toOwnedSlice();
}
return buildPlan(allocator, path, if (action == .renamed) original_path else null, action, content, added, removed, mode);
}
/// The plan for one file, taking its own copy of both names.
fn buildPlan(
allocator: std.mem.Allocator,
path: []const u8,
old_path: ?[]const u8,
action: Action,
content: ?[]u8,
added: usize,
removed: usize,
mode: ?u32,
) error{OutOfMemory}!Plan {
const owned_path = try allocator.dupe(u8, path);
const owned_old = if (old_path) |old| try allocator.dupe(u8, old) else null;
return .{
.path = owned_path,
.old_path = owned_old,
.action = action,
.content = content,
.added = added,
.removed = removed,
.mode = mode,
};
}
/// What the rebuilt file ends with, which its trailing newline follows from: a
/// file whose last line was copied keeps the ending it had, and a file whose
/// last came from the patch takes that line's `\ No newline at end of file`.
const Ending = enum { original, patched_with_newline, patched_without_newline };
/// A file split into lines, borrowing the bytes it was read into.
const TextFile = struct {
lines: std.ArrayList([]const u8),
/// Whether the last line is terminated.
trailing_newline: bool,
/// Whether the lines are separated by CRLF. The file is written back the
/// way it came in: a patch that touches one line of a CRLF file must not
/// turn every other line into a change as well.
crlf: bool,
fn deinit(self: *TextFile, allocator: std.mem.Allocator) void {
self.lines.deinit(allocator);
}
};
/// Split a file into lines. Line endings are not part of a line: they are the
/// two facts reported alongside, so that a line of a patch is compared as its
/// text whatever kind of file it came from.
fn readLines(allocator: std.mem.Allocator, content: []const u8) !TextFile {
var lines: std.ArrayList([]const u8) = .empty;
errdefer lines.deinit(allocator);
var crlf = false;
var start: usize = 0;
while (start < content.len) {
const end = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len;
var line = content[start..end];
if (std.mem.endsWith(u8, line, "\r")) {
line = line[0 .. line.len - 1];
crlf = true;
}
try lines.append(allocator, line);
start = end + 1;
}
return .{
.lines = lines,
.trailing_newline = content.len > 0 and std.mem.endsWith(u8, content, "\n"),
.crlf = crlf,
};
}
/// How many lines of the original a hunk's old side covers.
fn oldSideLen(hunk: Hunk) usize {
var count: usize = 0;
for (hunk.lines) |line| {
if (line.kind != .add) count += 1;
}
return count;
}
/// Whether a hunk's old side is the text of the file at `at`.
fn matchesAt(lines: []const []const u8, at: usize, hunk: Hunk) bool {
const need = oldSideLen(hunk);
// Written as a subtraction because `at` comes from a number the patch
// chose, and `at + need` on a number that large wraps.
if (at > lines.len or need > lines.len - at) return false;
var j = at;
for (hunk.lines) |line| {
if (line.kind == .add) continue;
if (!std.mem.eql(u8, lines[j], line.text)) return false;
j += 1;
}
return true;
}
/// A confirmed match supplies an offset without signed arithmetic on untrusted
/// header numbers. Overflow/underflow means there is no usable prediction.
const HunkAnchor = struct {
stated: usize,
actual: usize,
fn predict(self: HunkAnchor, stated: usize) ?usize {
if (self.actual >= self.stated)
return std.math.add(usize, stated, self.actual - self.stated) catch null;
return std.math.sub(usize, stated, self.stated - self.actual) catch null;
}
};
/// Try the stated position, then the offset established by the previous hunk,
/// always requiring an exact old-side match outside already consumed text.
/// Otherwise only a unique text match is accepted: proximity alone is not
/// evidence that one of two identical blocks is the intended target.
fn locate(diag: *Diagnostic, path: []const u8, lines: []const []const u8, consumed: usize, hunk: Hunk, stated: usize, anchor: ?HunkAnchor) ?usize {
const need = oldSideLen(hunk);
// A hunk that only adds lines has nothing to match against; it goes where
// it says it goes, which is how the first lines of a new file are placed.
if (need == 0) {
if (stated < consumed or stated > lines.len) {
diag.say("the insertion at patch line {d} names position {d} in '{s}', outside the remaining range {d}..{d}; add context or correct the line number", .{ hunk.patch_line, stated, path, consumed, lines.len });
return null;
}
return stated;
}
if (stated >= consumed and matchesAt(lines, stated, hunk)) return stated;
if (anchor) |previous| {
if (previous.predict(stated)) |predicted| {
if (predicted >= consumed and matchesAt(lines, predicted, hunk)) return predicted;
}
}
var first: ?usize = null;
var second: ?usize = null;
var at = consumed;
while (at <= lines.len) : (at += 1) {
if (!matchesAt(lines, at, hunk)) continue;
if (first == null) {
first = at;
} else {
second = at;
break;
}
}
if (first) |only| {
if (second) |other| {
diag.say(
"the hunk at patch line {d} fits '{s}' at two places, line {d} and line {d}, and its stated line {d} is neither. Add a line of context that is unique to the one you mean",
.{ hunk.patch_line, path, only + 1, other + 1, stated + 1 },
);
return null;
}
return only;
}
return null;
}
/// Explain a hunk that matched nothing, naming the line it was looking for and
/// what the file has there instead: the two facts it takes to fix the patch.
fn describeMiss(
diag: *Diagnostic,
path: []const u8,
lines: []const []const u8,
hunk: Hunk,
stated: usize,
) void {
var wanted: ?[]const u8 = null;
var j = stated;
for (hunk.lines) |line| {
if (line.kind == .add) continue;
if (j >= lines.len or !std.mem.eql(u8, lines[j], line.text)) {
wanted = line.text;
break;
}
j += 1;
}
const expected = wanted orelse {
diag.say("the hunk at patch line {d} does not fit '{s}': the {d} line(s) it expects from line {d} are not in the file", .{ hunk.patch_line, path, oldSideLen(hunk), stated + 1 });
return;
};
if (j < lines.len) {
diag.say("the hunk at patch line {d} does not fit '{s}': it expects \"{s}\" at line {d}, where the file has \"{s}\"", .{ hunk.patch_line, path, expected, j + 1, lines[j] });
} else {
diag.say("the hunk at patch line {d} does not fit '{s}': it expects \"{s}\" after line {d}, which is where the file ends", .{ hunk.patch_line, path, expected, j });
}
}
/// Write into an in-memory buffer, whose only way to fail is the allocation
/// behind it. Keeping that as `OutOfMemory` is what lets every other failure
/// this module reports stay one a patch can be blamed for.
fn writeOrFail(w: *std.Io.Writer, bytes: []const u8) error{OutOfMemory}!void {
w.writeAll(bytes) catch return error.OutOfMemory;
}
fn pathExists(io: std.Io, path: []const u8) bool {
_ = std.Io.Dir.cwd().statFile(io, path, .{}) catch return false;
return true;
}
fn failedWith(allocator: std.mem.Allocator, diag: *Diagnostic) !Outcome {
const written = diag.written();
const message = if (written.len > 0) written else "the patch could not be read";
return .{ .failed = try allocator.dupe(u8, message) };
}
fn fail(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !Outcome {
return .{ .failed = try std.fmt.allocPrint(allocator, fmt, args) };
}
/// The explanation of a patch that failed, written by whichever part of the
/// parse or the apply gave up. It is assembled as it goes rather than derived
/// from an error name, because what a model needs to fix an edit is the file,
/// the line and the text, and an error name carries none of them.
const Diagnostic = struct {
aw: std.Io.Writer.Allocating,
fn init(allocator: std.mem.Allocator) Diagnostic {
return .{ .aw = .init(allocator) };
}
fn deinit(self: *Diagnostic) void {
self.aw.deinit();
}
fn say(self: *Diagnostic, comptime fmt: []const u8, args: anytype) void {
// Failing to write the failure is not worth a second failure: the
// message comes out short or empty, and the caller reports that.
self.aw.writer.print(fmt, args) catch {};
}
fn written(self: *Diagnostic) []const u8 {
return self.aw.written();
}
};
// ---------------------------------------------------------------------------
// Parsing
// ---------------------------------------------------------------------------
const Kind = enum { context, remove, add };
const Line = struct {
kind: Kind,
/// The line without its ` `, `-` or `+` marker, borrowing the patch text.
text: []const u8,
/// Whether the patch followed this line with `\ No newline at end of file`.
no_newline: bool = false,
};
/// A hunk's `@@` header. The new-side numbers are read to make sure this is a
/// header at all, and are not used afterwards: the old side is what has to
/// match the file, and the result is built from the lines.
const HunkHeader = struct {
old_start: usize,
old_count: usize,
new_count: usize,
};
const Hunk = struct {
header: HunkHeader,
lines: []Line,
/// The line of the patch the `@@` is on, so a failure can point at it.
patch_line: usize,
fn deinit(self: Hunk, allocator: std.mem.Allocator) void {
allocator.free(self.lines);
}
};
const FilePatch = struct {
/// Null when the header was `/dev/null`, i.e. the file is being created.
old_path: ?[]const u8,
/// Null when the header was `/dev/null`, i.e. the file is being deleted.
new_path: ?[]const u8,
/// Whether the patch says in git's own words that these two names are one
/// rename, rather than two headers that happen to disagree.
renamed: bool = false,
/// The mode the section named, as git prints one: `new file mode`, or the
/// `new mode` of an `old mode`/`new mode` pair. Null when it named none, in
/// which case the file keeps the permissions it has.
mode: ?u32 = null,
hunks: []Hunk,
fn deinit(self: FilePatch, allocator: std.mem.Allocator) void {
if (self.old_path) |path| allocator.free(path);
if (self.new_path) |path| allocator.free(path);
for (self.hunks) |hunk| hunk.deinit(allocator);
allocator.free(self.hunks);
}
};
/// What a git section says about the file before its hunks: the file it is
/// about, the mode change it may announce, and the `rename from`/`rename to`
/// pair that makes it a rename.
///
/// Kept between the lines that carry it rather than read where it is used,
/// because a rename's two names are written before the headers that repeat them,
/// and because a rename with no hunks at all has nothing else to be read from.
/// Owns its names.
const Section = struct {
/// The name the section starts from: the `rename from` line.
from: ?[]const u8 = null,
/// The name it goes to: the `rename to` line.
to: ?[]const u8 = null,
/// The file `diff --git` says the section is about. Kept for the refusals
/// that have no rename lines to name a file with.
subject: ?[]const u8 = null,
/// The line the section began on, so a refusal can point at it.
line: usize = 0,
/// `new file mode`: the permissions a file this section creates is written
/// with, and — when the section has no hunks at all — what makes it the
/// creation of an empty file.
new_file_mode: ?u32 = null,
/// `deleted file mode`: a deletion of a file that had nothing in it, which
/// git writes with no hunks to remove.
deleted_file_mode: bool = false,
/// `old mode`/`new mode`: the permissions the file ends up with, applied
/// after whatever the hunks wrote.
old_mode: ?u32 = null,
new_mode: ?u32 = null,
/// `copy from`/`copy to`. A copy leaves the original in place, so reading it
/// as a rename would delete a file the patch never mentions again.
copy: bool = false,
fn deinit(self: *Section, allocator: std.mem.Allocator) void {
self.clear(allocator);
self.* = .{};
}
fn clear(self: *Section, allocator: std.mem.Allocator) void {
if (self.from) |from| allocator.free(from);
if (self.to) |to| allocator.free(to);
if (self.subject) |subject| allocator.free(subject);
self.from = null;
self.to = null;
self.subject = null;
self.line = 0;
self.new_file_mode = null;
self.deleted_file_mode = false;
self.old_mode = null;
self.new_mode = null;
self.copy = false;
}
fn isRename(self: Section) bool {
return self.from != null and self.to != null and !std.mem.eql(u8, self.from.?, self.to.?);
}
/// The mode this section gives the file it ends up with, if any. A pair of
/// mode lines that say the same thing is no change at all, and a `new file
/// mode` is the whole of a creation's mode.
fn givenMode(self: Section) ?u32 {
if (self.new_file_mode) |created| return created;
if (self.old_mode) |old| {
if (self.new_mode) |new| {
if (old != new) return new;
}
}
return null;
}
/// The file to name when explaining what this section cannot be applied as.
fn named(self: Section) []const u8 {
return self.from orelse self.to orelse self.subject orelse "a file";
}
};
const Patch = struct {
files: []FilePatch,
fn deinit(self: Patch, allocator: std.mem.Allocator) void {
for (self.files) |file| file.deinit(allocator);
allocator.free(self.files);
}
};
/// The lines of a patch, and how far through them the parse has read.
///
/// A cursor rather than an iterator because a diff needs to look at the line
/// after the one it is reading — the line after `---` decides what that header
/// even is — and to leave a line for whoever reads next when it turns out to
/// belong to them.
const Cursor = struct {
lines: []const []const u8,
i: usize = 0,
fn peek(self: Cursor) ?[]const u8 {
return if (self.i < self.lines.len) self.lines[self.i] else null;
}
fn take(self: *Cursor) ?[]const u8 {
const line = self.peek() orelse return null;
self.i += 1;
return line;
}
/// The 1-based number of the line `peek` would return.
fn number(self: Cursor) usize {
return self.i + 1;
}
};
/// Read a unified diff. Paths and line text borrow `patch_text`, so the parse
/// lives exactly as long as the bytes it was given.
fn parse(allocator: std.mem.Allocator, patch_text: []const u8, diag: *Diagnostic) (error{ OutOfMemory, BadPatch })!Patch {
var all: std.ArrayList([]const u8) = .empty;
defer all.deinit(allocator);
{
var start: usize = 0;
while (start < patch_text.len) {
const end = std.mem.indexOfScalarPos(u8, patch_text, start, '\n') orelse patch_text.len;
const raw = patch_text[start..end];
try all.append(allocator, if (std.mem.endsWith(u8, raw, "\r")) raw[0 .. raw.len - 1] else raw);
start = end + 1;
}
}
var files: std.ArrayList(FilePatch) = .empty;
errdefer {
for (files.items) |file| file.deinit(allocator);
files.deinit(allocator);
}
var cursor: Cursor = .{ .lines = all.items };
// Git's metadata for the file whose hunks are being read, or for the rename
// that has none.
var section: Section = .{};
defer section.deinit(allocator);
while (cursor.take()) |raw| {
const line = std.mem.trimEnd(u8, raw, " \t");
if (std.mem.startsWith(u8, line, "diff --git ")) {
// Whatever the section before this one announced and never got to
// apply is finished here: a rename that changes nothing but the name
// is the whole of its section.
if (try finishSection(allocator, §ion, diag)) |file_patch| {
try appendFile(allocator, &files, file_patch, diag);
}
section.clear(allocator);
section.line = cursor.i;
section.subject = try diffGitSubject(allocator, line);
continue;
}
if (gitMeta(line)) |what| {
// A section that git did not open with `diff --git` is still a
// section, and its first metadata line is where it starts.
if (section.line == 0) section.line = cursor.i;
switch (what) {
// A rename is read from these two lines and from nowhere else:
// they are the only part of a git patch that says "one file, two
// names" rather than "these headers disagree", and git writes
// both for every rename it finds, hunks or none.
.rename_from => try setSectionPath(allocator, §ion.from, line["rename from ".len..]),
.rename_to => try setSectionPath(allocator, §ion.to, line["rename to ".len..]),
.copy => section.copy = true,
}
continue;
}
if (modeLine(line)) |meta| {
if (section.line == 0) section.line = cursor.i;
// Git prints a whole `st_mode`, so the type bits say whether this is
// a file this tool can write at all: a symlink's "contents" are a
// path, and a submodule's are a commit.
if (meta.mode & 0o170000 != 0o100000) {
diag.say("the section on line {d} gives '{s}' mode {o}, which is a symlink or a submodule rather than a regular file: this tool writes regular files only", .{ section.line, section.named(), meta.mode });
return error.BadPatch;
}
switch (meta.kind) {
.new_file => section.new_file_mode = meta.mode,
.deleted_file => section.deleted_file_mode = true,
.old => section.old_mode = meta.mode,
.new => section.new_mode = meta.mode,
}
continue;
}
// A binary change is a git patch nothing here can carry out: refusing at
// the line that says so beats reading base85 as though it were a diff.
if (std.mem.startsWith(u8, line, "Binary files ") or std.mem.startsWith(u8, line, "GIT binary patch")) {
if (section.line == 0) section.line = cursor.i;
diag.say("the section on line {d} changes '{s}' in binary, which this tool does not apply: change the file with 'write' instead", .{ section.line, section.named() });
return error.BadPatch;
}
if (isDecoration(line)) continue;
if (std.mem.startsWith(u8, line, "--- ")) {
// A copy with hunks is still a copy: its hunks would land, and then
// the original would be deleted as though this were a rename.
try rejectCopy(§ion, diag);
const file_patch = try parseFile(allocator, &cursor, line, §ion, diag);
// The section's names are the file's now; the next file starts a new
// one.
section.clear(allocator);
try appendFile(allocator, &files, file_patch, diag);
continue;
}
diag.say("line {d} is not part of a diff: \"{s}\". A patch reads '--- a/path', '+++ b/path', then '@@ -1,2 +1,2 @@' hunks whose lines each start with a space, '-' or '+'", .{ cursor.i, line });
return error.BadPatch;
}
// A rename with no hunks is the last thing in a patch as often as it is the
// first, so the section is finished here too.
if (try finishSection(allocator, §ion, diag)) |file_patch| {
try appendFile(allocator, &files, file_patch, diag);
}
if (files.items.len == 0) {
diag.say("no file headers found: a patch starts with '--- a/path' followed by '+++ b/path', or with a git 'diff --git a/path b/path' section", .{});
return error.BadPatch;
}
return .{ .files = try files.toOwnedSlice(allocator) };
}
/// The git metadata lines that decide what a section means, as opposed to the
/// ones that only say what it came from and are skipped as decoration.
const Meta = enum { rename_from, rename_to, copy };
fn gitMeta(line: []const u8) ?Meta {
if (std.mem.startsWith(u8, line, "rename from ")) return .rename_from;
if (std.mem.startsWith(u8, line, "rename to ")) return .rename_to;
if (std.mem.startsWith(u8, line, "copy from ") or std.mem.startsWith(u8, line, "copy to ")) return .copy;
return null;
}
const ModeKind = enum { new_file, deleted_file, old, new };
/// One of git's four mode lines, with the mode it names.
const ModeMeta = struct {
kind: ModeKind,
/// The whole `st_mode` as git prints it, in octal: `100755`.
mode: u32,
};
fn modeLine(line: []const u8) ?ModeMeta {
const kinds = [_]struct { prefix: []const u8, kind: ModeKind }{
.{ .prefix = "new file mode ", .kind = .new_file },
.{ .prefix = "deleted file mode ", .kind = .deleted_file },
.{ .prefix = "old mode ", .kind = .old },
.{ .prefix = "new mode ", .kind = .new },
};
for (kinds) |kind| {