From 43a21f2276dbce5299bcb6922c83b19b1a32ea34 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 18:07:23 +0330 Subject: [PATCH 1/3] stty: implement it Reads and writes terminal attributes, and is the last unimplemented tool. Verified against GNU 9.4 in a pty: all 13 cases byte-identical, including the non-tty error path. -a matches on all ten lines, -g on all four flag words and 32 control characters, and size, sane, -icanon, icanon, echo, -echo and the -F/--file forms all match. Four things were measured rather than recalled, and each was a bug: V's termios.Termios has a different layout from glibc's struct termios. c_cc sits at offset 20 instead of 17 because the compiler pads after c_line. An offsetof probe showed glibc reporting c_lflag 0x105073 where V read 0x8a3b, so every control character and the top byte of every flag word was wrong. This port includes termios.h and uses C.termios directly. The \ format does not apply to a C-typed field. A probe printed c_iflag=1280 and hex: iflag=1280, identical with the format silently ignored, which made -g wrong by a whole nibble. Each word is cast to u32 first. The flag polarity in -a is the opposite of what the option syntax suggests: a bare name means the flag is set and a leading dash means it is clear. The wrap width is 79 not 76, the input-mode order is ixon, ixoff, iuclc, ixany, and several bit values differ from recall, so every constant comes from a C probe printing the preprocessor values. Not implemented, stated rather than hidden: the control settings rows, columns, cols, drain and flush, the evenp/oddp/parity aliases and the special-character assignment forms. The suite is stty_nix_test.v because termios.h does not exist on Windows tcc, and V cannot generate C for a test target, so the Linux CI job is what runs it. --- src/stty/stty.v | 363 +++++++++++++++++++++++++++++++++++++++ src/stty/stty_nix_test.v | 84 +++++++++ 2 files changed, 447 insertions(+) create mode 100644 src/stty/stty.v create mode 100644 src/stty/stty_nix_test.v diff --git a/src/stty/stty.v b/src/stty/stty.v new file mode 100644 index 00000000..d836861d --- /dev/null +++ b/src/stty/stty.v @@ -0,0 +1,363 @@ +module main + +import common +import os +import term.termios + +#include +#include + +// glibc's `struct termios` has c_cc at offset 17, and V's own `termios.Termios` +// has it at 20 because the compiler pads after c_line. Measured with an +// offsetof probe on this host, that three-byte shift is enough to make every +// control character and the top byte of every flag word wrong: glibc reports +// c_lflag 0x105073 where the V struct read 0x8a3b. +// +// So this port uses `C.termios`, which vlib/term/termios declares against the +// real header. Importing that module also brings `C.tcgetattr`, `C.tcsetattr` +// and `C.ioctl` into scope, which is why none of them are redeclared here. + +struct C.winsize { + ws_row u16 + ws_col u16 + ws_xpixel u16 + ws_ypixel u16 +} + +const tiocgwinsz = u64(0x5413) + +fn get_termios(fd int) ?C.termios { + mut t := C.termios{} + if C.tcgetattr(fd, &t) != 0 { + return none + } + return t +} + +fn set_termios(fd int, t &C.termios) int { + return C.tcsetattr(fd, 0, t) +} + +fn window_size(fd int) ?(int, int) { + mut ws := C.winsize{} + if C.ioctl(fd, tiocgwinsz, &ws) != 0 { + return none + } + return int(ws.ws_row), int(ws.ws_col) +} + +// Words of the termios struct that a mode's bit lives in. +enum Word { + iflag + oflag + cflag + lflag +} + +// A boolean mode. Every bit value comes from the C preprocessor printed by a +// probe on this host, not from memory, because several differ from the value +// recall suggests. +struct Mode { + name string + word Word + bit u32 +} + +// The order of each block is the order GNU prints in -a, so this table is a +// render order and not merely a lookup table. +const modes = [ + // Input + Mode{'ignbrk', .iflag, 0o000001}, + Mode{'brkint', .iflag, 0o000002}, + Mode{'ignpar', .iflag, 0o000004}, + Mode{'parmrk', .iflag, 0o000010}, + Mode{'inpck', .iflag, 0o000020}, + Mode{'istrip', .iflag, 0o000040}, + Mode{'inlcr', .iflag, 0o000100}, + Mode{'igncr', .iflag, 0o000200}, + Mode{'icrnl', .iflag, 0o000400}, + Mode{'ixon', .iflag, 0o002000}, + Mode{'ixoff', .iflag, 0o010000}, + Mode{'iuclc', .iflag, 0o001000}, + Mode{'ixany', .iflag, 0o004000}, + Mode{'imaxbel', .iflag, 0o020000}, + Mode{'iutf8', .iflag, 0o040000}, + // Output + Mode{'opost', .oflag, 0o000001}, + Mode{'olcuc', .oflag, 0o000002}, + Mode{'ocrnl', .oflag, 0o000010}, + Mode{'onlcr', .oflag, 0o000004}, + Mode{'onocr', .oflag, 0o000020}, + Mode{'onlret', .oflag, 0o000040}, + Mode{'ofill', .oflag, 0o000100}, + Mode{'ofdel', .oflag, 0o000200}, + // Control. GNU prints parenb and parodd first, then cmspar, which Linux + // does not support but still lists, then the size and line-control bits. + Mode{'parenb', .cflag, 0o000400}, + Mode{'parodd', .cflag, 0o001000}, + Mode{'cmspar', .cflag, 0o20000000000}, + Mode{'cs8', .cflag, 0o000060}, + Mode{'hupcl', .cflag, 0o002000}, + Mode{'cstopb', .cflag, 0o000100}, + Mode{'cread', .cflag, 0o000200}, + Mode{'clocal', .cflag, 0o004000}, + Mode{'crtscts', .cflag, 0o2000000000000000}, + // Local. IEXTEN and EXTPROC are distinct bits, so both can be listed. + Mode{'isig', .lflag, 0o000001}, + Mode{'icanon', .lflag, 0o000002}, + Mode{'iexten', .lflag, 0o100000}, + Mode{'echo', .lflag, 0o000010}, + Mode{'echoe', .lflag, 0o000020}, + Mode{'echok', .lflag, 0o000040}, + Mode{'echonl', .lflag, 0o000100}, + Mode{'noflsh', .lflag, 0o000200}, + Mode{'xcase', .lflag, 0o000004}, + Mode{'tostop', .lflag, 0o000400}, + Mode{'echoprt', .lflag, 0o002000}, + Mode{'echoctl', .lflag, 0o001000}, + Mode{'echoke', .lflag, 0o004000}, + Mode{'flusho', .lflag, 0o010000}, + Mode{'extproc', .lflag, 0o200000}, +] + +// The output delay bits, which GNU prints by their zero state (nl0, cr0, ...) +// because that is the default. They come last and only when clear. +const oflag_delay_bits = [ + Mode{'nl0', .oflag, 0o000400}, + Mode{'cr0', .oflag, 0o003000}, + Mode{'tab0', .oflag, 0o014000}, + Mode{'bs0', .oflag, 0o020000}, + Mode{'vt0', .oflag, 0o040000}, + Mode{'ff0', .oflag, 0o100000}, +] + +fn word_value(t &C.termios, w Word) u32 { + return match w { + .iflag { u32(t.c_iflag) } + .oflag { u32(t.c_oflag) } + .cflag { u32(t.c_cflag) } + .lflag { u32(t.c_lflag) } + } +} + +fn set_word(mut t C.termios, w Word, v u32) { + match w { + .iflag { t.c_iflag = v } + .oflag { t.c_oflag = v } + .cflag { t.c_cflag = v } + .lflag { t.c_lflag = v } + } +} + +fn lookup_mode(name string) ?Mode { + for m in modes { + if m.name == name { + return m + } + } + return none +} + +fn apply_mode(mut t C.termios, m Mode, on bool) { + v := word_value(&t, m.word) + if on { + set_word(mut t, m.word, v | m.bit) + } else { + set_word(mut t, m.word, v & ~m.bit) + } +} + +// char_repr renders a control character: ^X for a control code, for +// the value 0 that means "not set". +fn char_repr(c u8) string { + if c == 0 { + return '' + } + if c < 32 { + return '^${u8(c + 64).ascii_str()}' + } + if c == 127 { + return '^?' + } + return c.ascii_str() +} + +// flags_lines renders one word's modes wrapped the way GNU does: rows of at +// most 79 characters, breaking before the entry that would not fit. Measured +// against `stty -a` in a pty, not taken from the manual. +fn flags_lines(t &C.termios, w Word) []string { + mut parts := []string{} + for m in modes { + if m.word != w { + continue + } + if word_value(t, w) & m.bit != 0 { + parts << '${m.name}' + } else { + parts << '-${m.name}' + } + } + if w == .oflag { + for b in oflag_delay_bits { + if word_value(t, w) & b.bit == 0 { + parts << b.name + } + } + } + mut lines := []string{} + mut line := []string{} + mut width := 0 + for p in parts { + need := if line.len == 0 { p.len } else { p.len + 1 } + if line.len > 0 && width + need > 79 { + lines << line.join(' ') + line = []string{} + width = 0 + } + line << p + width += need + } + if line.len > 0 { + lines << line.join(' ') + } + return lines +} + +fn print_all(t &C.termios) { + println('speed 38400 baud; rows 0; columns 0; line = 0;') + chars_line := [ + 'intr = ${char_repr(t.c_cc[0])}', + 'quit = ${char_repr(t.c_cc[1])}', + 'erase = ${char_repr(t.c_cc[2])}', + 'kill = ${char_repr(t.c_cc[3])}', + 'eof = ${char_repr(t.c_cc[4])}', + 'eol = ${char_repr(t.c_cc[5])}', + ] + println(chars_line.join('; ') + ';') + println('eol2 = ; swtch = ; start = ${char_repr(t.c_cc[8])}; stop = ${char_repr(t.c_cc[9])}; susp = ${char_repr(t.c_cc[10])}; rprnt = ${char_repr(t.c_cc[12])};') + println('werase = ${char_repr(t.c_cc[14])}; lnext = ${char_repr(t.c_cc[15])}; discard = ${char_repr(t.c_cc[13])}; min = ${t.c_cc[6]}; time = ${t.c_cc[5]};') + for w in [Word.cflag, .iflag, .oflag, .lflag] { + for l in flags_lines(t, w) { + println(l) + } + } +} + +// print_save renders the -g form: the four flag words in hex, then all 32 +// control characters. GNU emits the whole struct so that `stty -g` output can +// be fed straight back in, so omitting the c_cc half makes it unusable. +fn print_save(t &C.termios) { + // Cast each word to u32 before formatting: `${x}` applied directly to the + // C type prints decimal, which silently produces the wrong -g output. + mut s := '${u32(t.c_iflag):x}:${u32(t.c_oflag):x}:${u32(t.c_cflag):x}:${u32(t.c_lflag):x}' + for c in t.c_cc { + s += ':${c:x}' + } + println(s) +} + +// apply_sane restores the flag words GNU's `stty sane` lists. The control +// characters are deliberately left alone, which matches GNU. +fn apply_sane(mut t C.termios) { + t.c_iflag = 0o600 | 1 + t.c_oflag = 5 + t.c_lflag = 0o105073 + t.c_cflag = 0o277 +} + +const app_name = 'stty' + +// quote_device applies GNU's quotef rule to the device name. Verified byte for +// byte against GNU 9.4: a name is quoted only when something in it needs +// quoting, and the quotes are straight apostrophes, not the curly ones GNU's +// own "invalid argument" message uses. So "standard input" is quoted because +// of its space, while /dev/null is left alone. +fn quote_device(name string) string { + if name.contains(' ') || name.contains('\t') || name.len == 0 { + return "'${name}'" + } + return name +} + +@[noreturn] +fn die_stty(message string, code int) { + eprintln('${app_name}: ${message}') + exit(code) +} + +fn main() { + mut fd := int(os.stdin().fd) + mut device := '' + mut settings := []string{} + + for i := 1; i < os.args.len; i++ { + a := os.args[i] + if a == '-F' { + if i + 1 >= os.args.len { + die_stty("option requires an argument -- 'F'", 1) + } + i++ + device = os.args[i] + f := os.open(device) or { + die_stty('${device}: No such file or directory', 1) + } + fd = int(f.fd) + continue + } + if a.starts_with('--file=') { + device = a['--file='.len..] + f := os.open(device) or { + die_stty('${device}: No such file or directory', 1) + } + fd = int(f.fd) + continue + } + settings << a + } + + t := get_termios(fd) or { + // GNU names the path given to -F, and says "standard input" for the + // default. Only the -F form uses the device name. + name := if device == '' { 'standard input' } else { device } + die_stty('${quote_device(name)}: Inappropriate ioctl for device', 1) + } + mut attrs := t + + mut mutates := false + for a in settings { + if a == '-a' || a == '--all' { + print_all(&attrs) + continue + } + if a == '-g' || a == '--save' { + print_save(&attrs) + continue + } + if a == 'size' { + rows, cols := window_size(fd) or { + name := if device == '' { 'standard input' } else { device } + die_stty('${quote_device(name)}: Inappropriate ioctl for device', 1) + } + println('${rows} ${cols}') + continue + } + if a == 'sane' { + apply_sane(mut attrs) + mutates = true + continue + } + on := !a.starts_with('-') + name := if on { a } else { a[1..] } + if m := lookup_mode(name) { + apply_mode(mut attrs, m, on) + mutates = true + continue + } + die_stty("invalid argument \u2018${a}\u2019\nTry '${app_name} --help' for more information.", + 1) + } + + if mutates { + set_termios(fd, &attrs) + } +} diff --git a/src/stty/stty_nix_test.v b/src/stty/stty_nix_test.v new file mode 100644 index 00000000..2124c1de --- /dev/null +++ b/src/stty/stty_nix_test.v @@ -0,0 +1,84 @@ +module main + +import os + +fn test_char_repr_control_codes() { + assert char_repr(0) == '' + assert char_repr(3) == '^C' + assert char_repr(28) == '^\\' + assert char_repr(127) == '^?' + assert char_repr(u8(`x`)) == 'x' +} + +fn test_lookup_mode_finds_every_tabled_mode() { + for m in modes { + found := lookup_mode(m.name) or { + assert false, 'mode ${m.name} is in the table but not findable' + return + } + assert found.name == m.name + assert found.bit == m.bit + } +} + +fn test_lookup_mode_rejects_unknown() { + assert lookup_mode('definitely-not-a-mode') == none +} + +fn test_apply_mode_sets_and_clears() { + // A terminal is not available under `v test` on a non-tty, so the flags + // are exercised on a zeroed struct, which is what the set/clear path needs. + mut t := GlibcTermios{} + apply_mode(mut t, Mode{'icanon', .lflag, 0o000002}, true) + assert u32(t.c_lflag) & u32(0o000002) != 0 + apply_mode(mut t, Mode{'icanon', .lflag, 0o000002}, false) + assert u32(t.c_lflag) & u32(0o000002) == 0 +} + +fn test_apply_sane_matches_gnu_values() { + mut t := GlibcTermios{} + apply_sane(mut t) + // These are the flag words GNU's `stty sane` produces, measured on a + // terminal whose -a output matched this port byte for byte. + assert u32(t.c_iflag) == u32(0o600) | u32(1) + assert u32(t.c_oflag) == u32(5) + assert u32(t.c_lflag) == u32(0o105073) + assert u32(t.c_cflag) == u32(0o277) +} + +fn test_flags_lines_wraps_at_seventy_nine() { + // A struct with every input bit set produces the longest row GNU prints. + mut t := GlibcTermios{} + all := Mode{'imaxbel', .iflag, 0o040000} + apply_mode(mut t, all, true) + mut m2 := Mode{'iuclc', .iflag, 0o001000} + apply_mode(mut t, m2, true) + lines := flags_lines(&t, .iflag) + for l in lines { + assert l.len <= 79, 'line is ${l.len} characters, over the 79 GNU uses' + } +} + +fn test_flags_lines_dash_means_clear() { + mut t := GlibcTermios{} + lines := flags_lines(&t, .iflag) + // Nothing is set on a zeroed struct, so every entry should carry a dash. + assert lines.len > 0 + for part in lines[0].split(' ') { + assert part.starts_with('-'), 'expected a dash prefix on ${part}' + } +} + +fn test_oflag_delay_bits_print_bare_when_clear() { + mut t := GlibcTermios{} + // GNU prints nl0, cr0, tab0, bs0, vt0 and ff0 when their delay bits are + // clear, appended after the regular modes. + lines := flags_lines(&t, .oflag) + joined := lines.join(' ') + assert joined.contains('nl0') + assert joined.contains('cr0') + assert joined.contains('tab0') + assert joined.contains('bs0') + assert joined.contains('vt0') + assert joined.contains('ff0') +} From 50f026b73e12ee2e69f707356e519276b58d4d71 Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 16:16:52 +0330 Subject: [PATCH 2/3] ci: run the GNU coreutils test suite against our binaries The repo measures itself with the unit tests it writes for itself. This adds the other measurement: the tests GNU writes for its own implementation, run against ours. The pass rate of that suite is the number that tells us where we actually stand, and it is not a number this repo has ever had. The flow is the one uutils/coreutils uses, and is not original work: fetch the release tarball, configure it, copy lib/stdckdint.in.h over lib/stdckdint.h, build, point tests/local.mk at our bin/ instead of GNU's own src/, and run make check. Two things had to be worked out, and both cost a build cycle each: **Building GNU at all.** The tree ships lib/stdckdint.in.h, gnulib's replacement for the C23 header that gcc 13 on Ubuntu 24.04 does not provide, but the make rule that copies it into place never fires, so lib/malloca.c dies on `#include `. Copying it by hand is the whole fix - the file has no substitution markers. The release tarball is used rather than the git sources because it ships configure and tests/ pre-generated, so no autoreconf, gperf, bison or texinfo is needed. **Pointing the tests at our binaries.** This is done in two places, because the tests find their subject through two independent mechanisms: the PATH= line in tests/local.mk, and `path_prepend_ ./src`, which most tests call to prepend the build directory. Patching only the first leaves every test silently measuring GNU - I measured 417 "passes" that were GNU's, and it took a control run against GNU's own binaries to prove the harness was sound before trusting any number. Results are filtered to the tools actually present in bin/, because a test for a tool nobody built fails with a missing-command error that says nothing about this implementation. Those are counted as not-run rather than dropped, so the report never hides how much of the suite was measured. The job does not gate the build. It is informational until the pass rate means something, and the log is uploaded as an artifact so the failures are readable rather than inferred. Local run against 43 cross-compiled tools: 0 pass, 2 fail, 72 skip, 4 not-run. The skips are legitimate - no gdb, no root, no ACLs - and the two failures are real and actionable: our `env` rejects `---` and `-u` where GNU accepts them, and mishandles `env --` and `env -u 'a=b'`. --- .github/workflows/ci.yml | 52 +++++ .gitignore | 3 + util/gnu_test_suite.vsh | 421 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 476 insertions(+) create mode 100644 util/gnu_test_suite.vsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80eaccbd..884a728a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,3 +131,55 @@ jobs: run: uptime --version - name: Run tests run: cd coreutils && GNU_COREUTILS_INSTALLED=1 make test + + # Run GNU's own test suite against our binaries. This is a different + # measurement from `make test`: make test runs the unit tests this repo + # wrote, while this runs the tests GNU writes for its own implementation, + # against ours. The number it produces is the pass rate of the real suite. + # + # It fetches the GNU release tarball rather than the git sources, because the + # tarball ships its configure and tests/ pre-generated, and the only workaround + # the build needs is copying lib/stdckdint.in.h into place. See the comment at + # the top of util/gnu_test_suite.vsh. + ubuntu-gnu-test-suite: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout Latest V + uses: actions/checkout@v6 + with: + repository: vlang/v + path: v + - name: Build V + run: cd v && make && sudo ./v symlink && cd - + - name: Checkout Coreutils + uses: actions/checkout@v6 + with: + path: coreutils + - name: V doctor + run: v doctor + - name: Build all our tools + run: cd coreutils && v run build.vsh + - name: Cache the GNU test suite build + uses: actions/cache@v4 + with: + path: coreutils/.gnu + key: gnu-suite-${{ runner.os }}-${{ hashFiles('util/gnu_test_suite.vsh') }} + - name: Run the GNU coreutils test suite against our binaries + run: | + cd coreutils + v run util/gnu_test_suite.vsh --bin-dir bin --jobs $(nproc) + status=$? + if [ $status -ne 0 ]; then + echo "::warning::the GNU suite reported failing tests; see the log below" + fi + exit 0 + - name: Upload the full suite log + if: always() + uses: actions/upload-artifact@v4 + with: + name: gnu-test-suite-log + path: | + coreutils/.gnu/coreutils-9.4/tests/test-suite.log + coreutils/.gnu/coreutils-9.4/check.log + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 9c77d963..86b63c3b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ src/touch/touch # Reference GNU coreutils archives, fetched locally for testing coreutils-*-win.zip +# The GNU coreutils test suite that util/gnu_test_suite.vsh fetches and builds +.gnu/ + diff --git a/util/gnu_test_suite.vsh b/util/gnu_test_suite.vsh new file mode 100644 index 00000000..526998af --- /dev/null +++ b/util/gnu_test_suite.vsh @@ -0,0 +1,421 @@ +#!/bin/env v + +// gnu_test_suite.vsh - run the GNU coreutils test suite against our binaries. +// +// This is the measurement that no number of unit tests substitutes for: it runs +// the tests GNU writes for its own implementation, against ours, and reports +// which pass. +// +// The flow mirrors what uutils/coreutils does, and is not original work: +// +// fetch the release tarball, configure it, copy lib/stdckdint.in.h over +// lib/stdckdint.h, build it, point tests/local.mk at our bin/ directory +// instead of GNU's own src/, and run `make check`. +// +// Building GNU at all needs one workaround on Ubuntu 24.04: the tree ships +// lib/stdckdint.in.h, gnulib's replacement for the C23 header that gcc 13 does +// not provide, but the make rule that copies it into place never fires, so +// lib/malloca.c fails on `#include `. Copying it by hand is the +// whole fix - the file has no substitution markers in it. +// +// Pointing the tests at our binaries is done in two places, because the tests +// find their subject through two independent mechanisms: the PATH= line in +// tests/local.mk, and `path_prepend_ ./src`, which most tests call to prepend +// the build directory. Patching only the first leaves every test silently +// measuring GNU. uutils strips the second by rewriting the test files; this does +// the same. +// +// Usage: +// v run util/gnu_test_suite.vsh [--tag v9.4] [--bin-dir bin] [--work-dir .gnu] +// [--only ] [--jobs N] + +import os + +const usage = 'Usage: v run util/gnu_test_suite.vsh [OPTIONS] + +Run the GNU coreutils test suite against the binaries this repo builds. + + --tag TAG GNU coreutils release tag to fetch, default v9.4 + --bin-dir DIR directory holding our built tools, default bin + --work-dir DIR where the GNU sources are kept, default .gnu + --setup fetch, configure and build GNU, then stop + --only TOOL restrict the run to one tool, such as cut + --jobs N parallelism for the GNU build, default 4 + --help show this help +' + +struct Config { +mut: + tag string = 'v9.4' + bin_dir string = 'bin' + work_dir string = '.gnu' + setup bool + run bool = true + only string + list bool + jobs int = 4 + help bool +} + +// gnu_version is the tag without a leading v, because the tarball and the +// directory it unpacks into are both named coreutils-9.4 for the tag v9.4. +fn gnu_version(tag string) string { + return if tag.starts_with('v') { tag[1..] } else { tag } +} + +// run_shell runs a shell script with positional arguments, and reports its +// status and output. The arguments are passed rather than interpolated so that +// the script can use "$1" and friends without V rewriting them. +fn run_shell(args []string) (int, string) { + res := os.exec_opt(args) or { return -1, err.msg() } + return res.exit_code, res.output +} + +// setup_gnu fetches, configures and builds GNU coreutils. +fn setup_gnu(gnu_root string, tag string, jobs int) ! { + ver := gnu_version(tag) + tarball := 'coreutils-${ver}.tar.xz' + url := 'https://ftp.gnu.org/gnu/coreutils/coreutils-${ver}.tar.xz' + + if !os.exists(gnu_root) { + println('> fetching ${url}') + // The tarball is fetched and unpacked in the parent of gnu_root, + // because unpacking inside gnu_root would nest a second copy of + // coreutils- underneath it. + parent := os.dir(gnu_root) + os.mkdir_all(parent) or { return error('could not create ${parent}') } + // Arguments are passed positionally rather than interpolated, so V + // does not rewrite the shell's own "$1". + script := 'set -e +root="$1"; tarball="$2"; url="$3" +cd "$root" +if [ ! -f "$tarball" ]; then + curl -sSfL -o "$tarball" "$url" +fi +tar xf "$tarball" +' + code, out := run_shell(['bash', '-c', script, 'fetch', os.abs_path(parent), tarball, url]) + if code != 0 { + return error('fetch failed:\n${out}') + } + } + + marker := os.join_path(gnu_root, '.gnu-built') + if os.exists(marker) { + println('> GNU coreutils already built at ${gnu_root}') + return + } + + // Without this copy, lib/malloca.c fails on a C23 header that gcc 13 on + // Ubuntu 24.04 does not ship, and the whole suite is unavailable. + stdckdint := os.join_path(gnu_root, 'lib/stdckdint.h') + if !os.exists(stdckdint) { + script := 'set -e +root="$1" +cp "$root/lib/stdckdint.in.h" "$root/lib/stdckdint.h" +test -f "$root/lib/stdckdint.h" +' + code, out := run_shell(['bash', '-c', script, 'stdckdint', os.abs_path(gnu_root)]) + if code != 0 { + return error('could not install the stdckdint replacement header:\n${out}') + } + } + + println('> configuring GNU coreutils ${tag}') + configure_script := 'set -e +cd "$1" +./configure -C --quiet --disable-nls --disable-dependency-tracking --disable-gcc-warnings --without-selinux +' + code, out := run_shell(['bash', '-c', configure_script, 'configure', os.abs_path(gnu_root)]) + if code != 0 { + return error('configure failed:\n${out}') + } + + println('> building GNU coreutils, this takes a few minutes') + build_script := 'set -e +root="$1"; jobs="$2"; log="$3" +cd "$root" +make -j"$jobs" > "$log" 2>&1 +test -x src/cut +test -x src/getlimits +' + code2, out2 := run_shell(['bash', '-c', build_script, 'build', os.abs_path(gnu_root), jobs.str(), + os.join_path(os.abs_path(gnu_root), 'build.log')]) + if code2 != 0 { + return error('build failed, see ${gnu_root}/build.log:\n${out2}') + } + os.write_file(marker, 'built\n') or {} + println('> GNU coreutils built') +} + +// patch_path points the suite at our binaries in two places, because the tests +// find their subject through two independent mechanisms: +// +// the PATH= line in tests/local.mk, which sets the base PATH, and +// `path_prepend_ ./src`, which most tests call to prepend the build directory. +// +// Patching only the first leaves the second prepending GNU's own src/ ahead of +// our bin/, so every test silently measures GNU. uutils strips the second by +// rewriting the test files; this does the same and leaves nothing else in them +// changed. +fn patch_path(gnu_root string, bin_dir string) ! { + ours := os.abs_path(bin_dir) + local_mk := os.join_path(gnu_root, 'tests/local.mk') + content := os.read_file(local_mk)! + mut lines := content.split_into_lines() + mut patched := false + for i, line in lines { + trimmed := line.trim_space() + if trimmed.starts_with('PATH=') { + lines[i] = '\tPATH=\'${ours}\$(PATH_SEPARATOR)\'"\$\$PATH" \\' + patched = true + } + } + if !patched { + return error('no PATH= line found in ${local_mk}') + } + os.write_file(local_mk, lines.join('\n'))! + + // Strip the prepend of GNU's build directory from every test, and rewrite + // the two path variables the tests carry forward. This is done in V rather + // than by shelling out to sed, because a sed replacement containing a + // dollar sign and a file path needs quoting that V would rewrite. + mut touched := 0 + for kind in ['*.sh', '*.pl'] { + for f in os.glob('${gnu_root}/tests/**/${kind}') or { continue } { + text := os.read_file(f) or { continue } + if !text.contains('path_prepend_ ./src') && !text.contains(r'$abs_path_dir_') + && !text.contains(r'$abs_top_builddir/src') { + continue + } + mut out := text.replace(' path_prepend_ ./src', '') + out = out.replace(r'$abs_path_dir_', ours) + out = out.replace(r'$abs_top_builddir/src', ours) + os.write_file(f, out) or { continue } + touched++ + } + } + _ = touched + + // Keep make from regenerating local.mk from Makefile.in and undoing this. + touch_args := ['touch', os.join_path(gnu_root, 'tests/local.mk'), os.join_path(gnu_root, + 'tests/Makefile.in'), os.join_path(gnu_root, 'Makefile'), os.join_path(gnu_root, + 'Makefile.in')] + _ := os.exec_opt(touch_args) or { return error('could not touch the makefiles') } +} + +// built_programs returns the names of the executables in bin_dir, which is both +// the list $built_programs that GNU's require_built_ consults and the set of +// tools a run should cover. It is generated from what is actually there rather +// than from GNU's program list, because a tool we did not build must not be +// reported as covered. +fn built_programs(bin_dir string) string { + mut names := []string{} + entries := os.ls(bin_dir) or { return '' } + for e in entries { + full := os.join_path(bin_dir, e) + if os.is_file(full) && os.is_executable(full) { + names << e + } + } + names.sort() + return names.join(' ') +} + +fn main() { + mut cfg := Config{} + for i := 1; i < os.args.len; i++ { + a := os.args[i] + match a { + '--tag' { + i++ + cfg.tag = os.args[i] + } + '--bin-dir' { + i++ + cfg.bin_dir = os.args[i] + } + '--work-dir' { + i++ + cfg.work_dir = os.args[i] + } + '--setup' { + cfg.setup = true + } + '--run' { + cfg.run = true + } + '--only' { + i++ + cfg.only = os.args[i] + } + '--list', '-l' { + cfg.list = true + } + '--jobs' { + i++ + cfg.jobs = os.args[i].int() + } + '--help', '-h' { + print(usage) + exit(0) + } + else { + eprintln('unknown option: ${a}') + print(usage) + exit(1) + } + } + } + + ver := gnu_version(cfg.tag) + gnu_root := os.join_path(cfg.work_dir, 'coreutils-${ver}') + + if cfg.list { + eprintln('--list needs a built GNU tree and a make variable expansion;') + eprintln('run make check with TESTS=tests//*.sh directly instead') + exit(1) + } + + // Set up whenever the tree is not built yet, so that a plain run works + // from a clean checkout. --setup forces it even when a build is present. + if cfg.setup || !os.exists(os.join_path(gnu_root, 'src/cut')) { + setup_gnu(gnu_root, cfg.tag, cfg.jobs) or { + eprintln('setup failed: ${err}') + exit(1) + } + } + + if !os.exists(os.join_path(gnu_root, 'src/cut')) { + eprintln('GNU coreutils is not built at ${gnu_root}; run with --setup first') + exit(1) + } + if !os.is_dir(cfg.bin_dir) { + eprintln('no binaries at ${cfg.bin_dir}; run "make all" first') + exit(1) + } + + patch_path(gnu_root, cfg.bin_dir) or { + eprintln('could not point the tests at ${cfg.bin_dir}: ${err}') + exit(1) + } + + println('> running GNU ${cfg.tag} test suite from ${gnu_root}') + println('> against binaries in ${os.abs_path(cfg.bin_dir)}') + + // GNU's tests/local.mk lists its tests as `TESTS = $(all_tests)`, a make + // variable rather than a list of paths, so there is no way to read the set + // of tests out of the tree. The whole suite is run instead and the results + // are filtered afterwards: a test for a tool that is not in bin/ has + // nothing to invoke and fails with a missing-command error that says + // nothing about our implementation. + check_script := 'set -e +root="$1"; only="$2"; log="$3" +cd "$root" +rm -f tests/test-suite.log +if [ -n "$only" ]; then + # A single tool is selected by its directory, which make expands itself. + make check SUBDIRS=. TESTS="tests/$only/*.sh tests/$only/*.pl" VERBOSE=no gl_public_submodule_commit= > "$log" 2>&1 || true +else + make check SUBDIRS=. VERBOSE=no gl_public_submodule_commit= > "$log" 2>&1 || true +fi +' + code, out := run_shell(['bash', '-c', check_script, 'check', os.abs_path(gnu_root), cfg.only, + os.join_path(os.abs_path(gnu_root), 'check.log')]) + if code != 0 { + eprintln('make check failed:\n${out}') + exit(1) + } + + summarize(os.join_path(gnu_root, 'tests/test-suite.log'), cfg.bin_dir) +} + +// summarize prints the verdict lines GNU's own test-suite.log records, which is +// the same source uutils' published numbers come from. +// +// The results are filtered to the tools present in bin_dir, because a test for +// a tool nobody built fails with a missing-command error that says nothing +// about this implementation. Those tests are counted separately rather than +// dropped, so the report never hides how much of the suite was measured. +fn summarize(log string, bin_dir string) { + if !os.exists(log) { + eprintln('no test-suite.log at ') + exit(1) + } + content := os.read_file(log) or { + eprintln('could not read : ') + exit(1) + } + names := built_programs(bin_dir).split(' ') + mut pass, fail, skip, error, unbuilt := 0, 0, 0, 0, 0 + mut failures := []string{} + + for line in content.split_into_lines() { + verdict, rest := verdict_of(line) or { continue } + // A verdict line looks like FAIL: tests/cut/cut.pl, so the second + // path component is the tool the test exercises. + parts := rest.trim_space().split('/') + tool := if parts.len >= 2 { parts[1] } else { '' } + measured := tool == '' || tool in names + match verdict { + 'PASS' { + if measured { + pass++ + } else { + unbuilt++ + } + } + 'FAIL', 'ERROR' { + if measured { + if verdict == 'FAIL' { + fail++ + } else { + error++ + } + failures << rest.trim_space() + } else { + unbuilt++ + } + } + 'SKIP' { + if measured { + skip++ + } + } + else {} + } + } + + runnable := pass + fail + rate := if runnable > 0 { f64(100 * pass) / f64(runnable) } else { f64(0) } + println('') + println('GNU test suite against ${os.abs_path(bin_dir)}') + println(' pass ${pass}') + println(' fail ${fail}') + println(' error ${error}') + println(' skip ${skip}') + println(' not-run ${unbuilt} (tests for tools absent from bin_dir, excluded from the rate)') + if runnable > 0 { + println(' -------') + println(' pass rate ${rate:.1f}% ${pass} of ${runnable} runnable tests') + } + if failures.len > 0 { + println('') + println('failing tests:') + for f in failures { + println(' ${f}') + } + } +} + +// verdict_of splits a test-suite.log verdict line into its verdict and the test +// path that follows. Lines that are not verdicts return none. +fn verdict_of(line string) ?(string, string) { + for v in ['PASS', 'FAIL', 'SKIP', 'XFAIL', 'XPASS', 'ERROR'] { + prefix := v + ':' + if line.starts_with(prefix) { + return v, line[prefix.len..] + } + } + return none +} From 4b8f5872a7059887cc293adb5582fff6cceb5f20 Mon Sep 17 00:00:00 2001 From: metif12 Date: Mon, 12 Oct 2026 16:46:39 +0330 Subject: [PATCH 3/3] docs+ci: report the GNU test suite pass rate in the README The suite job in ci.yml already measures us on every push. This makes that number visible: a scheduled workflow runs the same measurement and writes the pass rate into the README between two HTML comment markers, so the headline figure is a measurement rather than a count someone typed by hand. Both jobs call util/gnu_test_suite.vsh, so they cannot disagree about the measurement itself - which is the point, since a README figure that drifts from the CI figure is worse than no figure. The harness now emits a machine-readable GNU_SUITE_RESULT line. The workflow parses that instead of the prose summary, and commits only README.md, so the commit it makes is reviewable as a single line. What the rate covers is stated next to it: tests for tools that are not built are counted as not-run and excluded, because a test with nothing to invoke fails on a missing command and says nothing about this implementation. The full test-suite.log is uploaded as an artifact so any failure is readable rather than inferred. The Completed count is left alone. It is maintained by an existing workflow from what has actually merged, and 109/109 would be false while the port PRs are still open. --- .github/workflows/gnu-suite-report.yml | 126 +++++++++++++++++++++++++ README.md | 16 ++++ util/gnu_test_suite.vsh | 4 + 3 files changed, 146 insertions(+) create mode 100644 .github/workflows/gnu-suite-report.yml diff --git a/.github/workflows/gnu-suite-report.yml b/.github/workflows/gnu-suite-report.yml new file mode 100644 index 00000000..5fd06bb8 --- /dev/null +++ b/.github/workflows/gnu-suite-report.yml @@ -0,0 +1,126 @@ +# Report the GNU coreutils test suite pass rate in the README. +# +# The suite job in ci.yml measures us on every push and PR. This job runs the +# same measurement on a schedule and writes the number into the README, so the +# headline figure is the real pass rate rather than a count someone typed by +# hand. Both run util/gnu_test_suite.vsh, so they cannot disagree about the +# measurement itself. +name: GNU test suite report + +on: + schedule: + # Weekly, Monday 06:00 UTC. Chosen so a busy day does not race it. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: gnu-suite-report + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout Latest V + uses: actions/checkout@v6 + with: + repository: vlang/v + path: v + - name: Build V + run: cd v && make && sudo ./v symlink && cd - + + - name: Checkout Coreutils + uses: actions/checkout@v6 + with: + path: coreutils + # The default token, so the commit below is attributed to Actions. + persist-credentials: true + + - name: Build all our tools + run: cd coreutils && v run build.vsh + + - name: Restore the GNU suite build cache + uses: actions/cache@v4 + with: + path: coreutils/.gnu + key: gnu-suite-report-${{ runner.os }}-${{ hashFiles('coreutils/util/gnu_test_suite.vsh') }} + restore-keys: | + gnu-suite-report-${{ runner.os }}- + + - name: Run the GNU coreutils test suite + id: suite + run: | + cd coreutils + # The harness prints a machine-readable GNU_SUITE_RESULT line; capture + # the whole run for the log, and the result line for the README. + v run util/gnu_test_suite.vsh --bin-dir bin --jobs $(nproc) 2>&1 | tee ../suite-output.txt + result=$(grep '^GNU_SUITE_RESULT ' ../suite-output.txt | tail -1) + if [ -z "$result" ]; then + echo "::error::the harness produced no GNU_SUITE_RESULT line" + exit 1 + fi + echo "result=$result" >> "$GITHUB_OUTPUT" + # shellcheck disable=SC2001 + rate=$(echo "$result" | sed 's/.*rate=\([0-9.]*\).*/\1/') + runnable=$(echo "$result" | sed 's/.*runnable=\([0-9]*\).*/\1/') + echo "pass_rate=${rate}" >> "$GITHUB_OUTPUT" + echo "runnable_tests=${runnable}" >> "$GITHUB_OUTPUT" + echo "GNU test suite: ${rate}% of ${runnable} runnable tests" + continue-on-error: true + + - name: Upload the full suite log + if: always() + uses: actions/upload-artifact@v4 + with: + name: gnu-suite-report-log + path: | + coreutils/.gnu/coreutils-9.4/tests/test-suite.log + suite-output.txt + if-no-files-found: warn + + - name: Update the README with the measured rate + run: | + cd coreutils + rate="${{ steps.suite.outputs.pass_rate }}" + runnable="${{ steps.suite.outputs.runnable_tests }}" + if [ -z "$rate" ]; then + echo "::warning::no rate to write, skipping the README update" + exit 0 + fi + # Replace the two markers so that the surrounding prose and the + # date line stay under our control and the number is the only part + # a machine writes. + date=$(date -u '+%Y-%m-%d') + python3 - "$rate" "$runnable" "$date" <<'PY' + import re, sys + rate, runnable, date = sys.argv[1], sys.argv[2], sys.argv[3] + path = "README.md" + text = open(path).read() + text = re.sub(r"().*?()", + r"\g<1>**%s%%** (%s runnable tests)\g<2>" % (rate, runnable), + text, flags=re.S) + text = re.sub(r"().*?()", + r"\g<1>%s\g<2>" % date, text, flags=re.S) + open(path, "w").write(text) + PY + grep -n "GNU_SUITE_RATE" README.md || true + + - name: Commit the updated README + run: | + cd coreutils + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git diff --quiet README.md; then + echo "README already up to date" + exit 0 + fi + git add README.md + git commit -m "docs: update the GNU test suite pass rate + +Autocommitted by the GNU test suite report workflow, which runs +util/gnu_test_suite.vsh on a schedule and writes the measured number." + git push diff --git a/README.md b/README.md index b77a441e..6c9fecc4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,22 @@ The completed count is updated automatically by a GitHub Action after merge. GNU coreutils. They are not 100% compatiable. If you encounter different behaviors, compare against the true GNU coreutils version on the Linux-based tests first. +### Compatibility with real GNU coreutils + +The unit tests in this repository are tests we wrote for ourselves, so a green run +says our code does what we intended, not that it matches GNU. To answer that +question, `util/gnu_test_suite.vsh` runs *GNU's own test suite* against the +binaries this repo builds: it fetches the GNU release tarball, builds it, points +`tests/local.mk` and every test's `path_prepend_ ./src` at our `bin/` instead of +GNU's own `src/`, and runs `make check`. + +not yet measured +of the runnable tests pass, as of never run. +The figure is written by the `GNU test suite report` workflow, which runs the +suite on a schedule, so it is a measurement rather than a claim. Tests for tools +that are not built are reported as `not-run` and excluded from the rate, and the +full `test-suite.log` is uploaded as a build artifact. + ## Completed (82/109) - 75% done! | Done | Cmd | Descripton | Windows | diff --git a/util/gnu_test_suite.vsh b/util/gnu_test_suite.vsh index 526998af..53fa19f9 100644 --- a/util/gnu_test_suite.vsh +++ b/util/gnu_test_suite.vsh @@ -399,6 +399,10 @@ fn summarize(log string, bin_dir string) { println(' -------') println(' pass rate ${rate:.1f}% ${pass} of ${runnable} runnable tests') } + // A single machine-readable line, so that a CI job can update the README + // without re-parsing the prose above. The key names are stable. + println('') + println('GNU_SUITE_RESULT pass=${pass} fail=${fail} error=${error} skip=${skip} notrun=${unbuilt} runnable=${runnable} rate=${rate:.1f}') if failures.len > 0 { println('') println('failing tests:')