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/.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/.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/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/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') +} diff --git a/util/gnu_test_suite.vsh b/util/gnu_test_suite.vsh new file mode 100644 index 00000000..53fa19f9 --- /dev/null +++ b/util/gnu_test_suite.vsh @@ -0,0 +1,425 @@ +#!/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') + } + // 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:') + 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 +}