I keep small behavior specs for GNU coreutils commands in the behave repo. Each spec is a folder of YAML files: the arguments, stdin, input files, and the exact stdout, stderr and exit code GNU produced. Then I replay the same folder against another implementation and read the diff.
This week I added one for expand (tabs to spaces). 21 cases: default and custom tab stops, the obsolete -4 form, tab lists, the /N and +N extensions, -i, backspace, several files, and the usual error paths. Recording took a minute:
behave add examples/expand --name 'files: dash means stdin' \
--stdin $'\ts\n' --file f=$'a\tb\n' -- f - f
The diff against uutils
Replaying it against uutils coreutils built from main:
✗ files: dash means stdin 0017-files-dash-means-stdin.yaml
$ expand f - f
stdout:
a b
- s
a b
expand f - f should print the file, then stdin, then the file again. The rewrite printed the file twice and never read stdin. No error, exit code 0.
The cause is a small preprocessing step that turns obsolete options like -4 or -2,6 into --tabs=.... It checks "starts with - and the rest is digits or commas". For a bare - the rest is empty, and "all characters are digits" is true for an empty string, so the hyphen was swallowed. expand - alone still worked because the file list defaults to -, which is exactly why this kind of bug survives: the common case is fine.
Someone had already found it (uutils/coreutils#15716 is open with a fix), so I linked it from the example instead of filing a duplicate. The other two differences were error wording only.
The bug in my own tool
Writing the spec also broke behave itself. An input file starting with a tab ($'\tc\n') was saved as a YAML literal block whose first line began with a tab, which YAML does not allow. The case saved fine, then every later command on the spec failed to load it. Input file contents now get the same quoting rules as stdin and stdout, with a round-trip test for tabs, leading spaces, CR and empty files.
A version trap
My first version had 22 cases. One checked that é before a tab counts as one column. It passed locally on GNU 9.12 and failed in CI, where Ubuntu 24.04 still ships GNU 9.4, which counts the two bytes. Same tool, two answers depending on the release. I dropped the case and wrote down why, because a spec that fails for reasons unrelated to the tool under test teaches people to ignore it.
The spec is in examples/expand if you want to run it against your own build:
behave run examples/expand # against expand on your PATH
behave diff examples/expand --impl ./target/release/expand
Top comments (1)
The vacuous match is the whole bug. After the leading
-is stripped, "every remaining character is a digit or a comma" is true for the empty string, so a bare-is rewritten as--tabs=and never reaches the file list.expand -still passes because no file operand already means stdin.expand f - fis the case that actually fails: file, then stdin, then file, and the rewrite printed the file twice with exit 0.The column-count split is worth keeping as a written skip even though you dropped the case. GNU 9.4 counting the two bytes of é and 9.12 counting one column means that test is a coreutils version pin, not a signal about the rewrite. A spec that goes red on Ubuntu 24.04's 9.4 for that reason is exactly the kind people start ignoring.