Skip to content

ls: bring the listing, the long format and the option parser up to GNU - #201

Merged
medvednikov merged 3 commits into
vlang:mainfrom
metif12:fix/ls
Oct 3, 2026
Merged

medvednikov merged 3 commits into
vlang:mainfrom
metif12:fix/ls

Conversation

@metif12

@metif12 metif12 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Supersedes #183, whose author handed it on.

Takes over #183 and moves it onto current V so that it builds at all. The
option parser used v.mathutil, which no longer exists, six helpers each
shadowed max with a local and then called max, and a C.readlink that
vlib/os now declares with a different signature. On top of that, the
behaviour was compared against GNU ls 9.4 on the same machine over a matrix
of 49 invocations. It started at 0 of 49 and is now 39 of them.

Every number below was read off the reference. Where the first reading
turned out to be wrong, the measurement is named rather than the correction.

Things that were simply wrong

  • The type character came from entry.exe, which means "has the execute bit",
    so a 755 file was listed as xrwxr-xr-x. GNU has no x type at all.
  • A symlink's size was the size of its target, so a link to /nonexistent
    showed 0. It is the link's own size, which is the length of the target
    string: 12 for /nonexistent, 9 for plain.txt.
  • Every column was padded to the widest entry, so every line ended in spaces.
    GNU pads all but the last column of a row and nothing else.
  • Columns were laid out even with the output redirected. ls | cat gives one
    entry per line at GNU; only a terminal, or -C, gets a grid.
  • An empty directory lost its section header entirely, because it produces no
    entries to group by. ls emptydir anotherdir printed only the other one.
  • os.join_path rewrites a backslash in a name into a separator, so
    ./back\slash became ./back/slash and the entry failed to stat, printing
    as ?---------. Joining on / directly is what fixed it.
  • An operand that could not be reached was listed anyway and the exit status
    stayed 0. GNU prints cannot access and exits 2.

The long format

total is the sum of the disk blocks in 1K units, not of the file lengths: a
200000 byte file on a 4K filesystem contributes 196, not 195. It has no colon.
V's os.Stat has no st_blocks, so the field is read from a C.stat declared
alongside the one vlib declares — the field lists have to match exactly or
vlib's own call to C.lstat stops type checking.

-h and --si were the same calculation with the base and the suffix swapped:
-h divides by 1024 and writes K, --si divides by 1000 and writes k.
The rounding is a ceiling, not to nearest. GNU prints 2500 bytes as 2.5K where
nearest would give 2.4K, and 10240 as 10K while 10241 is 11K; one decimal is
kept below ten and dropped at or above. All 33 sizes sampled from the reference
fit that, and no nearest-rounding rule does.

The day is padded with a space, Oct 3, because GNU uses %e; V's
custom_format has no token for that (_2 is passed through literally, and
2 gives an unpadded day). Anything older than six calendar months shows the
year instead of the time of day. That boundary is calendar months, not a fixed
count of days: with now = 2026-10-03 the reference showed a time for 2026-04-04
and a year for 2026-04-01.

Quoting

--quoting-style was not implemented and exited 2. Each style has its own rule
and all of them were measured:

  • literal never quotes. There is no none; GNU 9.4 rejects it and lists the
    ten words it does accept.
  • shell quotes a name only when it holds something outside the set a shell
    treats literally, in single quotes, or in double quotes when the name itself
    contains a single quote. A newline stays inside the quotes: 'new<newline> line', with the quotes still open on the far side.
  • shell-escape closes the run at a byte that is not printable ASCII and
    writes $'\n', $'\t' or a three digit octal escape, so a name with a tab
    is 'tab'$'\t''name' and ü is ''$'\303\274''n'.
  • c is always double quoted with C escapes. -Q is this, not shell quoting:
    ls -Q prints "new\nline", which is worth knowing.

The default follows the destination, shell-escape to a terminal and literal to a
pipe. In the long listing the link target is quoted as well as the name.

Added

