rufio 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +74 -3
  4. data/README_ja.md +71 -2
  5. data/Rakefile +14 -1
  6. data/bin/rufio +31 -2
  7. data/docs/fast_zip_design_ja.md +167 -0
  8. data/info/help.md +2 -0
  9. data/info/keybindings.md +3 -1
  10. data/info/welcome.md +1 -1
  11. data/lib/rufio/application.rb +12 -1
  12. data/lib/rufio/archive_controller.rb +211 -0
  13. data/lib/rufio/background_command_executor.rb +9 -0
  14. data/lib/rufio/background_status.rb +49 -0
  15. data/lib/rufio/bookmark_controller.rb +38 -121
  16. data/lib/rufio/bookmark_manager.rb +77 -331
  17. data/lib/rufio/command_completion.rb +6 -1
  18. data/lib/rufio/command_history.rb +5 -2
  19. data/lib/rufio/command_mode.rb +40 -134
  20. data/lib/rufio/command_mode_ui.rb +4 -42
  21. data/lib/rufio/config.rb +88 -136
  22. data/lib/rufio/config_loader.rb +3 -11
  23. data/lib/rufio/dialog_renderer.rb +64 -152
  24. data/lib/rufio/dialog_support.rb +69 -0
  25. data/lib/rufio/document_text_extractor.rb +167 -0
  26. data/lib/rufio/file_operation_controller.rb +49 -222
  27. data/lib/rufio/file_preview.rb +46 -2
  28. data/lib/rufio/global_search.rb +121 -9
  29. data/lib/rufio/help_menu.rb +8 -2
  30. data/lib/rufio/interpreter_resolver.rb +6 -11
  31. data/lib/rufio/job_manager.rb +2 -5
  32. data/lib/rufio/key_input.rb +77 -0
  33. data/lib/rufio/keybind_handler.rb +81 -115
  34. data/lib/rufio/local_script_scanner.rb +6 -18
  35. data/lib/rufio/native/aarch64-linux-gnu/librufio_zig.so +0 -0
  36. data/lib/rufio/native/aarch64-linux-musl/librufio_zig.so +0 -0
  37. data/lib/rufio/native/aarch64-macos/librufio_zig.dylib +0 -0
  38. data/lib/rufio/native/aarch64-windows/rufio_zig.dll +0 -0
  39. data/lib/rufio/native/x86_64-linux-gnu/librufio_zig.so +0 -0
  40. data/lib/rufio/native/x86_64-linux-musl/librufio_zig.so +0 -0
  41. data/lib/rufio/native/x86_64-macos/librufio_zig.dylib +0 -0
  42. data/lib/rufio/native/x86_64-windows/rufio_zig.dll +0 -0
  43. data/lib/rufio/native_content_search.rb +153 -0
  44. data/lib/rufio/native_scanner.rb +13 -68
  45. data/lib/rufio/native_scanner_zig.rb +106 -56
  46. data/lib/rufio/native_zip.rb +121 -0
  47. data/lib/rufio/navigation_controller.rb +18 -20
  48. data/lib/rufio/screen.rb +2 -12
  49. data/lib/rufio/script_executor.rb +6 -73
  50. data/lib/rufio/script_file.rb +44 -0
  51. data/lib/rufio/script_path_manager.rb +9 -43
  52. data/lib/rufio/script_runner.rb +13 -50
  53. data/lib/rufio/search_controller.rb +1 -1
  54. data/lib/rufio/shell_command_completion.rb +1 -5
  55. data/lib/rufio/shell_init.rb +90 -0
  56. data/lib/rufio/task_status.rb +5 -1
  57. data/lib/rufio/terminal_ui.rb +55 -294
  58. data/lib/rufio/text_utils.rb +26 -3
  59. data/lib/rufio/ui_renderer.rb +63 -40
  60. data/lib/rufio/version.rb +1 -1
  61. data/lib/rufio/zip_archive.rb +314 -0
  62. data/lib/rufio/zip_extractor.rb +346 -0
  63. data/lib/rufio/zip_writer.rb +268 -0
  64. data/lib/rufio/zoxide_integration.rb +5 -43
  65. data/lib/rufio.rb +5 -0
  66. data/lib_zig/rufio_native/Makefile +54 -17
  67. data/lib_zig/rufio_native/bench.sh +36 -0
  68. data/lib_zig/rufio_native/build.zig +32 -35
  69. data/lib_zig/rufio_native/src/cli.zig +99 -0
  70. data/lib_zig/rufio_native/src/main.zig +187 -83
  71. data/lib_zig/rufio_native/src/os.zig +889 -0
  72. data/lib_zig/rufio_native/src/search.zig +919 -0
  73. data/lib_zig/rufio_native/src/search_test.zig +88 -0
  74. data/lib_zig/rufio_native/src/zip.zig +482 -0
  75. metadata +125 -3
  76. data/lib/rufio/native/rufio_zig.bundle +0 -0
