1const std = @import("std");
2const builtin = @import("builtin");
3const ourio = @import("ourio");
4const zeit = @import("zeit");
5const natord = @import("natord.zig");
6const build_options = @import("build_options");
7
8const posix = std.posix;
9
10const usage =
11 \\Usage:
12 \\ lsr [options] [path]
13 \\
14 \\ --help Print this message and exit
15 \\ --version Print the version string
16 \\
17 \\DISPLAY OPTIONS
18 \\ -1, --oneline Print entries one per line
19 \\ -a, --all Show files that start with a dot (ASCII 0x2E)
20 \\ -A, --almost-all Like --all, but skips implicit "." and ".." directories
21 \\ -C, --columns Print the output in columns
22 \\ --color=WHEN When to use colors (always, auto, never)
23 \\ --group-directories-first Print all directories before printing regular files
24 \\ --hyperlinks=WHEN When to use OSC 8 hyperlinks (always, auto, never)
25 \\ --icons=WHEN When to display icons (always, auto, never)
26 \\ -l, --long Display extended file metadata
27 \\ -r, --reverse Reverse the sort order
28 \\ -t, --time Sort the entries by modification time, most recent first
29 \\
30;
31
32const queue_size = 256;
33
34const Options = struct {
35 all: bool = false,
36 @"almost-all": bool = false,
37 color: When = .auto,
38 shortview: enum { columns, oneline } = .oneline,
39 @"group-directories-first": bool = true,
40 hyperlinks: When = .auto,
41 icons: When = .auto,
42 long: bool = false,
43 sort_by_mod_time: bool = false,
44 reverse_sort: bool = false,
45
46 directory: [:0]const u8 = ".",
47 file: ?[]const u8 = null,
48
49 winsize: ?posix.winsize = null,
50 colors: Colors = .none,
51
52 const When = enum {
53 never,
54 auto,
55 always,
56 };
57
58 const Colors = struct {
59 reset: []const u8,
60 dir: []const u8,
61 executable: []const u8,
62 symlink: []const u8,
63 symlink_target: []const u8,
64 symlink_missing: []const u8,
65
66 const none: Colors = .{
67 .reset = "",
68 .dir = "",
69 .executable = "",
70 .symlink = "",
71 .symlink_target = "",
72 .symlink_missing = "",
73 };
74
75 const default: Colors = .{
76 .reset = _reset,
77 .dir = bold ++ blue,
78 .executable = bold ++ green,
79 .symlink = bold ++ purple,
80 .symlink_target = bold ++ cyan,
81 .symlink_missing = bold ++ red,
82 };
83
84 const _reset = "\x1b[m";
85 const red = "\x1b[31m";
86 const green = "\x1b[32m";
87 const yellow = "\x1b[33m";
88 const blue = "\x1b[34m";
89 const purple = "\x1b[35m";
90 const cyan = "\x1b[36m";
91 const fg = "\x1b[37m";
92
93 const bold = "\x1b[1m";
94 };
95
96 fn useColor(self: Options) bool {
97 switch (self.color) {
98 .never => return false,
99 .always => return true,
100 .auto => return self.isatty(),
101 }
102 }
103
104 fn useIcons(self: Options) bool {
105 switch (self.icons) {
106 .never => return false,
107 .always => return true,
108 .auto => return self.isatty(),
109 }
110 }
111
112 fn useHyperlinks(self: Options) bool {
113 switch (self.hyperlinks) {
114 .never => return false,
115 .always => return true,
116 .auto => return self.isatty(),
117 }
118 }
119
120 fn showDotfiles(self: Options) bool {
121 return self.@"almost-all" or self.all;
122 }
123
124 fn isatty(self: Options) bool {
125 return self.winsize != null;
126 }
127};
128
129pub fn main() !void {
130 var debug_allocator: std.heap.DebugAllocator(.{}) = .init;
131 const gpa, const is_debug = gpa: {
132 break :gpa switch (builtin.mode) {
133 .Debug, .ReleaseSafe => .{ debug_allocator.allocator(), true },
134 .ReleaseFast, .ReleaseSmall => .{ std.heap.smp_allocator, false },
135 };
136 };
137 defer if (is_debug) {
138 _ = debug_allocator.deinit();
139 };
140
141 var arena = std.heap.ArenaAllocator.init(gpa);
142 defer arena.deinit();
143
144 var sfb = std.heap.stackFallback(1 << 20, arena.allocator());
145 const allocator = sfb.get();
146
147 var cmd: Command = .{ .arena = allocator };
148
149 cmd.opts.winsize = getWinsize(std.io.getStdOut().handle);
150
151 cmd.opts.shortview = if (cmd.opts.isatty()) .columns else .oneline;
152
153 const stdout = std.io.getStdOut().writer();
154 const stderr = std.io.getStdErr().writer();
155 var bw = std.io.bufferedWriter(stdout);
156
157 var args = std.process.args();
158 // skip binary
159 _ = args.next();
160 while (args.next()) |arg| {
161 switch (optKind(arg)) {
162 .short => {
163 const str = arg[1..];
164 for (str) |b| {
165 switch (b) {
166 '1' => cmd.opts.shortview = .oneline,
167 'A' => cmd.opts.@"almost-all" = true,
168 'C' => cmd.opts.shortview = .columns,
169 'a' => cmd.opts.all = true,
170 'h' => {}, // human-readable: present for compatibility
171 'l' => cmd.opts.long = true,
172 'r' => cmd.opts.reverse_sort = true,
173 't' => cmd.opts.sort_by_mod_time = true,
174 else => {
175 try stderr.print("Invalid opt: '{c}'", .{b});
176 std.process.exit(1);
177 },
178 }
179 }
180 },
181 .long => {
182 var split = std.mem.splitScalar(u8, arg[2..], '=');
183 const opt = split.first();
184 const val = split.rest();
185 if (eql(opt, "all")) {
186 cmd.opts.all = parseArgBool(val) orelse {
187 try stderr.print("Invalid boolean: '{s}'", .{val});
188 std.process.exit(1);
189 };
190 } else if (eql(opt, "long")) {
191 cmd.opts.long = parseArgBool(val) orelse {
192 try stderr.print("Invalid boolean: '{s}'", .{val});
193 std.process.exit(1);
194 };
195 } else if (eql(opt, "almost-all")) {
196 cmd.opts.@"almost-all" = parseArgBool(val) orelse {
197 try stderr.print("Invalid boolean: '{s}'", .{val});
198 std.process.exit(1);
199 };
200 } else if (eql(opt, "group-directories-first")) {
201 cmd.opts.@"group-directories-first" = parseArgBool(val) orelse {
202 try stderr.print("Invalid boolean: '{s}'", .{val});
203 std.process.exit(1);
204 };
205 } else if (eql(opt, "color")) {
206 cmd.opts.color = std.meta.stringToEnum(Options.When, val) orelse {
207 try stderr.print("Invalid color option: '{s}'", .{val});
208 std.process.exit(1);
209 };
210 } else if (eql(opt, "human-readable")) {
211 // no-op: present for compatibility
212 } else if (eql(opt, "hyperlinks")) {
213 cmd.opts.hyperlinks = std.meta.stringToEnum(Options.When, val) orelse {
214 try stderr.print("Invalid hyperlinks option: '{s}'", .{val});
215 std.process.exit(1);
216 };
217 } else if (eql(opt, "icons")) {
218 cmd.opts.icons = std.meta.stringToEnum(Options.When, val) orelse {
219 try stderr.print("Invalid color option: '{s}'", .{val});
220 std.process.exit(1);
221 };
222 } else if (eql(opt, "columns")) {
223 const c = parseArgBool(val) orelse {
224 try stderr.print("Invalid columns option: '{s}'", .{val});
225 std.process.exit(1);
226 };
227 cmd.opts.shortview = if (c) .columns else .oneline;
228 } else if (eql(opt, "oneline")) {
229 const o = parseArgBool(val) orelse {
230 try stderr.print("Invalid oneline option: '{s}'", .{val});
231 std.process.exit(1);
232 };
233 cmd.opts.shortview = if (o) .oneline else .columns;
234 } else if (eql(opt, "time")) {
235 cmd.opts.sort_by_mod_time = parseArgBool(val) orelse {
236 try stderr.print("Invalid boolean: '{s}'", .{val});
237 std.process.exit(1);
238 };
239 } else if (eql(opt, "reverse")) {
240 cmd.opts.reverse_sort = parseArgBool(val) orelse {
241 try stderr.print("Invalid boolean: '{s}'", .{val});
242 std.process.exit(1);
243 };
244 } else if (eql(opt, "help")) {
245 return stderr.writeAll(usage);
246 } else if (eql(opt, "version")) {
247 try bw.writer().print("lsr {s}\r\n", .{build_options.version});
248 try bw.flush();
249 return;
250 } else {
251 try stderr.print("Invalid opt: '{s}'", .{opt});
252 std.process.exit(1);
253 }
254 },
255 .positional => {
256 cmd.opts.directory = arg;
257 },
258 }
259 }
260
261 if (cmd.opts.useColor()) {
262 cmd.opts.colors = .default;
263 }
264
265 var ring: ourio.Ring = try .init(allocator, queue_size);
266 defer ring.deinit();
267
268 _ = try ring.open(cmd.opts.directory, .{ .DIRECTORY = true, .CLOEXEC = true }, 0, .{
269 .ptr = &cmd,
270 .cb = onCompletion,
271 .msg = @intFromEnum(Msg.cwd),
272 });
273
274 if (cmd.opts.long) {
275 _ = try ring.open("/etc/localtime", .{ .CLOEXEC = true }, 0, .{
276 .ptr = &cmd,
277 .cb = onCompletion,
278 .msg = @intFromEnum(Msg.localtime),
279 });
280 _ = try ring.open("/etc/passwd", .{ .CLOEXEC = true }, 0, .{
281 .ptr = &cmd,
282 .cb = onCompletion,
283 .msg = @intFromEnum(Msg.passwd),
284 });
285 _ = try ring.open("/etc/group", .{ .CLOEXEC = true }, 0, .{
286 .ptr = &cmd,
287 .cb = onCompletion,
288 .msg = @intFromEnum(Msg.group),
289 });
290 }
291
292 try ring.run(.until_done);
293
294 if (cmd.entries.len == 0) return;
295
296 std.sort.pdq(Entry, cmd.entries, cmd.opts, Entry.lessThan);
297
298 if (cmd.opts.reverse_sort) {
299 std.mem.reverse(Entry, cmd.entries);
300 }
301
302 if (cmd.opts.long) {
303 try printLong(cmd, bw.writer());
304 } else switch (cmd.opts.shortview) {
305 .columns => try printShortColumns(cmd, bw.writer()),
306 .oneline => try printShortOnePerLine(cmd, bw.writer()),
307 }
308 try bw.flush();
309}
310
311fn printShortColumns(cmd: Command, writer: anytype) !void {
312 const win_width = blk: {
313 const ws = cmd.opts.winsize orelse break :blk 80;
314 break :blk ws.col;
315 };
316 if (win_width == 0) return printShortOnePerLine(cmd, writer);
317
318 const icon_width: u2 = if (cmd.opts.useIcons()) 2 else 0;
319
320 var n_cols = @min(win_width, cmd.entries.len);
321
322 const Column = struct {
323 width: usize = 0,
324 entries: []const Entry = &.{},
325 };
326
327 var columns: std.ArrayListUnmanaged(Column) = try .initCapacity(cmd.arena, n_cols);
328
329 outer: while (n_cols > 0) {
330 columns.clearRetainingCapacity();
331 const n_rows = std.math.divCeil(usize, cmd.entries.len, n_cols) catch unreachable;
332 const padding = (n_cols - 1) * 2;
333
334 // The number of columns that are short by one entry
335 const short_cols = n_cols * n_rows - cmd.entries.len;
336
337 var idx: usize = 0;
338 var line_width: usize = padding + icon_width * n_cols;
339
340 if (line_width > win_width) {
341 n_cols -= 1;
342 continue :outer;
343 }
344
345 for (0..n_cols) |i| {
346 const col_entries = if (isShortColumn(i, n_cols, short_cols)) n_rows - 1 else n_rows;
347 const entries = cmd.entries[idx .. idx + col_entries];
348 idx += col_entries;
349
350 var max_width: usize = 0;
351 for (entries) |entry| {
352 max_width = @max(max_width, entry.name.len);
353 }
354
355 // line_width already includes all icons and padding
356 line_width += max_width;
357
358 const col_width = max_width + icon_width + 2;
359
360 columns.appendAssumeCapacity(.{
361 .entries = entries,
362 .width = col_width,
363 });
364
365 if (line_width > win_width) {
366 n_cols -= 1;
367 continue :outer;
368 }
369 }
370
371 break :outer;
372 }
373
374 if (n_cols <= 1) return printShortOnePerLine(cmd, writer);
375
376 const n_rows = std.math.divCeil(usize, cmd.entries.len, columns.items.len) catch unreachable;
377 for (0..n_rows) |row| {
378 for (columns.items, 0..) |column, i| {
379 if (row >= column.entries.len) continue;
380 const entry = column.entries[row];
381 try printShortEntry(column.entries[row], cmd, writer);
382
383 if (i < columns.items.len - 1) {
384 const spaces = column.width - (icon_width + entry.name.len);
385 try writer.writeByteNTimes(' ', spaces);
386 }
387 }
388 try writer.writeAll("\r\n");
389 }
390}
391
392fn isShortColumn(idx: usize, n_cols: usize, n_short_cols: usize) bool {
393 return idx + n_short_cols >= n_cols;
394}
395
396fn printShortEntry(entry: Entry, cmd: Command, writer: anytype) !void {
397 const opts = cmd.opts;
398 const colors = opts.colors;
399 if (opts.useIcons()) {
400 const icon = Icon.get(entry, opts);
401
402 if (opts.useColor()) {
403 try writer.writeAll(icon.color);
404 try writer.writeAll(icon.icon);
405 try writer.writeAll(colors.reset);
406 } else {
407 try writer.writeAll(icon.icon);
408 }
409
410 try writer.writeByte(' ');
411 }
412 switch (entry.kind) {
413 .directory => try writer.writeAll(colors.dir),
414 .sym_link => try writer.writeAll(colors.symlink),
415 else => {
416 if (entry.isExecutable()) {
417 try writer.writeAll(colors.executable);
418 }
419 },
420 }
421
422 if (opts.useHyperlinks()) {
423 const path = try std.fs.path.join(cmd.arena, &.{ opts.directory, entry.name });
424 try writer.print("\x1b]8;;file://{s}\x1b\\", .{path});
425 try writer.writeAll(entry.name);
426 try writer.writeAll("\x1b]8;;\x1b\\");
427 try writer.writeAll(colors.reset);
428 } else {
429 try writer.writeAll(entry.name);
430 try writer.writeAll(colors.reset);
431 }
432}
433
434fn printShortOneRow(cmd: Command, writer: anytype) !void {
435 for (cmd.entries) |entry| {
436 try printShortEntry(entry, cmd.opts, writer);
437 try writer.writeAll(" ");
438 }
439 try writer.writeAll("\r\n");
440}
441
442fn printShortOnePerLine(cmd: Command, writer: anytype) !void {
443 for (cmd.entries) |entry| {
444 try printShortEntry(entry, cmd, writer);
445 try writer.writeAll("\r\n");
446 }
447}
448
449fn printLong(cmd: Command, writer: anytype) !void {
450 const tz = cmd.tz.?;
451 const now = zeit.instant(.{}) catch unreachable;
452 const one_year_ago = try now.subtract(.{ .days = 365 });
453 const colors = cmd.opts.colors;
454
455 const longest_group, const longest_user, const longest_size, const longest_suffix = blk: {
456 var n_group: usize = 0;
457 var n_user: usize = 0;
458 var n_size: usize = 0;
459 var n_suff: usize = 0;
460 for (cmd.entries) |entry| {
461 const group = cmd.getGroup(entry.statx.gid);
462 const user = cmd.getUser(entry.statx.uid);
463
464 var buf: [16]u8 = undefined;
465 const size = try entry.humanReadableSize(&buf);
466 const group_len: usize = if (group) |g| g.name.len else switch (entry.statx.gid) {
467 0...9 => 1,
468 10...99 => 2,
469 100...999 => 3,
470 1000...9999 => 4,
471 10000...99999 => 5,
472 else => 6,
473 };
474
475 const user_len: usize = if (user) |u| u.name.len else switch (entry.statx.uid) {
476 0...9 => 1,
477 10...99 => 2,
478 100...999 => 3,
479 1000...9999 => 4,
480 10000...99999 => 5,
481 else => 6,
482 };
483
484 n_group = @max(n_group, group_len);
485 n_user = @max(n_user, user_len);
486 n_size = @max(n_size, size.len);
487 n_suff = @max(n_suff, entry.humanReadableSuffix().len);
488 }
489 break :blk .{ n_group, n_user, n_size, n_suff };
490 };
491
492 for (cmd.entries) |entry| {
493 const user: User = cmd.getUser(entry.statx.uid) orelse
494 .{
495 .uid = entry.statx.uid,
496 .name = try std.fmt.allocPrint(cmd.arena, "{d}", .{entry.statx.uid}),
497 };
498 const group: Group = cmd.getGroup(entry.statx.gid) orelse
499 .{
500 .gid = entry.statx.gid,
501 .name = try std.fmt.allocPrint(cmd.arena, "{d}", .{entry.statx.gid}),
502 };
503 const ts = @as(i128, entry.statx.mtime.sec) * std.time.ns_per_s;
504 const inst: zeit.Instant = .{ .timestamp = ts, .timezone = &tz };
505 const time = inst.time();
506
507 const mode = entry.modeStr();
508
509 try writer.writeAll(&mode);
510 try writer.writeByte(' ');
511 try writer.writeAll(user.name);
512 try writer.writeByteNTimes(' ', longest_user - user.name.len);
513 try writer.writeByte(' ');
514 try writer.writeAll(group.name);
515 try writer.writeByteNTimes(' ', longest_group - group.name.len);
516 try writer.writeByte(' ');
517
518 var size_buf: [16]u8 = undefined;
519 const size = try entry.humanReadableSize(&size_buf);
520 const suffix = entry.humanReadableSuffix();
521
522 try writer.writeByteNTimes(' ', longest_size - size.len);
523 try writer.writeAll(size);
524 try writer.writeByte(' ');
525 try writer.writeAll(suffix);
526 try writer.writeByteNTimes(' ', longest_suffix - suffix.len);
527 try writer.writeByte(' ');
528
529 try writer.print("{d: >2} {s} ", .{
530 time.day,
531 time.month.shortName(),
532 });
533
534 if (ts > one_year_ago.timestamp) {
535 try writer.print("{d: >2}:{d:0>2} ", .{ time.hour, time.minute });
536 } else {
537 try writer.print("{d: >5} ", .{@as(u32, @intCast(time.year))});
538 }
539
540 if (cmd.opts.useIcons()) {
541 const icon = Icon.get(entry, cmd.opts);
542
543 if (cmd.opts.useColor()) {
544 try writer.writeAll(icon.color);
545 try writer.writeAll(icon.icon);
546 try writer.writeAll(colors.reset);
547 } else {
548 try writer.writeAll(icon.icon);
549 }
550
551 try writer.writeByte(' ');
552 }
553
554 switch (entry.kind) {
555 .directory => try writer.writeAll(colors.dir),
556 .sym_link => try writer.writeAll(colors.symlink),
557 else => {
558 if (entry.isExecutable()) {
559 try writer.writeAll(colors.executable);
560 }
561 },
562 }
563
564 if (cmd.opts.useHyperlinks()) {
565 const path = try std.fs.path.join(cmd.arena, &.{ cmd.opts.directory, entry.name });
566 try writer.print("\x1b]8;;file://{s}\x1b\\", .{path});
567 try writer.writeAll(entry.name);
568 try writer.writeAll("\x1b]8;;\x1b\\");
569 } else {
570 try writer.writeAll(entry.name);
571 }
572 try writer.writeAll(colors.reset);
573
574 switch (entry.kind) {
575 .sym_link => {
576 try writer.writeAll(" -> ");
577
578 const symlink: Symlink = cmd.symlinks.get(entry.name) orelse .{
579 .name = "[missing]",
580 .exists = false,
581 };
582
583 const color = if (symlink.exists) colors.symlink_target else colors.symlink_missing;
584
585 try writer.writeAll(color);
586 if (cmd.opts.useHyperlinks() and symlink.exists) {
587 try writer.print("\x1b]8;;file://{s}\x1b\\", .{symlink.name});
588 try writer.writeAll(symlink.name);
589 try writer.writeAll("\x1b]8;;\x1b\\");
590 } else {
591 try writer.writeAll(symlink.name);
592 }
593 try writer.writeAll(colors.reset);
594 },
595
596 else => {},
597 }
598
599 try writer.writeAll("\r\n");
600 }
601}
602
603const Command = struct {
604 arena: std.mem.Allocator,
605 opts: Options = .{},
606 entries: []Entry = &.{},
607 entry_idx: usize = 0,
608 symlinks: std.StringHashMapUnmanaged(Symlink) = .empty,
609
610 tz: ?zeit.TimeZone = null,
611 groups: std.ArrayListUnmanaged(Group) = .empty,
612 users: std.ArrayListUnmanaged(User) = .empty,
613
614 fn getUser(self: Command, uid: posix.uid_t) ?User {
615 for (self.users.items) |user| {
616 if (user.uid == uid) return user;
617 }
618 return null;
619 }
620
621 fn getGroup(self: Command, gid: posix.gid_t) ?Group {
622 for (self.groups.items) |group| {
623 if (group.gid == gid) return group;
624 }
625 return null;
626 }
627};
628
629const Msg = enum(u16) {
630 cwd,
631 localtime,
632 passwd,
633 group,
634 stat,
635
636 read_localtime,
637 read_passwd,
638 read_group,
639};
640
641const User = struct {
642 uid: if (builtin.os.tag == .macos) i33 else posix.uid_t,
643 name: []const u8,
644
645 fn lessThan(_: void, lhs: User, rhs: User) bool {
646 return lhs.uid < rhs.uid;
647 }
648};
649
650const Group = struct {
651 gid: if (builtin.os.tag == .macos) i33 else posix.gid_t,
652 name: []const u8,
653
654 fn lessThan(_: void, lhs: Group, rhs: Group) bool {
655 return lhs.gid < rhs.gid;
656 }
657};
658
659const MinimalEntry = struct {
660 name: [:0]const u8,
661 kind: std.fs.File.Kind,
662
663 fn lessThan(opts: Options, lhs: MinimalEntry, rhs: MinimalEntry) bool {
664 if (opts.@"group-directories-first" and
665 lhs.kind != rhs.kind and
666 (lhs.kind == .directory or rhs.kind == .directory))
667 {
668 return lhs.kind == .directory;
669 }
670
671 return std.ascii.lessThanIgnoreCase(lhs.name, rhs.name);
672 }
673};
674
675const Symlink = struct {
676 name: [:0]const u8,
677 exists: bool = true,
678};
679
680const Entry = struct {
681 name: [:0]const u8,
682 kind: std.fs.File.Kind,
683 statx: ourio.Statx,
684
685 fn lessThan(opts: Options, lhs: Entry, rhs: Entry) bool {
686 if (opts.@"group-directories-first" and
687 lhs.kind != rhs.kind and
688 (lhs.kind == .directory or rhs.kind == .directory))
689 {
690 return lhs.kind == .directory;
691 }
692
693 if (opts.sort_by_mod_time) {
694 if (lhs.statx.mtime.sec == rhs.statx.mtime.sec) {
695 return lhs.statx.mtime.nsec > rhs.statx.mtime.nsec;
696 }
697 return lhs.statx.mtime.sec > rhs.statx.mtime.sec;
698 }
699
700 return natord.orderIgnoreCase(lhs.name, rhs.name) == .lt;
701 }
702
703 fn modeStr(self: Entry) [10]u8 {
704 var mode = [_]u8{'-'} ** 10;
705 switch (self.kind) {
706 .block_device => mode[0] = 'b',
707 .character_device => mode[0] = 'c',
708 .directory => mode[0] = 'd',
709 .named_pipe => mode[0] = 'p',
710 .sym_link => mode[0] = 'l',
711 else => {},
712 }
713
714 if (self.statx.mode & posix.S.IRUSR != 0) mode[1] = 'r';
715 if (self.statx.mode & posix.S.IWUSR != 0) mode[2] = 'w';
716 if (self.statx.mode & posix.S.IXUSR != 0) mode[3] = 'x';
717
718 if (self.statx.mode & posix.S.IRGRP != 0) mode[4] = 'r';
719 if (self.statx.mode & posix.S.IWGRP != 0) mode[5] = 'w';
720 if (self.statx.mode & posix.S.IXGRP != 0) mode[6] = 'x';
721
722 if (self.statx.mode & posix.S.IROTH != 0) mode[7] = 'r';
723 if (self.statx.mode & posix.S.IWOTH != 0) mode[8] = 'w';
724 if (self.statx.mode & posix.S.IXOTH != 0) mode[9] = 'x';
725 return mode;
726 }
727
728 fn humanReadableSuffix(self: Entry) []const u8 {
729 if (self.kind == .directory) return "-";
730
731 const buckets = [_]u64{
732 1 << 40, // TB
733 1 << 30, // GB
734 1 << 20, // MB
735 1 << 10, // KB
736 };
737
738 const suffixes = [_][]const u8{ "TB", "GB", "MB", "KB" };
739
740 for (buckets, suffixes) |bucket, suffix| {
741 if (self.statx.size >= bucket) {
742 return suffix;
743 }
744 }
745 return "B";
746 }
747
748 fn humanReadableSize(self: Entry, out: []u8) ![]u8 {
749 if (self.kind == .directory) return &.{};
750
751 const buckets = [_]u64{
752 1 << 40, // TB
753 1 << 30, // GB
754 1 << 20, // MB
755 1 << 10, // KB
756 };
757
758 for (buckets) |bucket| {
759 if (self.statx.size >= bucket) {
760 const size_f: f64 = @floatFromInt(self.statx.size);
761 const bucket_f: f64 = @floatFromInt(bucket);
762 const val = size_f / bucket_f;
763 return std.fmt.bufPrint(out, "{d:0.1}", .{val});
764 }
765 }
766 return std.fmt.bufPrint(out, "{d}", .{self.statx.size});
767 }
768
769 fn isExecutable(self: Entry) bool {
770 return self.statx.mode & (posix.S.IXUSR | posix.S.IXGRP | posix.S.IXOTH) != 0;
771 }
772};
773
774fn onCompletion(io: *ourio.Ring, task: ourio.Task) anyerror!void {
775 const cmd = task.userdataCast(Command);
776 const msg = task.msgToEnum(Msg);
777 const result = task.result.?;
778
779 switch (msg) {
780 .cwd => {
781 const fd = result.open catch |err| {
782 switch (err) {
783 error.NotDir => {
784 // Guard against infinite recursion
785 if (cmd.opts.file != null) return err;
786
787 // if the user specified a file (or something that couldn't be opened as a
788 // directory), then we open it's parent and apply a filter
789 const dirname = std.fs.path.dirname(cmd.opts.directory) orelse ".";
790 cmd.opts.file = std.fs.path.basename(cmd.opts.directory);
791 cmd.opts.directory = try cmd.arena.dupeZ(u8, dirname);
792 _ = try io.open(
793 cmd.opts.directory,
794 .{ .DIRECTORY = true, .CLOEXEC = true },
795 0,
796 .{
797 .ptr = cmd,
798 .cb = onCompletion,
799 .msg = @intFromEnum(Msg.cwd),
800 },
801 );
802 return;
803 },
804 else => return err,
805 }
806 };
807 // we are async, no need to defer!
808 _ = try io.close(fd, .{});
809 const dir: std.fs.Dir = .{ .fd = fd };
810
811 if (cmd.opts.useHyperlinks()) {
812 var buf: [std.fs.max_path_bytes]u8 = undefined;
813 const cwd = try std.os.getFdPath(fd, &buf);
814 cmd.opts.directory = try cmd.arena.dupeZ(u8, cwd);
815 }
816
817 var temp_results: std.ArrayListUnmanaged(MinimalEntry) = .empty;
818
819 // Preallocate some memory
820 try temp_results.ensureUnusedCapacity(cmd.arena, queue_size);
821
822 // zig skips "." and "..", so we manually add them if needed
823 if (cmd.opts.all) {
824 temp_results.appendAssumeCapacity(.{
825 .name = ".",
826 .kind = .directory,
827 });
828 temp_results.appendAssumeCapacity(.{
829 .name = "..",
830 .kind = .directory,
831 });
832 }
833
834 var iter = dir.iterate();
835 while (try iter.next()) |dirent| {
836 if (!cmd.opts.showDotfiles() and std.mem.startsWith(u8, dirent.name, ".")) continue;
837 if (cmd.opts.file) |file| {
838 if (eql(file, dirent.name)) {
839 const nameZ = try cmd.arena.dupeZ(u8, dirent.name);
840 try temp_results.append(cmd.arena, .{
841 .name = nameZ,
842 .kind = dirent.kind,
843 });
844 }
845 continue;
846 }
847 const nameZ = try cmd.arena.dupeZ(u8, dirent.name);
848 try temp_results.append(cmd.arena, .{
849 .name = nameZ,
850 .kind = dirent.kind,
851 });
852 }
853
854 // sort the entries on the minimal struct. This has better memory locality since it is
855 // much smaller than bringing in the ourio.Statx struct
856 std.sort.pdq(MinimalEntry, temp_results.items, cmd.opts, MinimalEntry.lessThan);
857
858 var results: std.ArrayListUnmanaged(Entry) = .empty;
859 try results.ensureUnusedCapacity(cmd.arena, temp_results.items.len);
860 for (temp_results.items) |tmp| {
861 results.appendAssumeCapacity(.{
862 .name = tmp.name,
863 .kind = tmp.kind,
864 .statx = undefined,
865 });
866 }
867 cmd.entries = results.items;
868
869 for (cmd.entries, 0..) |*entry, i| {
870 if (i >= queue_size) {
871 cmd.entry_idx = i;
872 break;
873 }
874 const path = try std.fs.path.joinZ(
875 cmd.arena,
876 &.{ cmd.opts.directory, entry.name },
877 );
878
879 if (entry.kind == .sym_link) {
880 var buf: [std.fs.max_path_bytes]u8 = undefined;
881
882 // NOTE: Sadly, we can't do readlink via io_uring
883 const link = try posix.readlink(path, &buf);
884 const symlink: Symlink = .{ .name = try cmd.arena.dupeZ(u8, link) };
885 try cmd.symlinks.put(cmd.arena, entry.name, symlink);
886 }
887 _ = try io.stat(path, &entry.statx, .{
888 .cb = onCompletion,
889 .ptr = cmd,
890 .msg = @intFromEnum(Msg.stat),
891 });
892 }
893 },
894
895 .localtime => {
896 const fd = try result.open;
897
898 // Largest TZ file on my system is Asia/Hebron at 4791 bytes. We allocate an amount
899 // sufficiently more than that to make sure we do this in a single pass
900 const buffer = try cmd.arena.alloc(u8, 8192);
901 _ = try io.read(fd, buffer, .file, .{
902 .cb = onCompletion,
903 .ptr = cmd,
904 .msg = @intFromEnum(Msg.read_localtime),
905 });
906 },
907
908 .read_localtime => {
909 const n = try result.read;
910 _ = try io.close(task.req.read.fd, .{});
911 const bytes = task.req.read.buffer[0..n];
912 var fbs = std.io.fixedBufferStream(bytes);
913 const tz = try zeit.timezone.TZInfo.parse(cmd.arena, fbs.reader());
914 cmd.tz = .{ .tzinfo = tz };
915 },
916
917 .passwd => {
918 const fd = try result.open;
919
920 // TODO: stat this or do multiple reads. We'll never know a good bound unless we go
921 // really big
922 const buffer = try cmd.arena.alloc(u8, 8192 * 2);
923 _ = try io.read(fd, buffer, .file, .{
924 .cb = onCompletion,
925 .ptr = cmd,
926 .msg = @intFromEnum(Msg.read_passwd),
927 });
928 },
929
930 .read_passwd => {
931 const n = try result.read;
932 _ = try io.close(task.req.read.fd, .{});
933 const bytes = task.req.read.buffer[0..n];
934
935 var lines = std.mem.splitScalar(u8, bytes, '\n');
936
937 var line_count: usize = 0;
938 while (lines.next()) |_| {
939 line_count += 1;
940 }
941 try cmd.users.ensureUnusedCapacity(cmd.arena, line_count);
942 lines.reset();
943 // <name>:<throwaway>:<uid><...garbage>
944 while (lines.next()) |line| {
945 if (line.len == 0) continue;
946 if (std.mem.startsWith(u8, line, "#")) continue;
947
948 var iter = std.mem.splitScalar(u8, line, ':');
949 const name = iter.first();
950 _ = iter.next();
951 const uid = iter.next().?;
952
953 const user: User = .{
954 .name = name,
955 .uid = try std.fmt.parseInt(
956 if (builtin.os.tag == .macos) i33 else u32,
957 uid,
958 10,
959 ),
960 };
961
962 cmd.users.appendAssumeCapacity(user);
963 }
964 std.sort.pdq(User, cmd.users.items, {}, User.lessThan);
965 },
966
967 .group => {
968 const fd = try result.open;
969
970 const buffer = try cmd.arena.alloc(u8, 8192);
971 _ = try io.read(fd, buffer, .file, .{
972 .cb = onCompletion,
973 .ptr = cmd,
974 .msg = @intFromEnum(Msg.read_group),
975 });
976 },
977
978 .read_group => {
979 const n = try result.read;
980 _ = try io.close(task.req.read.fd, .{});
981 const bytes = task.req.read.buffer[0..n];
982
983 var lines = std.mem.splitScalar(u8, bytes, '\n');
984
985 var line_count: usize = 0;
986 while (lines.next()) |_| {
987 line_count += 1;
988 }
989 try cmd.groups.ensureUnusedCapacity(cmd.arena, line_count);
990 lines.reset();
991 // <name>:<throwaway>:<uid><...garbage>
992 while (lines.next()) |line| {
993 if (line.len == 0) continue;
994 if (std.mem.startsWith(u8, line, "#")) continue;
995
996 var iter = std.mem.splitScalar(u8, line, ':');
997 const name = iter.first();
998 _ = iter.next();
999 const gid = iter.next().?;
1000
1001 const group: Group = .{
1002 .name = name,
1003 .gid = try std.fmt.parseInt(
1004 if (builtin.os.tag == .macos) i33 else u32,
1005 gid,
1006 10,
1007 ),
1008 };
1009
1010 cmd.groups.appendAssumeCapacity(group);
1011 }
1012 std.sort.pdq(Group, cmd.groups.items, {}, Group.lessThan);
1013 },
1014
1015 .stat => {
1016 _ = result.statx catch |err| {
1017 const entry: *Entry = @fieldParentPtr("statx", task.req.statx.result);
1018 const symlink = cmd.symlinks.getPtr(entry.name) orelse return err;
1019
1020 if (!symlink.exists) {
1021 // We already lstated this and found an error. Just zero out statx and move
1022 // along
1023 entry.statx = std.mem.zeroInit(ourio.Statx, entry.statx);
1024 return;
1025 }
1026
1027 symlink.exists = false;
1028
1029 _ = try io.lstat(task.req.statx.path, task.req.statx.result, .{
1030 .cb = onCompletion,
1031 .ptr = cmd,
1032 .msg = @intFromEnum(Msg.stat),
1033 });
1034 return;
1035 };
1036
1037 if (cmd.entry_idx >= cmd.entries.len) return;
1038
1039 const entry = &cmd.entries[cmd.entry_idx];
1040 cmd.entry_idx += 1;
1041 const path = try std.fs.path.joinZ(
1042 cmd.arena,
1043 &.{ cmd.opts.directory, entry.name },
1044 );
1045
1046 if (entry.kind == .sym_link) {
1047 var buf: [std.fs.max_path_bytes]u8 = undefined;
1048
1049 // NOTE: Sadly, we can't do readlink via io_uring
1050 const link = try posix.readlink(path, &buf);
1051 const symlink: Symlink = .{ .name = try cmd.arena.dupeZ(u8, link) };
1052 try cmd.symlinks.put(cmd.arena, entry.name, symlink);
1053 }
1054 _ = try io.stat(path, &entry.statx, .{
1055 .cb = onCompletion,
1056 .ptr = cmd,
1057 .msg = @intFromEnum(Msg.stat),
1058 });
1059 },
1060 }
1061}
1062
1063const Icon = struct {
1064 icon: []const u8,
1065 color: []const u8,
1066
1067 // Entry types
1068 const directory: Icon = .{ .icon = "", .color = Options.Colors.blue };
1069 const drive: Icon = .{ .icon = "", .color = Options.Colors.blue };
1070 const file: Icon = .{ .icon = "", .color = Options.Colors.fg };
1071 const file_hidden: Icon = .{ .icon = "", .color = Options.Colors.fg };
1072 const pipe: Icon = .{ .icon = "", .color = Options.Colors.fg };
1073 const socket: Icon = .{ .icon = "", .color = Options.Colors.fg };
1074 const symlink: Icon = .{ .icon = "", .color = Options.Colors.fg };
1075 const symlink_dir: Icon = .{ .icon = "", .color = Options.Colors.blue };
1076
1077 // Broad file types
1078 const executable: Icon = .{ .icon = "", .color = Options.Colors.green };
1079 const image: Icon = .{ .icon = "", .color = Options.Colors.yellow };
1080 const video: Icon = .{ .icon = "", .color = Options.Colors.yellow };
1081
1082 // Filetypes
1083 const css: Icon = .{ .icon = "", .color = "\x1b[38:2:50:167:220m" };
1084 const go: Icon = .{ .icon = "", .color = Options.Colors.blue };
1085 const html: Icon = .{ .icon = "", .color = "\x1b[38:2:229:76:33m" };
1086 const javascript: Icon = .{ .icon = "", .color = "\x1b[38:2:233:212:77m" };
1087 const json: Icon = .{ .icon = "", .color = Options.Colors.blue };
1088 const lua: Icon = .{ .icon = "", .color = Options.Colors.blue };
1089 const markdown: Icon = .{ .icon = "", .color = "" };
1090 const nix: Icon = .{ .icon = "", .color = "\x1b[38:2:127:185:228m" };
1091 const python: Icon = .{ .icon = "", .color = Options.Colors.yellow };
1092 const rust: Icon = .{ .icon = "", .color = "" };
1093 const typescript: Icon = .{ .icon = "", .color = Options.Colors.blue };
1094 const zig: Icon = .{ .icon = "", .color = "\x1b[38:2:247:164:29m" };
1095
1096 const by_name: std.StaticStringMap(Icon) = .initComptime(.{
1097 .{ "flake.lock", Icon.nix },
1098 .{ "go.mod", Icon.go },
1099 .{ "go.sum", Icon.go },
1100 });
1101
1102 const by_extension: std.StaticStringMap(Icon) = .initComptime(.{
1103 .{ "cjs", Icon.javascript },
1104 .{ "css", Icon.css },
1105 .{ "drv", Icon.nix },
1106 .{ "gif", Icon.image },
1107 .{ "go", Icon.go },
1108 .{ "html", Icon.html },
1109 .{ "jpeg", Icon.image },
1110 .{ "jpg", Icon.image },
1111 .{ "js", Icon.javascript },
1112 .{ "jsx", Icon.javascript },
1113 .{ "json", Icon.json },
1114 .{ "lua", Icon.lua },
1115 .{ "md", Icon.markdown },
1116 .{ "mjs", Icon.javascript },
1117 .{ "mkv", Icon.video },
1118 .{ "mp4", Icon.video },
1119 .{ "nar", Icon.nix },
1120 .{ "nix", Icon.nix },
1121 .{ "png", Icon.image },
1122 .{ "py", Icon.python },
1123 .{ "rs", Icon.rust },
1124 .{ "ts", Icon.typescript },
1125 .{ "tsx", Icon.typescript },
1126 .{ "webp", Icon.image },
1127 .{ "zig", Icon.zig },
1128 .{ "zon", Icon.zig },
1129 });
1130
1131 fn get(entry: Entry, opts: Options) Icon {
1132 // 1. By name
1133 // 2. By extension
1134 // 3. By type
1135 if (by_name.get(entry.name)) |icon| return icon;
1136
1137 const ext = std.fs.path.extension(entry.name);
1138 if (ext.len > 0) {
1139 const ft = ext[1..];
1140 if (by_extension.get(ft)) |icon| return icon;
1141 }
1142
1143 switch (entry.kind) {
1144 .block_device => return drive,
1145 .character_device => return drive,
1146 .directory => return directory,
1147 .file => {
1148 if (entry.isExecutable()) {
1149 return executable;
1150 }
1151 return file;
1152 },
1153 .named_pipe => return pipe,
1154 .sym_link => {
1155 if (opts.long and posix.S.ISDIR(entry.statx.mode)) {
1156 return symlink_dir;
1157 }
1158 return symlink;
1159 },
1160 .unix_domain_socket => return pipe,
1161 else => return file,
1162 }
1163 }
1164};
1165
1166fn eql(a: []const u8, b: []const u8) bool {
1167 return std.mem.eql(u8, a, b);
1168}
1169
1170fn parseArgBool(arg: []const u8) ?bool {
1171 if (arg.len == 0) return true;
1172
1173 if (std.ascii.eqlIgnoreCase(arg, "true")) return true;
1174 if (std.ascii.eqlIgnoreCase(arg, "false")) return false;
1175 if (std.ascii.eqlIgnoreCase(arg, "1")) return true;
1176 if (std.ascii.eqlIgnoreCase(arg, "0")) return false;
1177
1178 return null;
1179}
1180
1181/// getWinsize gets the window size of the output. Returns null if output is not a terminal
1182fn getWinsize(fd: posix.fd_t) ?posix.winsize {
1183 var winsize: posix.winsize = .{
1184 .row = 0,
1185 .col = 0,
1186 .xpixel = 0,
1187 .ypixel = 0,
1188 };
1189
1190 const err = posix.system.ioctl(fd, posix.T.IOCGWINSZ, @intFromPtr(&winsize));
1191 switch (posix.errno(err)) {
1192 .SUCCESS => return winsize,
1193 else => return null,
1194 }
1195}
1196
1197fn optKind(a: []const u8) enum { short, long, positional } {
1198 if (std.mem.startsWith(u8, a, "--")) return .long;
1199 if (std.mem.startsWith(u8, a, "-")) return .short;
1200 return .positional;
1201}
1202
1203test "ref" {
1204 _ = natord;
1205}