-F and -d were absent, and both are ordinary rather than exotic. -F marks
directories, links, sockets, fifos and devices, executables and unknown types,
and leaves an ordinary file alone; in the long listing it marks everything
except a link, which already has -> target after it. -d lists the operand
rather than its contents, so a bare ls -d prints ..

An earlier comment on this PR said GNU sorts names case-insensitively, and the
default arm did compare byte-wise. Measuring it settled the question the other
way: with a directory holding Apple BANANA ZZZ _under aPPle apple banana, GNU
prints them in that order under LC_ALL=C, C.UTF-8 and en_US.UTF-8 alike,
which is byte order — folding case would have put _under second. The report
appears to have come from a case-insensitive filesystem, where Apple and
apple cannot both exist.

The closure in sort is now bound through a typed variable. V 0.5.2 does not
give the arms of a match of closures a type it will pass to
sorted_with_compare: it looks for a function named after the variable instead
and reports Uint128.cmp, which has nothing to do with this module.

Testing

49 invocations over one directory holding a directory, an empty directory, a
dangling and a working symlink, an executable, a 3000 and a 200000 byte file,
names with a space, a single quote, a dollar, a backslash, a semicolon, an
asterisk, a tab and an embedded newline, each with a distinct mtime so that
-t compares times rather than nanosecond ties. Both binaries run in the same
directory with the same arguments.

39 match. The ten that do not, and why:

  • --time-style=iso, =long-iso and =full-iso are not implemented. This is
    a gap, not a difference of opinion; the port has its own time_iso and
    compact formats but never read the option. octal is in this group only
    because the case was written with --time-style in it.
  • -C, -x and -w size every column to the widest name in the whole
    listing. GNU sizes each column to its own contents, which is how it fits six
    entries to a line where this fits five.
  • -m wraps at the width, but GNU picks where to break from the same column
    calculation as -C, so the two disagree about where the third line ends.
  • -li -l and -AC fail in the parser. A repeated short flag makes
    flag.to_struct panic, and a bundled one is not expanded. That is vlib's,
    not this module's.

entry_test.v asserted the old human-readable sizes, including the b suffix
GNU does not print and 1024-based maths under the --si flag. It now pins the
values read off the reference, including the two places where a nearest
rounding would disagree.

Still the author's extras

The icons, the checksum column, the bordered table format and --table are
left as they were. They are not GNU options and are not compared here.

Stacked on #195 for the build fixes. v fmt -verify . passes over src/ls,
v -o bin/ls ./ls builds, and both src/ls test files pass.

metif12 and others added 3 commits October 2, 2026 09:42
main does not build with V 0.5.2, and `make testfmt` is red too, so every
CI job has been failing. This gets both gates back to green without changing
any intended behaviour.

Compile fixes, one per cause:

- `v.mathutil` is gone; `min`/`max` now live in `math`. Affects basenc, cut
  and tail.
- `for ; cond; post {}` and `for i := n; i; i-- {}` are no longer accepted:
  the condition has to be `bool`. Affects cksum and expand.
- `time.sleep` takes a `time.Duration`, not a number of seconds. sleep now
  converts explicitly and saturates instead of overflowing, so `sleep inf`
  keeps waiting rather than wrapping around to roughly zero.
- `const` cannot hold the result of a function call, so printenv's NUL
  terminator became a function.
- `setup_cp_command`/`setup_mv_command`/`setup_command` (head) were declared
  with `?`, but every failure path in them calls the @[noreturn]
  `common.exit_with_error_message`, so they never return an error and their
  callers had no `err` to look at. They now return plain tuples.
- `C.statvfs` is already declared privately by vlib/builtin/cfns.c.v, so a
  local declaration resolved to that one and failed to compile. stat binds a
  distinct name to the libc call with #define.
- `struct utmpx` was declared twice, in common/readutmp_nix.c.v and again in
  src/users/users.c.v with a different, shorter field list, which V now
  rejects as a redeclaration. Declared once, with the full layout.

stat additionally needed more than a compile fix:

- src/stat/stat_to_vlib.v (now src/stat/modes.v) imported v.scanner and
  v.pref to decode --printf escapes. Building it pulled the whole V compiler
  into the program: `v build` of src/stat never finished, and taking the
  machine down with it. The escapes are now decoded directly.