@@ -0,0 +1,889 @@
1
+ //! 検索・スキャナーが使う OS 抽象層(macOS / Linux / Windows)。
2
+ //!
3
+ //! - macOS: getattrlistbulk で名前・種別・サイズ・mtime・inode を一括取得し、openat で開く
4
+ //! - Linux: getdents64 と statx を直接システムコールで呼ぶ(古い glibc でも動くよう libc を経由しない)
5
+ //! - Windows: FindFirstFileExW(LARGE_FETCH)で属性ごと列挙し、CreateFileW/ReadFile で読む
6
+ //!
7
+ //! パスは常に UTF-8・区切り文字 '/' で受け渡す(Ruby の File.join と同じ形)。
8
+ const std = @import("std");
9
+ const builtin = @import("builtin");
10
+ const c = std.c;
11
+ const linux = std.os.linux;
12
+
13
+ pub const is_windows = builtin.os.tag == .windows;
14
+ pub const is_darwin = builtin.os.tag.isDarwin();
15
+ pub const is_linux = builtin.os.tag == .linux;
16
+
17
+ /// キャッシュの有効性判定に使うファイル属性
18
+ pub const FileMeta = struct {
19
+ mtime_sec: i64,
20
+ mtime_nsec: i64,
21
+ ino: u64,
22
+ size: u64,
23
+
24
+ pub fn eql(a: FileMeta, b: FileMeta) bool {
25
+ return a.mtime_sec == b.mtime_sec and a.mtime_nsec == b.mtime_nsec and a.ino == b.ino and a.size == b.size;
26
+ }
27
+ };
28
+
29
+ pub const DirKey = struct { dev: u64, ino: u64 };
30
+
31
+ pub const Kind = enum { file, dir, symlink, unknown, other };
32
+
33
+ pub const Entry = struct {
34
+ name: [:0]const u8,
35
+ kind: Kind,
36
+ /// 通常ファイルで属性が取れた場合のみ
37
+ meta: ?FileMeta,
38
+ };
39
+
40
+ /// stat 相当の情報(スキャナーとシンボリックリンク解決用)
41
+ pub const Info = struct {
42
+ kind: Kind,
43
+ size: u64,
44
+ mtime_sec: i64,
45
+ executable: bool,
46
+ };
47
+
48
+ // ============================================================
49
+ // 同期プリミティブ
50
+ // ============================================================
51
+
52
+ const win = struct {
53
+ const INVALID_HANDLE: usize = std.math.maxInt(usize);
54
+ const GENERIC_READ: u32 = 0x80000000;
55
+ const GENERIC_WRITE: u32 = 0x40000000;
56
+ const CREATE_NEW: u32 = 1;
57
+ const FILE_ATTRIBUTE_NORMAL: u32 = 0x80;
58
+ const FILE_READ_ATTRIBUTES: u32 = 0x80;
59
+ const FILE_SHARE_ALL: u32 = 0x7;
60
+ const OPEN_EXISTING: u32 = 3;
61
+ const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
62
+ const FILE_FLAG_SEQUENTIAL_SCAN: u32 = 0x08000000;
63
+ const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
64
+ const FILE_ATTRIBUTE_DEVICE: u32 = 0x40;
65
+ const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
66
+ const FIND_EX_INFO_BASIC: c_int = 1;
67
+ const FIND_FIRST_EX_LARGE_FETCH: u32 = 2;
68
+ const ERROR_FILE_NOT_FOUND: u32 = 2;
69
+ const ERROR_NO_MORE_FILES: u32 = 18;
70
+ const INFINITE: u32 = 0xFFFFFFFF;
71
+
72
+ const FILETIME = extern struct { low: u32, high: u32 };
73
+
74
+ const OVERLAPPED = extern struct {
75
+ internal: usize = 0,
76
+ internal_high: usize = 0,
77
+ offset: u32,
78
+ offset_high: u32,
79
+ event: ?*anyopaque = null,
80
+ };
81
+
82
+ const FindData = extern struct {
83
+ attributes: u32,
84
+ creation: FILETIME,
85
+ access: FILETIME,
86
+ write: FILETIME,
87
+ size_high: u32,
88
+ size_low: u32,
89
+ reserved0: u32,
90
+ reserved1: u32,
91
+ name: [260]u16,
92
+ alt_name: [14]u16,
93
+ };
94
+
95
+ const ByHandleInfo = extern struct {
96
+ attributes: u32,
97
+ creation: FILETIME,
98
+ access: FILETIME,
99
+ write: FILETIME,
100
+ volume_serial: u32,
101
+ size_high: u32,
102
+ size_low: u32,
103
+ links: u32,
104
+ index_high: u32,
105
+ index_low: u32,
106
+ };
107
+
108
+ extern "kernel32" fn AcquireSRWLockExclusive(lock: *?*anyopaque) callconv(.winapi) void;
109
+ extern "kernel32" fn ReleaseSRWLockExclusive(lock: *?*anyopaque) callconv(.winapi) void;
110
+ extern "kernel32" fn SleepConditionVariableSRW(cv: *?*anyopaque, lock: *?*anyopaque, ms: u32, flags: u32) callconv(.winapi) c_int;
111
+ extern "kernel32" fn WakeConditionVariable(cv: *?*anyopaque) callconv(.winapi) void;
112
+ extern "kernel32" fn WakeAllConditionVariable(cv: *?*anyopaque) callconv(.winapi) void;
113
+ extern "kernel32" fn FindFirstFileExW(name: [*:0]const u16, level: c_int, data: *FindData, op: c_int, filter: ?*anyopaque, flags: u32) callconv(.winapi) usize;
114
+ extern "kernel32" fn FindNextFileW(handle: usize, data: *FindData) callconv(.winapi) c_int;
115
+ extern "kernel32" fn FindClose(handle: usize) callconv(.winapi) c_int;
116
+ extern "kernel32" fn CreateFileW(name: [*:0]const u16, access: u32, share: u32, security: ?*anyopaque, disposition: u32, flags: u32, template: ?*anyopaque) callconv(.winapi) usize;
117
+ extern "kernel32" fn ReadFile(handle: usize, buf: [*]u8, len: u32, read: *u32, overlapped: ?*OVERLAPPED) callconv(.winapi) c_int;
118
+ extern "kernel32" fn CloseHandle(handle: usize) callconv(.winapi) c_int;
119
+ extern "kernel32" fn GetLastError() callconv(.winapi) u32;
120
+ extern "kernel32" fn GetFileInformationByHandle(handle: usize, info: *ByHandleInfo) callconv(.winapi) c_int;
121
+ extern "kernel32" fn QueryPerformanceCounter(count: *i64) callconv(.winapi) c_int;
122
+ extern "kernel32" fn QueryPerformanceFrequency(freq: *i64) callconv(.winapi) c_int;
123
+ extern "kernel32" fn Sleep(ms: u32) callconv(.winapi) void;
124
+ extern "kernel32" fn GetStdHandle(which: u32) callconv(.winapi) usize;
125
+ extern "kernel32" fn WriteFile(handle: usize, buf: [*]const u8, len: u32, written: *u32, overlapped: ?*anyopaque) callconv(.winapi) c_int;
126
+
127
+ fn ticks(ft: FILETIME) i64 {
128
+ return @bitCast((@as(u64, ft.high) << 32) | ft.low);
129
+ }
130
+
131
+ /// FILETIME(1601 年起点・100ns 単位)を Unix 秒に変換
132
+ fn unixSeconds(ft: FILETIME) i64 {
133
+ return @divFloor(ticks(ft), 10_000_000) - 11_644_473_600;
134
+ }
135
+ };
136
+
137
+ pub const Mutex = if (is_windows) struct {
138
+ srw: ?*anyopaque = null,
139
+
140
+ pub fn lock(self: *@This()) void {
141
+ win.AcquireSRWLockExclusive(&self.srw);
142
+ }
143
+ pub fn unlock(self: *@This()) void {
144
+ win.ReleaseSRWLockExclusive(&self.srw);
145
+ }
146
+ } else struct {
147
+ inner: c.pthread_mutex_t = .{},
148
+
149
+ pub fn lock(self: *@This()) void {
150
+ _ = c.pthread_mutex_lock(&self.inner);
151
+ }
152
+ pub fn unlock(self: *@This()) void {
153
+ _ = c.pthread_mutex_unlock(&self.inner);
154
+ }
155
+ };
156
+
157
+ pub const Cond = if (is_windows) struct {
158
+ cv: ?*anyopaque = null,
159
+
160
+ pub fn wait(self: *@This(), mutex: *Mutex) void {
161
+ _ = win.SleepConditionVariableSRW(&self.cv, &mutex.srw, win.INFINITE, 0);
162
+ }
163
+ pub fn signal(self: *@This()) void {
164
+ win.WakeConditionVariable(&self.cv);
165
+ }
166
+ pub fn broadcast(self: *@This()) void {
167
+ win.WakeAllConditionVariable(&self.cv);
168
+ }
169
+ } else struct {
170
+ inner: c.pthread_cond_t = .{},
171
+
172
+ pub fn wait(self: *@This(), mutex: *Mutex) void {
173
+ _ = c.pthread_cond_wait(&self.inner, &mutex.inner);
174
+ }
175
+ pub fn signal(self: *@This()) void {
176
+ _ = c.pthread_cond_signal(&self.inner);
177
+ }
178
+ pub fn broadcast(self: *@This()) void {
179
+ _ = c.pthread_cond_broadcast(&self.inner);
180
+ }
181
+ };
182
+
183
+ // ============================================================
184
+ // エラー
185
+ // ============================================================
186
+
187
+ extern "c" fn strerror(errnum: c_int) [*:0]const u8;
188
+
189
+ /// 直前に失敗した操作のエラーコード
190
+ pub const ErrorCode = u32;
191
+
192
+ threadlocal var last_error: ErrorCode = 0;
193
+
194
+ fn setPosixError(rc: usize) void {
195
+ last_error = @intFromEnum(linux.errno(rc));
196
+ }
197
+
198
+ fn captureError() void {
199
+ if (is_windows) {
200
+ last_error = win.GetLastError();
201
+ } else if (!is_linux) {
202
+ last_error = @intCast(@intFromEnum(c.errno(-1)));
203
+ }
204
+ }
205
+
206
+ /// Ruby 側のエラー表示用メッセージ
207
+ pub fn lastErrorMessage() []const u8 {
208
+ if (is_windows) {
209
+ return switch (last_error) {
210
+ 2, 3 => "No such file or directory",
211
+ 5 => "Permission denied",
212
+ 32, 33 => "Device or resource busy",
213
+ else => "Windows error",
214
+ };
215
+ }
216
+ return std.mem.span(strerror(@intCast(last_error)));
217
+ }
218
+
219
+ // ============================================================
220
+ // macOS: getattrlistbulk
221
+ // ============================================================
222
+
223
+ extern "c" fn getattrlistbulk(dirfd: c_int, attr_list: *const AttrList, buf: [*]u8, size: usize, options: u64) c_int;
224
+
225
+ const AttrList = extern struct {
226
+ bitmapcount: u16 = 5,
227
+ reserved: u16 = 0,
228
+ commonattr: u32 = 0,
229
+ volattr: u32 = 0,
230
+ dirattr: u32 = 0,
231
+ fileattr: u32 = 0,
232
+ forkattr: u32 = 0,
233
+ };
234
+
235
+ const ATTR_CMN_NAME: u32 = 0x00000001;
236
+ const ATTR_CMN_OBJTYPE: u32 = 0x00000008;
237
+ const ATTR_CMN_MODTIME: u32 = 0x00000400;
238
+ const ATTR_CMN_FILEID: u32 = 0x02000000;
239
+ const ATTR_CMN_RETURNED_ATTRS: u32 = 0x80000000;
240
+ const ATTR_FILE_DATALENGTH: u32 = 0x00000200;
241
+ const FSOPT_PACK_INVAL_ATTRS: u64 = 0x00000008;
242
+ const VREG: u32 = 1;
243
+ const VDIR: u32 = 2;
244
+ const VLNK: u32 = 5;
245
+
246
+ const bulk_attrs = AttrList{
247
+ .commonattr = ATTR_CMN_RETURNED_ATTRS | ATTR_CMN_NAME | ATTR_CMN_OBJTYPE | ATTR_CMN_MODTIME | ATTR_CMN_FILEID,
248
+ .fileattr = ATTR_FILE_DATALENGTH,
249
+ };
250
+
251
+ // ============================================================
252
+ // ディレクトリ
253
+ // ============================================================
254
+
255
+ /// ディレクトリ読み込み用の作業領域(スレッドごとに 1 つ)
256
+ pub const Scratch = struct {
257
+ pub const DIR_BUFFER = 64 * 1024;
258
+ /// Windows の長いパス(\\?\ 形式)にも足りる UTF-16 バッファ
259
+ const WIDE_LEN = 32 * 1024;
260
+
261
+ buf: []u8 = &.{},
262
+ wide: []u16 = &.{},
263
+ name: []u8 = &.{},
264
+
265
+ pub fn init(self: *Scratch) !void {
266
+ const alloc = std.heap.c_allocator;
267
+ self.buf = try alloc.alloc(u8, DIR_BUFFER);
268
+ if (is_windows) {
269
+ self.wide = try alloc.alloc(u16, WIDE_LEN);
270
+ self.name = try alloc.alloc(u8, 260 * 3 + 1);
271
+ }
272
+ }
273
+
274
+ pub fn deinit(self: *Scratch) void {
275
+ const alloc = std.heap.c_allocator;
276
+ if (self.buf.len > 0) alloc.free(self.buf);
277
+ if (self.wide.len > 0) alloc.free(self.wide);
278
+ if (self.name.len > 0) alloc.free(self.name);
279
+ self.* = .{};
280
+ }
281
+
282
+ /// UTF-8 のパス(dir と name を '/' で連結)を Win32 用の UTF-16 に変換する
283
+ fn widePath(self: *Scratch, dir: []const u8, name: []const u8) ?[*:0]const u16 {
284
+ const total = dir.len + 1 + name.len;
285
+ // 260 文字制限を超えるパスは \\?\ 形式(区切りは '\' 必須)にする
286
+ const long = total >= 248;
287
+ var w: usize = 0;
288
+ if (long) {
289
+ const prefix = if (std.mem.startsWith(u8, dir, "//")) "\\\\?\\UNC\\" else "\\\\?\\";
290
+ for (prefix) |ch| {
291
+ self.wide[w] = ch;
292
+ w += 1;
293
+ }
294
+ }
295
+ const dir_part = if (long and std.mem.startsWith(u8, dir, "//")) dir[2..] else dir;
296
+ w += appendWide(self.wide[w..], dir_part, long) orelse return null;
297
+ if (name.len > 0) {
298
+ if (w + 1 >= self.wide.len) return null;
299
+ self.wide[w] = if (long) '\\' else '/';
300
+ w += 1;
301
+ w += appendWide(self.wide[w..], name, long) orelse return null;
302
+ }
303
+ if (w >= self.wide.len) return null;
304
+ self.wide[w] = 0;
305
+ return self.wide[0..w :0].ptr;
306
+ }
307
+
308
+ fn appendWide(dst: []u16, utf8: []const u8, backslash: bool) ?usize {
309
+ const need = std.unicode.calcWtf16LeLen(utf8) catch return null;
310
+ if (need + 1 > dst.len) return null;
311
+ const n = std.unicode.wtf8ToWtf16Le(dst, utf8) catch return null;
312
+ if (backslash) {
313
+ for (dst[0..n]) |*ch| {
314
+ if (ch.* == '/') ch.* = '\\';
315
+ }
316
+ }
317
+ return n;
318
+ }
319
+ };
320
+
321
+ pub const Dir = struct {
322
+ scratch: *Scratch,
323
+ path: []const u8,
324
+ // POSIX
325
+ fd: c_int = -1,
326
+ used: usize = 0,
327
+ offset: usize = 0,
328
+ remaining: c_int = 0,
329
+ // Windows
330
+ find: usize = win.INVALID_HANDLE,
331
+ find_data: if (is_windows) win.FindData else void = undefined,
332
+ pending: bool = false,
333
+
334
+ /// path は NUL 終端の UTF-8('/' 区切り)
335
+ pub fn open(path: [:0]const u8, scratch: *Scratch) ?Dir {
336
+ var self = Dir{ .scratch = scratch, .path = path };
337
+ if (is_windows) {
338
+ const pattern = scratch.widePath(path, "*") orelse {
339
+ last_error = 3;
340
+ return null;
341
+ };
342
+ self.find = win.FindFirstFileExW(pattern, win.FIND_EX_INFO_BASIC, &self.find_data, 0, null, win.FIND_FIRST_EX_LARGE_FETCH);
343
+ if (self.find == win.INVALID_HANDLE) {
344
+ captureError();
345
+ // 空のドライブ直下などは「エントリなし」として扱う
346
+ if (last_error == win.ERROR_FILE_NOT_FOUND) return self;
347
+ return null;
348
+ }
349
+ self.pending = true;
350
+ return self;
351
+ }
352
+ if (is_linux) {
353
+ const rc = linux.openat(linux.AT.FDCWD, path.ptr, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
354
+ if (linux.errno(rc) != .SUCCESS) {
355
+ setPosixError(rc);
356
+ return null;
357
+ }
358
+ self.fd = @intCast(rc);
359
+ return self;
360
+ }
361
+ self.fd = c.open(path.ptr, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true });
362
+ if (self.fd < 0) {
363
+ captureError();
364
+ return null;
365
+ }
366
+ return self;
367
+ }
368
+
369
+ pub fn close(self: *Dir) void {
370
+ if (is_windows) {
371
+ if (self.find != win.INVALID_HANDLE) _ = win.FindClose(self.find);
372
+ self.find = win.INVALID_HANDLE;
373
+ } else if (is_linux) {
374
+ if (self.fd >= 0) _ = linux.close(self.fd);
375
+ } else {
376
+ if (self.fd >= 0) _ = c.close(self.fd);
377
+ }
378
+ self.fd = -1;
379
+ }
380
+
381
+ /// 重複走査の判定用 ID(取得できなければ null)
382
+ pub fn identity(self: *Dir) ?DirKey {
383
+ if (is_windows) {
384
+ const wide = self.scratch.widePath(self.path, "") orelse return null;
385
+ const h = win.CreateFileW(wide, win.FILE_READ_ATTRIBUTES, win.FILE_SHARE_ALL, null, win.OPEN_EXISTING, win.FILE_FLAG_BACKUP_SEMANTICS, null);
386
+ if (h == win.INVALID_HANDLE) return null;
387
+ defer _ = win.CloseHandle(h);
388
+ var info: win.ByHandleInfo = undefined;
389
+ if (win.GetFileInformationByHandle(h, &info) == 0) return null;
390
+ return .{ .dev = info.volume_serial, .ino = (@as(u64, info.index_high) << 32) | info.index_low };
391
+ }
392
+ if (is_linux) {
393
+ var stx: linux.Statx = undefined;
394
+ const rc = linux.statx(self.fd, "", linux.AT.EMPTY_PATH, .{ .INO = true }, &stx);
395
+ if (linux.errno(rc) != .SUCCESS) return null;
396
+ return .{ .dev = (@as(u64, stx.dev_major) << 32) | stx.dev_minor, .ino = stx.ino };
397
+ }
398
+ var st: c.Stat = undefined;
399
+ if (c.fstat(self.fd, &st) != 0) return null;
400
+ return .{ .dev = @bitCast(@as(i64, st.dev)), .ino = st.ino };
401
+ }
402
+
403
+ /// 次のエントリ。終端は null、読み込みエラーは error.ReadFailed
404
+ pub fn next(self: *Dir) error{ReadFailed}!?Entry {
405
+ if (is_windows) return self.nextWindows();
406
+ if (is_linux) return self.nextLinux();
407
+ return self.nextDarwin();
408
+ }
409
+
410
+ fn nextDarwin(self: *Dir) error{ReadFailed}!?Entry {
411
+ const buf = self.scratch.buf;
412
+ while (true) {
413
+ if (self.remaining == 0) {
414
+ const n = getattrlistbulk(self.fd, &bulk_attrs, buf.ptr, buf.len, FSOPT_PACK_INVAL_ATTRS);
415
+ if (n < 0) {
416
+ captureError();
417
+ if (c.errno(-1) == .INTR) continue;
418
+ return error.ReadFailed;
419
+ }
420
+ if (n == 0) return null;
421
+ self.remaining = n;
422
+ self.offset = 0;
423
+ }
424
+ self.remaining -= 1;
425
+ // レイアウト: length, returned_attrs[5], name_ref{off,len}, objtype, modtime, fileid, datalength
426
+ const entry = buf.ptr + self.offset;
427
+ const length = std.mem.readInt(u32, entry[0..4], .little);
428
+ self.offset += length;
429
+ const returned_common = std.mem.readInt(u32, entry[4..8], .little);
430
+ const returned_file = std.mem.readInt(u32, entry[16..20], .little);
431
+ const name_ref = entry + 24;
432
+ const name_off = std.mem.readInt(i32, name_ref[0..4], .little);
433
+ const name_len = std.mem.readInt(u32, name_ref[4..8], .little);
434
+ const objtype = std.mem.readInt(u32, entry[32..36], .little);
435
+ if (name_len <= 1) continue;
436
+ // name_len は終端 NUL を含む
437
+ const name_ptr: [*:0]const u8 = @ptrCast(name_ref + @as(usize, @intCast(name_off)));
438
+ const name = name_ptr[0 .. name_len - 1 :0];
439
+ const has_meta = returned_file & ATTR_FILE_DATALENGTH != 0 and
440
+ returned_common & (ATTR_CMN_MODTIME | ATTR_CMN_FILEID) == (ATTR_CMN_MODTIME | ATTR_CMN_FILEID);
441
+ const kind: Kind = switch (objtype) {
442
+ VREG => .file,
443
+ VDIR => .dir,
444
+ VLNK => .symlink,
445
+ else => .other,
446
+ };
447
+ const meta: ?FileMeta = if (kind == .file and has_meta) .{
448
+ .mtime_sec = std.mem.readInt(i64, entry[36..44], .little),
449
+ .mtime_nsec = std.mem.readInt(i64, entry[44..52], .little),
450
+ .ino = std.mem.readInt(u64, entry[52..60], .little),
451
+ .size = std.mem.readInt(u64, entry[60..68], .little),
452
+ } else null;
453
+ return .{ .name = name, .kind = kind, .meta = meta };
454
+ }
455
+ }
456
+
457
+ fn nextLinux(self: *Dir) error{ReadFailed}!?Entry {
458
+ const buf = self.scratch.buf;
459
+ while (true) {
460
+ if (self.offset >= self.used) {
461
+ const rc = linux.getdents64(self.fd, buf.ptr, @intCast(buf.len));
462
+ switch (linux.errno(rc)) {
463
+ .SUCCESS => {},
464
+ .INTR => continue,
465
+ else => {
466
+ setPosixError(rc);
467
+ return error.ReadFailed;
468
+ },
469
+ }
470
+ if (rc == 0) return null;
471
+ self.used = rc;
472
+ self.offset = 0;
473
+ }
474
+ const ent: *align(1) const linux.dirent64 = @ptrCast(buf.ptr + self.offset);
475
+ self.offset += ent.reclen;
476
+ const name_ptr: [*:0]const u8 = @ptrCast(&ent.name);
477
+ const name = std.mem.span(name_ptr);
478
+ if (isDotEntry(name)) continue;
479
+ var kind: Kind = switch (ent.type) {
480
+ linux.DT.REG => .file,
481
+ linux.DT.DIR => .dir,
482
+ linux.DT.LNK => .symlink,
483
+ linux.DT.UNKNOWN => .unknown,
484
+ else => .other,
485
+ };
486
+ var meta: ?FileMeta = null;
487
+ if (kind == .file or kind == .unknown) {
488
+ if (self.statLinux(name, false)) |stx| {
489
+ kind = kindFromMode(stx.mode);
490
+ if (kind == .file) meta = metaFromStatx(&stx);
491
+ }
492
+ }
493
+ return .{ .name = name, .kind = kind, .meta = meta };
494
+ }
495
+ }
496
+
497
+ fn nextWindows(self: *Dir) error{ReadFailed}!?Entry {
498
+ while (true) {
499
+ if (self.find == win.INVALID_HANDLE) return null;
500
+ if (!self.pending) {
501
+ if (win.FindNextFileW(self.find, &self.find_data) == 0) {
502
+ captureError();
503
+ if (last_error == win.ERROR_NO_MORE_FILES) return null;
504
+ return error.ReadFailed;
505
+ }
506
+ }
507
+ self.pending = false;
508
+ const data = &self.find_data;
509
+ const wide = std.mem.sliceTo(&data.name, 0);
510
+ const n = std.unicode.wtf16LeToWtf8(self.scratch.name, wide);
511
+ self.scratch.name[n] = 0;
512
+ const name = self.scratch.name[0..n :0];
513
+ if (isDotEntry(name)) continue;
514
+ const attrs = data.attributes;
515
+ const kind: Kind = if (attrs & win.FILE_ATTRIBUTE_REPARSE_POINT != 0)
516
+ .symlink
517
+ else if (attrs & win.FILE_ATTRIBUTE_DIRECTORY != 0)
518
+ .dir
519
+ else if (attrs & win.FILE_ATTRIBUTE_DEVICE != 0)
520
+ .other
521
+ else
522
+ .file;
523
+ const meta: ?FileMeta = if (kind == .file) .{
524
+ .mtime_sec = win.ticks(data.write),
525
+ .mtime_nsec = 0,
526
+ .ino = 0,
527
+ .size = (@as(u64, data.size_high) << 32) | data.size_low,
528
+ } else null;
529
+ return .{ .name = name, .kind = kind, .meta = meta };
530
+ }
531
+ }
532
+
533
+ fn statLinux(self: *Dir, name: [*:0]const u8, follow: bool) ?linux.Statx {
534
+ var stx: linux.Statx = undefined;
535
+ const flags: u32 = if (follow) 0 else linux.AT.SYMLINK_NOFOLLOW;
536
+ const rc = linux.statx(self.fd, name, flags, .{ .TYPE = true, .MODE = true, .MTIME = true, .INO = true, .SIZE = true }, &stx);
537
+ if (linux.errno(rc) != .SUCCESS) return null;
538
+ return stx;
539
+ }
540
+
541
+ /// エントリの stat(follow=true でシンボリックリンクを辿る)
542
+ pub fn stat(self: *Dir, name: [:0]const u8, follow: bool) ?Info {
543
+ if (is_windows) {
544
+ const wide = self.scratch.widePath(self.path, name) orelse return null;
545
+ const flags = win.FILE_FLAG_BACKUP_SEMANTICS | (if (follow) 0 else @as(u32, 0x00200000)); // OPEN_REPARSE_POINT
546
+ const h = win.CreateFileW(wide, win.FILE_READ_ATTRIBUTES, win.FILE_SHARE_ALL, null, win.OPEN_EXISTING, flags, null);
547
+ if (h == win.INVALID_HANDLE) return null;
548
+ defer _ = win.CloseHandle(h);
549
+ var info: win.ByHandleInfo = undefined;
550
+ if (win.GetFileInformationByHandle(h, &info) == 0) return null;
551
+ const attrs = info.attributes;
552
+ return .{
553
+ .kind = if (!follow and attrs & win.FILE_ATTRIBUTE_REPARSE_POINT != 0)
554
+ .symlink
555
+ else if (attrs & win.FILE_ATTRIBUTE_DIRECTORY != 0)
556
+ .dir
557
+ else
558
+ .file,
559
+ .size = (@as(u64, info.size_high) << 32) | info.size_low,
560
+ .mtime_sec = win.unixSeconds(info.write),
561
+ .executable = isWindowsExecutable(name),
562
+ };
563
+ }
564
+ if (is_linux) {
565
+ const stx = self.statLinux(name.ptr, follow) orelse return null;
566
+ return .{
567
+ .kind = kindFromMode(stx.mode),
568
+ .size = stx.size,
569
+ .mtime_sec = stx.mtime.sec,
570
+ .executable = stx.mode & 0o100 != 0,
571
+ };
572
+ }
573
+ var st: c.Stat = undefined;
574
+ if (c.fstatat(self.fd, name.ptr, &st, if (follow) 0 else c.AT.SYMLINK_NOFOLLOW) != 0) return null;
575
+ return .{
576
+ .kind = kindFromMode(st.mode),
577
+ .size = @intCast(st.size),
578
+ .mtime_sec = @intCast(st.mtime().sec),
579
+ .executable = st.mode & c.S.IXUSR != 0,
580
+ };
581
+ }
582
+
583
+ /// 現在のエントリのスキャナー用情報(Windows は列挙結果をそのまま使う)
584
+ pub fn currentInfo(self: *Dir, entry: Entry) ?Info {
585
+ if (is_windows) {
586
+ const data = &self.find_data;
587
+ return .{
588
+ .kind = entry.kind,
589
+ .size = (@as(u64, data.size_high) << 32) | data.size_low,
590
+ .mtime_sec = win.unixSeconds(data.write),
591
+ .executable = isWindowsExecutable(entry.name),
592
+ };
593
+ }
594
+ return self.stat(entry.name, false);
595
+ }
596
+
597
+ pub fn openFile(self: *Dir, name: [:0]const u8) ?File {
598
+ if (is_windows) {
599
+ const wide = self.scratch.widePath(self.path, name) orelse {
600
+ last_error = 3;
601
+ return null;
602
+ };
603
+ const h = win.CreateFileW(wide, win.GENERIC_READ, win.FILE_SHARE_ALL, null, win.OPEN_EXISTING, win.FILE_FLAG_SEQUENTIAL_SCAN, null);
604
+ if (h == win.INVALID_HANDLE) {
605
+ captureError();
606
+ return null;
607
+ }
608
+ return .{ .handle = h };
609
+ }
610
+ if (is_linux) {
611
+ const rc = linux.openat(self.fd, name.ptr, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
612
+ if (linux.errno(rc) != .SUCCESS) {
613
+ setPosixError(rc);
614
+ return null;
615
+ }
616
+ return .{ .handle = rc };
617
+ }
618
+ const fd = c.openat(self.fd, name.ptr, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
619
+ if (fd < 0) {
620
+ captureError();
621
+ return null;
622
+ }
623
+ return .{ .handle = @intCast(fd) };
624
+ }
625
+ };
626
+
627
+ /// UTF-8 の絶対パスで読み取り用に開く(ZIP データ処理用)
628
+ pub fn openRead(path: [:0]const u8, scratch: *Scratch) ?File {
629
+ if (is_windows) {
630
+ const wide = scratch.widePath(path, "") orelse {
631
+ last_error = 3;
632
+ return null;
633
+ };
634
+ const h = win.CreateFileW(wide, win.GENERIC_READ, win.FILE_SHARE_ALL, null, win.OPEN_EXISTING, win.FILE_FLAG_SEQUENTIAL_SCAN, null);
635
+ if (h == win.INVALID_HANDLE) {
636
+ captureError();
637
+ return null;
638
+ }
639
+ return .{ .handle = h };
640
+ }
641
+ if (is_linux) {
642
+ const rc = linux.open(path.ptr, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }, 0);
643
+ if (linux.errno(rc) != .SUCCESS) {
644
+ setPosixError(rc);
645
+ return null;
646
+ }
647
+ return .{ .handle = rc };
648
+ }
649
+ const fd = c.open(path.ptr, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
650
+ if (fd < 0) {
651
+ captureError();
652
+ return null;
653
+ }
654
+ return .{ .handle = @intCast(fd) };
655
+ }
656
+
657
+ /// 新しいファイルを作成する(既に存在すれば失敗。権限は所有者のみ読み書き)
658
+ pub fn createExclusive(path: [:0]const u8, scratch: *Scratch) ?File {
659
+ if (is_windows) {
660
+ const wide = scratch.widePath(path, "") orelse {
661
+ last_error = 3;
662
+ return null;
663
+ };
664
+ const h = win.CreateFileW(wide, win.GENERIC_WRITE, 0, null, win.CREATE_NEW, win.FILE_ATTRIBUTE_NORMAL, null);
665
+ if (h == win.INVALID_HANDLE) {
666
+ captureError();
667
+ return null;
668
+ }
669
+ return .{ .handle = h };
670
+ }
671
+ if (is_linux) {
672
+ const rc = linux.open(path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .EXCL = true, .CLOEXEC = true }, 0o600);
673
+ if (linux.errno(rc) != .SUCCESS) {
674
+ setPosixError(rc);
675
+ return null;
676
+ }
677
+ return .{ .handle = rc };
678
+ }
679
+ const fd = c.open(path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .EXCL = true, .CLOEXEC = true }, @as(c.mode_t, 0o600));
680
+ if (fd < 0) {
681
+ captureError();
682
+ return null;
683
+ }
684
+ return .{ .handle = @intCast(fd) };
685
+ }
686
+
687
+ pub const File = struct {
688
+ handle: usize,
689
+
690
+ /// offset の位置から読む(ファイル位置は使わないので複数スレッドから呼べる)。0 は EOF、エラー時は null
691
+ pub fn pread(self: File, dst: []u8, offset: u64) ?usize {
692
+ if (is_windows) {
693
+ var got: u32 = 0;
694
+ var ov: win.OVERLAPPED = .{ .offset = @truncate(offset), .offset_high = @truncate(offset >> 32) };
695
+ const len: u32 = @intCast(@min(dst.len, std.math.maxInt(u32)));
696
+ if (win.ReadFile(self.handle, dst.ptr, len, &got, &ov) == 0) {
697
+ // ERROR_HANDLE_EOF
698
+ if (win.GetLastError() == 38) return 0;
699
+ captureError();
700
+ return null;
701
+ }
702
+ return got;
703
+ }
704
+ while (true) {
705
+ if (is_linux) {
706
+ const rc = linux.pread(@intCast(self.handle), dst.ptr, dst.len, @intCast(offset));
707
+ switch (linux.errno(rc)) {
708
+ .SUCCESS => return rc,
709
+ .INTR => continue,
710
+ else => {
711
+ setPosixError(rc);
712
+ return null;
713
+ },
714
+ }
715
+ }
716
+ const n = c.pread(@intCast(self.handle), dst.ptr, dst.len, @intCast(offset));
717
+ if (n >= 0) return @intCast(n);
718
+ captureError();
719
+ if (c.errno(-1) == .INTR) continue;
720
+ return null;
721
+ }
722
+ }
723
+
724
+ /// すべて書き込めたら true
725
+ pub fn writeAll(self: File, bytes: []const u8) bool {
726
+ var rest = bytes;
727
+ while (rest.len > 0) {
728
+ var n: usize = 0;
729
+ if (is_windows) {
730
+ var written: u32 = 0;
731
+ const len: u32 = @intCast(@min(rest.len, 1 << 30));
732
+ if (win.WriteFile(self.handle, rest.ptr, len, &written, null) == 0) {
733
+ captureError();
734
+ return false;
735
+ }
736
+ n = written;
737
+ } else if (is_linux) {
738
+ const rc = linux.write(@intCast(self.handle), rest.ptr, rest.len);
739
+ switch (linux.errno(rc)) {
740
+ .SUCCESS => n = rc,
741
+ .INTR => continue,
742
+ else => {
743
+ setPosixError(rc);
744
+ return false;
745
+ },
746
+ }
747
+ } else {
748
+ const rc = c.write(@intCast(self.handle), rest.ptr, rest.len);
749
+ if (rc < 0) {
750
+ captureError();
751
+ if (c.errno(-1) == .INTR) continue;
752
+ return false;
753
+ }
754
+ n = @intCast(rc);
755
+ }
756
+ if (n == 0) return false;
757
+ rest = rest[n..];
758
+ }
759
+ return true;
760
+ }
761
+
762
+ /// 読めたバイト数(0 は EOF)。エラー時は null
763
+ pub fn read(self: File, dst: []u8) ?usize {
764
+ if (is_windows) {
765
+ var got: u32 = 0;
766
+ const len: u32 = @intCast(@min(dst.len, std.math.maxInt(u32)));
767
+ if (win.ReadFile(self.handle, dst.ptr, len, &got, null) == 0) {
768
+ captureError();
769
+ return null;
770
+ }
771
+ return got;
772
+ }
773
+ while (true) {
774
+ if (is_linux) {
775
+ const rc = linux.read(@intCast(self.handle), dst.ptr, dst.len);
776
+ switch (linux.errno(rc)) {
777
+ .SUCCESS => return rc,
778
+ .INTR => continue,
779
+ else => {
780
+ setPosixError(rc);
781
+ return null;
782
+ },
783
+ }
784
+ }
785
+ const n = c.read(@intCast(self.handle), dst.ptr, dst.len);
786
+ if (n >= 0) return @intCast(n);
787
+ captureError();
788
+ if (c.errno(-1) == .INTR) continue;
789
+ return null;
790
+ }
791
+ }
792
+
793
+ pub fn close(self: File) void {
794
+ if (is_windows) {
795
+ _ = win.CloseHandle(self.handle);
796
+ } else if (is_linux) {
797
+ _ = linux.close(@intCast(self.handle));
798
+ } else {
799
+ _ = c.close(@intCast(self.handle));
800
+ }
801
+ }
802
+ };
803
+
804
+ fn isDotEntry(name: []const u8) bool {
805
+ return name.len == 0 or (name[0] == '.' and (name.len == 1 or (name.len == 2 and name[1] == '.')));
806
+ }
807
+
808
+ fn kindFromMode(mode: anytype) Kind {
809
+ const m: u32 = @intCast(mode);
810
+ return switch (m & 0o170000) {
811
+ 0o100000 => .file,
812
+ 0o040000 => .dir,
813
+ 0o120000 => .symlink,
814
+ else => .other,
815
+ };
816
+ }
817
+
818
+ fn metaFromStatx(stx: *const linux.Statx) FileMeta {
819
+ return .{ .mtime_sec = stx.mtime.sec, .mtime_nsec = stx.mtime.nsec, .ino = stx.ino, .size = stx.size };
820
+ }
821
+
822
+ /// Ruby の File.executable? と同様、Windows では拡張子で判定する
823
+ fn isWindowsExecutable(name: []const u8) bool {
824
+ const exts = [_][]const u8{ ".exe", ".com", ".bat", ".cmd" };
825
+ for (exts) |ext| {
826
+ if (name.len >= ext.len and std.ascii.eqlIgnoreCase(name[name.len - ext.len ..], ext)) return true;
827
+ }
828
+ return false;
829
+ }
830
+
831
+ /// 既定のスレッド数。macOS は性能コア数が最速(効率コアまで使うとカーネル内の競合で逆に遅くなる)
832
+ pub fn defaultThreads() usize {
833
+ if (is_darwin) {
834
+ var value: c_int = 0;
835
+ var len: usize = @sizeOf(c_int);
836
+ if (c.sysctlbyname("hw.perflevel0.physicalcpu", &value, &len, null, 0) == 0 and value > 0) {
837
+ return @intCast(value);
838
+ }
839
+ }
840
+ return std.Thread.getCpuCount() catch 4;
841
+ }
842
+
843
+ // ============================================================
844
+ // ベンチマーク CLI 用ユーティリティ
845
+ // ============================================================
846
+
847
+ /// 単調増加時計(ナノ秒)
848
+ pub fn nowNs() u64 {
849
+ if (is_windows) {
850
+ var count: i64 = 0;
851
+ var freq: i64 = 1;
852
+ _ = win.QueryPerformanceCounter(&count);
853
+ _ = win.QueryPerformanceFrequency(&freq);
854
+ return @intCast(@divFloor(@as(i128, count) * 1_000_000_000, freq));
855
+ }
856
+ var ts: c.timespec = undefined;
857
+ _ = c.clock_gettime(.MONOTONIC, &ts);
858
+ return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
859
+ }
860
+
861
+ pub fn sleepMicros(us: u32) void {
862
+ if (is_windows) {
863
+ win.Sleep(@max(us / 1000, 1));
864
+ return;
865
+ }
866
+ const ts: c.timespec = .{ .sec = 0, .nsec = @as(c_long, us) * 1000 };
867
+ _ = c.nanosleep(&ts, null);
868
+ }
869
+
870
+ /// fd: 1=標準出力, 2=標準エラー
871
+ pub fn writeAll(fd: u8, bytes: []const u8) void {
872
+ var rest = bytes;
873
+ while (rest.len > 0) {
874
+ var n: usize = 0;
875
+ if (is_windows) {
876
+ const handle = win.GetStdHandle(if (fd == 2) @as(u32, @bitCast(@as(i32, -12))) else @as(u32, @bitCast(@as(i32, -11))));
877
+ var written: u32 = 0;
878
+ const len: u32 = @intCast(@min(rest.len, 1 << 30));
879
+ if (win.WriteFile(handle, rest.ptr, len, &written, null) == 0) return;
880
+ n = written;
881
+ } else {
882
+ const rc = c.write(fd, rest.ptr, rest.len);
883
+ if (rc <= 0) return;
884
+ n = @intCast(rc);
885
+ }
886
+ if (n == 0) return;
887
+ rest = rest[n..];
888
+ }
889
+ }