fix(shell): implement long listing format for ls -l builtin (#25991)

## Summary
- Implements the `-l` (long listing) flag functionality for the shell
`ls` builtin
- The flag was being parsed but never used - output was identical to
short format
- Now displays proper long listing format: file type, permissions, hard
link count, UID, GID, size, modification time, and filename

## Test plan
- [x] Added regression test in `test/regression/issue/25831.test.ts`
- [x] Test passes with debug build: `bun bd test
test/regression/issue/25831.test.ts`
- [x] Test fails with system bun (confirming the bug exists):
`USE_SYSTEM_BUN=1 bun test test/regression/issue/25831.test.ts`

Example output with fix:
```
$ bun -e 'import { $ } from "bun"; console.log(await $`ls -l`.text())'
drwxr-xr-x   2  1000  1000     4096 Jan 12 15:30 subdir
-rw-r--r--   1  1000  1000       11 Jan 12 15:30 file.txt
```

Fixes #25831

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
robobun
2026-01-14 13:31:06 -08:00
committed by GitHub
parent 6e6896510a
commit 2b86ab0cd3
3 changed files with 356 additions and 10 deletions

View File

@@ -225,6 +225,9 @@ pub const ShellLsTask = struct {
is_absolute: bool = false,
err: ?Syscall.Error = null,
result_kind: enum { file, dir, idk } = .idk,
/// Cached current time (seconds since epoch) for formatting timestamps.
/// Cached once per task to avoid repeated syscalls.
#now_secs: u64 = 0,
event_loop: jsc.EventLoopHandle,
concurrent_task: jsc.EventLoopTask,
@@ -293,6 +296,11 @@ pub const ShellLsTask = struct {
}
pub fn run(this: *@This()) void {
// Cache current time once per task for timestamp formatting
if (this.opts.long_listing) {
this.#now_secs = @intCast(std.time.timestamp());
}
const fd = switch (ShellSyscall.openat(this.cwd, this.path, bun.O.RDONLY | bun.O.DIRECTORY, 0)) {
.err => |e| {
switch (e.getErrno()) {
@@ -301,7 +309,7 @@ pub const ShellLsTask = struct {
},
.NOTDIR => {
this.result_kind = .file;
this.addEntry(this.path);
this.addEntry(this.path, this.cwd);
},
else => {
this.err = this.errorWithPath(e, this.path);
@@ -329,7 +337,7 @@ pub const ShellLsTask = struct {
// If `-a` is used, "." and ".." should show up as results. However,
// our `DirIterator` abstraction skips them, so let's just add them
// now.
this.addDotEntriesIfNeeded();
this.addDotEntriesIfNeeded(fd);
while (switch (entry) {
.err => |e| {
@@ -338,7 +346,7 @@ pub const ShellLsTask = struct {
},
.result => |ent| ent,
}) |current| : (entry = iterator.next()) {
this.addEntry(current.name.sliceAssumeZ());
this.addEntry(current.name.sliceAssumeZ(), fd);
if (current.kind == .directory and this.opts.recursive) {
this.enqueue(current.name.sliceAssumeZ());
}
@@ -367,20 +375,167 @@ pub const ShellLsTask = struct {
}
// TODO more complex output like multi-column
fn addEntry(this: *@This(), name: [:0]const u8) void {
fn addEntry(this: *@This(), name: [:0]const u8, dir_fd: bun.FileDescriptor) void {
const skip = this.shouldSkipEntry(name);
debug("Entry: (skip={}) {s} :: {s}", .{ skip, this.path, name });
if (skip) return;
bun.handleOom(this.output.ensureUnusedCapacity(name.len + 1));
bun.handleOom(this.output.appendSlice(name));
bun.handleOom(this.output.append('\n'));
if (this.opts.long_listing) {
this.addEntryLong(name, dir_fd);
} else {
bun.handleOom(this.output.ensureUnusedCapacity(name.len + 1));
bun.handleOom(this.output.appendSlice(name));
bun.handleOom(this.output.append('\n'));
}
}
fn addDotEntriesIfNeeded(this: *@This()) void {
fn addEntryLong(this: *@This(), name: [:0]const u8, dir_fd: bun.FileDescriptor) void {
// Use lstatat to not follow symlinks (so symlinks show as 'l' type)
const stat_result = Syscall.lstatat(dir_fd, name);
const stat = switch (stat_result) {
.err => {
// If stat fails, just output the name with placeholders
const writer = this.output.writer();
bun.handleOom(writer.print("?????????? ? ? ? ? ? {s}\n", .{name}));
return;
},
.result => |s| s,
};
const writer = this.output.writer();
// File type and permissions
const mode: u32 = @intCast(stat.mode);
const file_type = getFileTypeChar(mode);
const perms = formatPermissions(mode);
// Number of hard links
const nlink: u64 = @intCast(stat.nlink);
// Owner and group (numeric)
const uid: u64 = @intCast(stat.uid);
const gid: u64 = @intCast(stat.gid);
// File size
const size: i64 = @intCast(stat.size);
// Modification time
const mtime = stat.mtime();
const time_str = formatTime(@intCast(mtime.sec), this.#now_secs);
bun.handleOom(writer.print("{c}{s} {d: >3} {d: >5} {d: >5} {d: >8} {s} {s}\n", .{
file_type,
&perms,
nlink,
uid,
gid,
size,
&time_str,
name,
}));
}
fn getFileTypeChar(mode: u32) u8 {
const file_type = mode & bun.S.IFMT;
return switch (file_type) {
bun.S.IFDIR => 'd',
bun.S.IFLNK => 'l',
bun.S.IFBLK => 'b',
bun.S.IFCHR => 'c',
bun.S.IFIFO => 'p',
bun.S.IFSOCK => 's',
else => '-', // IFREG or unknown
};
}
fn formatPermissions(mode: u32) [9]u8 {
var perms: [9]u8 = undefined;
// Owner permissions
perms[0] = if (mode & bun.S.IRUSR != 0) 'r' else '-';
perms[1] = if (mode & bun.S.IWUSR != 0) 'w' else '-';
// Owner execute with setuid handling
const owner_exec = mode & bun.S.IXUSR != 0;
const setuid = mode & bun.S.ISUID != 0;
perms[2] = if (setuid)
(if (owner_exec) 's' else 'S')
else
(if (owner_exec) 'x' else '-');
// Group permissions
perms[3] = if (mode & bun.S.IRGRP != 0) 'r' else '-';
perms[4] = if (mode & bun.S.IWGRP != 0) 'w' else '-';
// Group execute with setgid handling
const group_exec = mode & bun.S.IXGRP != 0;
const setgid = mode & bun.S.ISGID != 0;
perms[5] = if (setgid)
(if (group_exec) 's' else 'S')
else
(if (group_exec) 'x' else '-');
// Other permissions
perms[6] = if (mode & bun.S.IROTH != 0) 'r' else '-';
perms[7] = if (mode & bun.S.IWOTH != 0) 'w' else '-';
// Other execute with sticky bit handling
const other_exec = mode & bun.S.IXOTH != 0;
const sticky = mode & bun.S.ISVTX != 0;
perms[8] = if (sticky)
(if (other_exec) 't' else 'T')
else
(if (other_exec) 'x' else '-');
return perms;
}
fn formatTime(timestamp: i64, now_secs: u64) [12]u8 {
var buf: [12]u8 = undefined;
// Format as "Mon DD HH:MM" for recent files (within 6 months)
// or "Mon DD YYYY" for older files
const epoch_secs: u64 = if (timestamp < 0) 0 else @intCast(timestamp);
const epoch = std.time.epoch.EpochSeconds{ .secs = epoch_secs };
const day_seconds = epoch.getDaySeconds();
const year_day = epoch.getEpochDay().calculateYearDay();
const month_names = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
const month_day = year_day.calculateMonthDay();
const month_name = month_names[month_day.month.numeric() - 1];
// Check if file is older than 6 months (approximately 180 days)
const six_months_secs: u64 = 180 * 24 * 60 * 60;
const is_recent = epoch_secs > now_secs -| six_months_secs and epoch_secs <= now_secs + six_months_secs;
if (is_recent) {
const hours = day_seconds.getHoursIntoDay();
const minutes = day_seconds.getMinutesIntoHour();
_ = std.fmt.bufPrint(&buf, "{s} {d:0>2} {d:0>2}:{d:0>2}", .{
month_name,
month_day.day_index + 1,
hours,
minutes,
}) catch {
@memcpy(&buf, "??? ?? ??:??");
};
} else {
// Show year for old files
const year = year_day.year;
_ = std.fmt.bufPrint(&buf, "{s} {d:0>2} {d:4}", .{
month_name,
month_day.day_index + 1,
year,
}) catch {
@memcpy(&buf, "??? ?? ????");
};
}
return buf;
}
fn addDotEntriesIfNeeded(this: *@This(), dir_fd: bun.FileDescriptor) void {
// `.addEntry()` already checks will check if we can add "." and ".." to
// the result
this.addEntry(".");
this.addEntry("..");
this.addEntry(".", dir_fd);
this.addEntry("..", dir_fd);
}
fn errorWithPath(this: *@This(), err: Syscall.Error, path: [:0]const u8) Syscall.Error {