- `<bits/statx.h>` is a glibc internal header, so it is absent on musl based
  distributions (issue vlang#145). The kernel uapi `<linux/stat.h>` is used instead.
- `statx()` wrote the kernel's 224 byte struct through a `voidptr` into a
  V struct that was 160 bytes, so every call corrupted memory past the end of
  the allocation; stat segfaulted on every invocation. The C struct is now
  declared and used for real. `struct statvfs` has the same problem and its
  V mirror now matches the ABI including the trailing spare.
- `$embed_file` no longer resolves inside a function body, so fstypes.txt is
  embedded at module scope.

wc needed a fix of its own:

- `read_chunk` returned `?FileChunk`, but its caller inspected `err`, which
  does not exist for an option. os.File.read also reports end of file as an
  `os.Eof` error, and reading a zero-length chunk indexed `buffer[-1]`, so
  `wc` on an empty file would have panicked. It now returns `!FileChunk` and
  distinguishes exhaustion from a real read error.
- `\r` was matched both as "ignore" and as whitespace, which V rejects as a
  duplicate case.
- `byte(bool)` is no longer a valid conversion; the single-count check counts
  the selected options instead.

Finally, `v fmt -w .` over the 16 files that `v fmt -verify` rejected.

With this, `make` builds all 76 utilities and `make testfmt` passes.
Takes over vlang#183, whose author handed it on, and moves it onto current V so that
it builds at all. The option parser used `v.mathutil`, which no longer exists,
six helpers each shadowed `max` with a local and then called `max`, and a
`C.readlink` that vlib/os now declares with a different signature. On top of
that, the behaviour was compared against GNU ls 9.4 on the same machine over a
matrix of 49 invocations. It started at 0 of 49 and is now 39 of them.

Every number below was read off the reference. Where the first reading turned
out to be wrong, the measurement is named rather than the correction.

## Things that were simply wrong

* The type character came from `entry.exe`, which means "has the execute bit",
  so a 755 file was listed as `xrwxr-xr-x`. GNU has no `x` type at all.
* A symlink's size was the size of its target, so a link to `/nonexistent`
  showed 0. It is the link's own size, which is the length of the target
  string: 12 for `/nonexistent`, 9 for `plain.txt`.
* Every column was padded to the widest entry, so every line ended in spaces.
  GNU pads all but the last column of a row and nothing else.
* Columns were laid out even with the output redirected. `ls | cat` gives one
  entry per line at GNU; only a terminal, or `-C`, gets a grid.
* An empty directory lost its section header entirely, because it produces no
  entries to group by. `ls emptydir anotherdir` printed only the other one.
* `os.join_path` rewrites a backslash in a name into a separator, so
  `./back\slash` became `./back/slash` and the entry failed to stat, printing
  as `?---------`. Joining on `/` directly is what fixed it.
* An operand that could not be reached was listed anyway and the exit status
  stayed 0. GNU prints `cannot access` and exits 2.

## The long format

`total` is the sum of the disk blocks in 1K units, not of the file lengths: a
200000 byte file on a 4K filesystem contributes 196, not 195. It has no colon.
V's `os.Stat` has no `st_blocks`, so the field is read from a `C.stat` declared
alongside the one vlib declares — the field lists have to match exactly or
vlib's own call to `C.lstat` stops type checking.

`-h` and `--si` were the same calculation with the base and the suffix swapped:
`-h` divides by 1024 and writes `K`, `--si` divides by 1000 and writes `k`.
The rounding is a ceiling, not to nearest. GNU prints 2500 bytes as 2.5K where
nearest would give 2.4K, and 10240 as 10K while 10241 is 11K; one decimal is
kept below ten and dropped at or above. All 33 sizes sampled from the reference
fit that, and no nearest-rounding rule does.

The day is padded with a space, `Oct  3`, because GNU uses `%e`; V's
`custom_format` has no token for that (`_2` is passed through literally, and
`2` gives an unpadded day). Anything older than six calendar months shows the
year instead of the time of day. That boundary is calendar months, not a fixed
count of days: with now = 2026-10-03 the reference showed a time for 2026-04-04
and a year for 2026-04-01.

## Quoting

`--quoting-style` was not implemented and exited 2. Each style has its own rule
and all of them were measured:

* `literal` never quotes. There is no `none`; GNU 9.4 rejects it and lists the
  ten words it does accept.
* `shell` quotes a name only when it holds something outside the set a shell
  treats literally, in single quotes, or in double quotes when the name itself
  contains a single quote. A newline stays inside the quotes: `'new<newline>
  line'`, with the quotes still open on the far side.
* `shell-escape` closes the run at a byte that is not printable ASCII and
  writes `$'\n'`, `$'\t'` or a three digit octal escape, so a name with a tab
  is `'tab'$'\t''name'` and `ü` is `''$'\303\274''n'`.
* `c` is always double quoted with C escapes. `-Q` is this, not shell quoting:
  `ls -Q` prints `"new\nline"`, which is worth knowing.

The default follows the destination, shell-escape to a terminal and literal to a
pipe. In the long listing the link target is quoted as well as the name.

## Added

`-F` and `-d` were absent, and both are ordinary rather than exotic. `-F` marks
directories, links, sockets, fifos and devices, executables and unknown types,
and leaves an ordinary file alone; in the long listing it marks everything
except a link, which already has `-> target` after it. `-d` lists the operand
rather than its contents, so a bare `ls -d` prints `.`.

An earlier comment on this PR said GNU sorts names case-insensitively, and the
default arm did compare byte-wise. Measuring it settled the question the other
way: with a directory holding `Apple BANANA ZZZ _under aPPle apple banana`, GNU
prints them in that order under `LC_ALL=C`, `C.UTF-8` and `en_US.UTF-8` alike,
which is byte order — folding case would have put `_under` second. The report
appears to have come from a case-insensitive filesystem, where `Apple` and
`apple` cannot both exist.

The closure in `sort` is now bound through a typed variable. V 0.5.2 does not
give the arms of a `match` of closures a type it will pass to
`sorted_with_compare`: it looks for a function named after the variable instead
and reports `Uint128.cmp`, which has nothing to do with this module.

## Testing

49 invocations over one directory holding a directory, an empty directory, a
dangling and a working symlink, an executable, a 3000 and a 200000 byte file,
names with a space, a single quote, a dollar, a backslash, a semicolon, an
asterisk, a tab and an embedded newline, each with a distinct mtime so that
`-t` compares times rather than nanosecond ties. Both binaries run in the same
directory with the same arguments.

39 match. The ten that do not, and why:

* `--time-style=iso`, `=long-iso` and `=full-iso` are not implemented. This is
  a gap, not a difference of opinion; the port has its own `time_iso` and
  compact formats but never read the option. `octal` is in this group only
  because the case was written with `--time-style` in it.
* `-C`, `-x` and `-w` size every column to the widest name in the whole
  listing. GNU sizes each column to its own contents, which is how it fits six
  entries to a line where this fits five.
* `-m` wraps at the width, but GNU picks where to break from the same column
  calculation as `-C`, so the two disagree about where the third line ends.
* `-li -l` and `-AC` fail in the parser. A repeated short flag makes
  `flag.to_struct` panic, and a bundled one is not expanded. That is vlib's,
  not this module's.

`entry_test.v` asserted the old human-readable sizes, including the `b` suffix
GNU does not print and 1024-based maths under the `--si` flag. It now pins the
values read off the reference, including the two places where a nearest
rounding would disagree.

## Still the author's extras

The icons, the checksum column, the bordered table format and `--table` are
left as they were. They are not GNU options and are not compared here.

Stacked on vlang#195 for the build fixes. `v fmt -verify .` passes over `src/ls`,
`v -o bin/ls ./ls` builds, and both `src/ls` test files pass.
@medvednikov
medvednikov merged commit 4ac15cf into vlang:main Oct 3, 2026
0 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants