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