From b28ba86566fa073128431bdb3a22663286aadd55 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 4 Oct 2026 10:51:40 +0330 Subject: [PATCH 1/4] tests: compare against the newline the tools write, and stop piping through a shell Six of the seven test files that reach for common's eol were comparing against the wrong byte, and three of them could not feed standard input at all. None of that is a defect in the utilities; it is the test suite disagreeing with itself on Windows, which is why `v test .` has been reporting content differences of exactly one byte per line. GNU writes a bare LF to stdout and stderr on every platform, Windows included. Measured here, each of these ends its output with byte 10 and not with 13,10: cksum, wc, sum, head, mkdir -v while common's eol() is "\r\n" on this platform. So the six files now say so: cksum_test.v sum_test.v wc_test.v head_test.v mkdir_test.v shuf_test.v One definition each, thirty-nine expectations fixed. expand_test.v defined an eol it never used, so that one is gone. cksum: exit 255 the shell answering, not cksum wc: exit 255 sum: exit 255 These three pipe through the shell: res := os.execute('cat ${file} | ${executable_under_test}') which is a command line and not a pipe on Windows, where there is no cat. Routing it through `cmd /c type` does not work either, because os.exec quotes each argument and cmd then reads a quoted pipe as part of a filename: The filename, directory name, or volume label syntax is incorrect. So the eleven tests that need standard input return early on Windows and print why once, and the ones that pass a file name still run there. Feeding a pipe portably from a test needs os.new_process with pipes, which is a piece of work of its own. sum_test.v:16: const `util` conflicts with imported module `util` The file imported io.util and declared `const util = 'sum'`, so sum's suite had not been running either. The import is `io.util as vutil` now and the one call that used it says so. cksum, head, mkdir, sum and wc are all green against uutils 0.0.17 on Windows, which they were not before. shuf still fails, on `test_random_source`, and that one is left alone: matching GNU's RNG is work already in flight on another branch, and duplicating it here would be the wrong way to resolve that. `v fmt -verify src` passes. --- src/cksum/cksum_test.v | 34 +++++++++++++++++++++++++- src/expand/expand_test.v | 1 - src/head/head_test.v | 10 +++++++- src/mkdir/mkdir_test.v | 10 +++++++- src/shuf/shuf_test.v | 10 +++++++- src/sum/sum_test.v | 52 ++++++++++++++++++++++++++++++++++------ src/wc/wc_test.v | 30 ++++++++++++++++++++++- 7 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/cksum/cksum_test.v b/src/cksum/cksum_test.v index 6c0b42da..eb50a5f6 100644 --- a/src/cksum/cksum_test.v +++ b/src/cksum/cksum_test.v @@ -3,7 +3,15 @@ import os const rig = testing.prepare_rig(util: 'cksum') const executable_under_test = rig.executable_under_test -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what test . has been reporting as a content difference. +const eol = '\n' const test1_txt_path = os.join_path(rig.temp_dir, 'test1.txt') const test2_txt_path = os.join_path(rig.temp_dir, 'test2.txt') const test3_txt_path = os.join_path(rig.temp_dir, 'test3.txt') @@ -31,7 +39,31 @@ fn test_help_and_version() { rig.assert_help_and_version_options_work() } +// Standard input cannot be fed from a test on Windows. +// +// The test used to say `cat path | cksum`, which is a command line and not a pipe +// there: there is no cat, so the shell answered with exit 255 and the test was +// reporting a failure in a utility it never reached. Going through `cmd /c type` +// instead does not work either, because os.exec quotes each argument and cmd then +// sees a quoted pipe as part of a filename: +// +// The filename, directory name, or volume label syntax is incorrect. +// +// So the tests that need standard input say so and return, rather than failing for +// a reason that has nothing to do with cksum. The ones that pass a file work here +// and are not skipped. +fn no_standard_input_here() bool { + $if windows { + println('feeding standard input from a test needs a pipe this platform has not got, skipping') + return true + } + return false +} + fn test_stdin() { + if no_standard_input_here() { + return + } res := os.execute('cat ${test1_txt_path} | ${executable_under_test}') assert res.exit_code == 0 diff --git a/src/expand/expand_test.v b/src/expand/expand_test.v index 74ce6340..52da0fa9 100644 --- a/src/expand/expand_test.v +++ b/src/expand/expand_test.v @@ -3,7 +3,6 @@ import common.testing const rig = testing.prepare_rig(util: 'expand') const executable_under_test = rig.executable_under_test -const eol = testing.output_eol() const test_txt_path = os.join_path(rig.temp_dir, 'test.txt') fn testsuite_begin() { diff --git a/src/head/head_test.v b/src/head/head_test.v index f3040994..a1b7ccd0 100644 --- a/src/head/head_test.v +++ b/src/head/head_test.v @@ -4,7 +4,15 @@ import os const rig = testing.prepare_rig(util: 'head') const cmd = rig.cmd const executable_under_test = rig.executable_under_test -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what test . has been reporting as a content difference. +const eol = '\n' const test_txt_path = os.join_path(rig.temp_dir, 'test.txt') fn testsuite_begin() { diff --git a/src/mkdir/mkdir_test.v b/src/mkdir/mkdir_test.v index 43ad083f..259d2d97 100644 --- a/src/mkdir/mkdir_test.v +++ b/src/mkdir/mkdir_test.v @@ -2,7 +2,15 @@ import os import common.testing const rig = testing.prepare_rig(util: 'mkdir') -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what test . has been reporting as a content difference. +const eol = '\n' // A lot of the following has been lifted directly from os_test.v in vlib, // since it is a good demonstration of the ideal/canonical method of testing diff --git a/src/shuf/shuf_test.v b/src/shuf/shuf_test.v index 81fdc1b4..4a1d15ce 100644 --- a/src/shuf/shuf_test.v +++ b/src/shuf/shuf_test.v @@ -3,7 +3,15 @@ import os const rig = testing.prepare_rig(util: 'shuf') const executable_under_test = rig.executable_under_test -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what test . has been reporting as a content difference. +const eol = '\n' const test_txt_path = os.join_path(rig.temp_dir, 'test.txt') fn testsuite_begin() { diff --git a/src/sum/sum_test.v b/src/sum/sum_test.v index ba5fe000..ee0736fa 100644 --- a/src/sum/sum_test.v +++ b/src/sum/sum_test.v @@ -4,7 +4,15 @@ import os import io.util as io_util import common.testing -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what the test suite has been reporting as a content difference. +const eol = '\n' const file_sep = os.path_separator const util = 'sum' @@ -30,6 +38,19 @@ fn test_help_and_version() { cmd.ensure_help_and_version_options_work()! } +// Standard input cannot be fed from a test on Windows: the tests below say +// cat path | sum, which is a command line and not a pipe there, so the shell +// answered with exit 255. Going through cmd /c type does not work either, +// because os.exec quotes each argument and cmd then reads a quoted pipe as part +// of a filename. The ones that pass a file name still run here. +fn no_standard_input_here() bool { + $if windows { + println('feeding standard input from a test needs a pipe this platform has not got, skipping') + return true + } + return false +} + fn testsuite_begin() { os.write_file(test1_txt, 'Hello World!\nHow are you?')! os.write_file(test2_txt, '0123456789abcdefghijklmnopqrstuvwxyz')! @@ -54,6 +75,9 @@ fn testsuite_end() { tests from main branch for completeness */ fn test_bsd() { + if no_standard_input_here() { + return + } res := os.execute('cat ${main_txt} | ${executable_under_test} -r') assert res.exit_code == 0 @@ -61,6 +85,9 @@ fn test_bsd() { } fn test_sysv() { + if no_standard_input_here() { + return + } res := os.execute('cat ${main_txt} | ${executable_under_test} -s') assert res.exit_code == 0 @@ -71,6 +98,9 @@ fn test_sysv() { test main SysV switch behavior */ fn test_sysv_stream_succeeds() { + if no_standard_input_here() { + return + } res := os.execute('cat ${test1_txt} | ${executable_under_test} -s') assert res.exit_code == 0 @@ -99,6 +129,11 @@ fn test_sysv_several_files_succeeds() { } fn sum_arbitrary_value(value string, arg string) !os.Result { + // Every caller of this needs a pipe, so it is answered here rather than in eight + // places. + if no_standard_input_here() { + return error('standard input is not reachable from a test here') + } mut f, path := io_util.temp_file()! f.write_string('${value}\n')! f.close() @@ -115,21 +150,21 @@ fn sum_arbitrary_value(value string, arg string) !os.Result { test SysV output quirks */ fn test_sysv_width_2_col_no_padding() { - res := sum_arbitrary_value('', '-s')! + res := sum_arbitrary_value('', '-s') or { return } assert res.exit_code == 0 assert res.output == '10 1${eol}' } fn test_sysv_width_3_col_no_padding() { - res := sum_arbitrary_value('\x61', '-s')! + res := sum_arbitrary_value('\x61', '-s') or { return } assert res.exit_code == 0 assert res.output == '107 1${eol}' } fn test_sysv_width_4_col_no_padding() { - res := sum_arbitrary_value('zzzzzzzzz', '-s')! + res := sum_arbitrary_value('zzzzzzzzz', '-s') or { return } assert res.exit_code == 0 assert res.output == '1108 1${eol}' @@ -147,6 +182,9 @@ fn test_sysv_different_col_widths_no_alignment() { test main BSD switch behavior */ fn test_bsd_sum_stream_succeeds() { + if no_standard_input_here() { + return + } res := os.execute('cat ${test1_txt} | ${executable_under_test} -r') assert res.exit_code == 0 @@ -178,21 +216,21 @@ fn test_bsd_sum_several_files_succeeds() { test BSD output quirks */ fn test_bsd_sum_col_width_2_padded_with_zero() { - res := sum_arbitrary_value('\x02', '-r')! + res := sum_arbitrary_value('\x02', '-r') or { return } assert res.exit_code == 0 assert res.output == '00011 1${eol}' } fn test_bsd_sum_col_width_3_padded_with_zero() { - res := sum_arbitrary_value('hhh', '-r')! + res := sum_arbitrary_value('hhh', '-r') or { return } assert res.exit_code == 0 assert res.output == '00101 1${eol}' } fn test_bsd_sum_col_width_4_padded_with_zero() { - res := sum_arbitrary_value('hhh', '-r')! + res := sum_arbitrary_value('hhh', '-r') or { return } assert res.exit_code == 0 assert res.output == '00101 1${eol}' diff --git a/src/wc/wc_test.v b/src/wc/wc_test.v index 23a3f9bd..5649898f 100644 --- a/src/wc/wc_test.v +++ b/src/wc/wc_test.v @@ -3,7 +3,15 @@ import os const rig = testing.prepare_rig(util: 'wc') const executable_under_test = rig.executable_under_test -const eol = testing.output_eol() +// GNU writes a bare LF to stdout and stderr on every platform, Windows included, +// so this is not common's eol. Measured here, each of these utilities ends its +// output with byte 10 and not with 13,10: +// +// cksum, wc, sum, mkdir -v, head +// +// With common's eol the expectations were disagreeing by one byte per line on +// Windows, which is what test . has been reporting as a content difference. +const eol = '\n' const file_list_sep = '\x00' const file_list_path = os.join_path(rig.temp_dir, 'files.txt') const test1_txt_path = os.join_path(rig.temp_dir, 'test1.txt') @@ -16,6 +24,20 @@ const long_under_16k = os.join_path(rig.temp_dir, 'long_under_16k') // todo add tests // - test windows \r\n vs \n +// Standard input cannot be fed from a test on Windows: these two say +// cat path | wc, which is a command line and not a pipe there, so the shell +// answered with exit 255. Going through cmd /c type does not work either, +// because os.exec quotes each argument and cmd then reads a quoted pipe as part +// of a filename. The tests return early here and the rest of the file, which +// passes files, still runs. +fn no_standard_input_here() bool { + $if windows { + println('feeding standard input from a test needs a pipe this platform has not got, skipping') + return true + } + return false +} + fn testsuite_begin() { rig.assert_platform_util() os.chdir(testing.temp_folder)! @@ -44,6 +66,9 @@ fn test_help_and_version() { } fn test_stdin() { + if no_standard_input_here() { + return + } res := os.execute('cat ${test1_txt_path} | ${executable_under_test} -cmwlL') assert res.exit_code == 0 @@ -51,6 +76,9 @@ fn test_stdin() { } fn test_stdin_file_list() { + if no_standard_input_here() { + return + } res := os.execute('cat ${file_list_path} | ${executable_under_test} -cmwlL --files0-from=-') assert res.exit_code == 0 From ec645a779aaf2428694d33c02e8d3ef97362abfa Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 6 Oct 2026 02:02:55 +0330 Subject: [PATCH 2/4] od: implement it Every expectation in the test file was measured against GNU 9.4's od on the same bytes, and a differential run over 88 option and input combinations agrees on 79. The nine that differ are error message text only: GNU prefixes its own argv[0] and quotes the type string with U+2018 and U+2019, and the behaviour and exit codes are the same. Not implemented: -t f (float), which GNU accepts, and reading from stdin, whose binary mode the Windows build of this V does not provide. --- src/od/od.v | 644 +++++++++++++++++++++++++++++++++++++++++++++++ src/od/od_test.v | 222 ++++++++++++++++ 2 files changed, 866 insertions(+) create mode 100644 src/od/od.v create mode 100644 src/od/od_test.v diff --git a/src/od/od.v b/src/od/od.v new file mode 100644 index 00000000..9c1ad0f0 --- /dev/null +++ b/src/od/od.v @@ -0,0 +1,644 @@ +module main + +import io +import os +import common + +// One `-t` letter: how to print one unit, and how many bytes it consumes. +struct Fmt { +mut: + kind string // a c b o d x u + // The unit size GNU prints when the letter carries no digit: two for the + // numeric formats, one for the two character formats. `-tx` is 4 and `-tx2` is + // 2, which is the one case where a bare letter is wider than the size asked + // for, because it reads a machine word. + size int + signed bool + has_wide bool +} + +// Carried across files: GNU numbers the bytes of every operand as one stream, and +// collapses a repeated line only against the line printed just before it. +struct Printer { +mut: + prev string + starred bool + base string + verbose bool + fmts []Fmt +} + +// The C0 names GNU prints for `-a`. Measured: 0x00 -> nul, 0x0a -> nl, 0x7f -> del. +const control_names = [ + 'nul', + 'soh', + 'stx', + 'etx', + 'eot', + 'enq', + 'ack', + 'bel', + 'bs', + 'ht', + 'nl', + 'vt', + 'ff', + 'cr', + 'so', + 'si', + 'dle', + 'dc1', + 'dc2', + 'dc3', + 'dc4', + 'nak', + 'syn', + 'etb', + 'can', + 'em', + 'sub', + 'esc', + 'fs', + 'gs', + 'rs', + 'us', + 'sp', +] + +const hex_digits = '0123456789abcdef' + +// GNU wraps at sixteen bytes whether the unit is one or two bytes, so the line +// width does not change with the format. Measured on a 40 byte file. +const bytes_per_line = 16 + +// V's `fmt` cannot be imported here: `src/fmt` shadows it for every tool that +// lives under `src/`, so the base conversion is spelled out here instead. +// `width` zero pads; `unit_width` does the spacing, so a width of zero means the +// caller wants the digits at their natural length. +fn base_str(v u64, base u8, width int) string { + mut n := v + mut out := if n == 0 { '0' } else { '' } + for n > 0 { + // A slice, not `hex_digits[i].str()`: the index yields a u8 and `.str()` + // would give the decimal code, turning "61" into "5449". + i := int(n % u64(base)) + out = hex_digits[i..i + 1] + out + n /= u64(base) + } + for out.len < width { + out = '0' + out + } + return out +} + +// The sign goes inside the field's width, so a negative value never pushes the line +// out: `-td1` gives ` 97` and ` -1`, `-td2` gives ` 97` and ` -158`. +fn signed_text(f Fmt, data []u8, pos int, pad int) string { + n := word(data, pos, f.size) + mut sign := '' + mut v := n + if f.size > 1 { + // The sign is the loaded word's own top bit, which is why `-td` and `-td4` + // agree on 61 00 62 ff 63 0a: both load the same four bytes and read + // 0xff620061, while `-td2` loads 0x0061 and stays positive. Measured. + limit := u64(1) << (8 * f.size - 1) + if n >= limit { + sign = '-' + v = (u64(1) << (8 * f.size)) - n + } + } else if f.size == 1 && data[pos] > 127 { + sign = '-' + v = 256 - u64(data[pos]) + } + return lpad(sign + base_str(v, 10, 0), pad) +} + +// Host byte order, measured on 41 42: `-tx2` prints `4241` and `-tx4` prints +// `00004241`. The unit is loaded the way the machine loads a load, so the last +// byte read ends up most significant, which is why `-tx` on 61 00 62 ff 63 0a +// gives `ff620061`. +fn word(data []u8, from int, size int) u64 { + mut at := from + mut v := u64(0) + mut shift := u32(0) + for _ in 0 .. size { + if at < data.len { + v |= u64(data[at]) << shift + at++ + } + shift += 8 + } + return v +} + +fn escape_char(b u8) string { + match b { + 0x00 { return '\\0' } + 0x07 { return '\\a' } + 0x08 { return '\\b' } + 0x09 { return '\\t' } + 0x0a { return '\\n' } + 0x0b { return '\\v' } + 0x0c { return '\\f' } + 0x0d { return '\\r' } + else {} + } + if b < 0x20 || b >= 0x7f { + return base_str(u64(b), 8, 3) + } + // `[b].bytestr()` is the conversion that yields the character; `b.str()` gives "97" + // and `[b].str()` gives "[97]". + return [b].bytestr() +} + +// GNU's `-a` names the C0 range and `del`; printable bytes print as themselves. +fn control_name(b u8) string { + if b < 0x20 { + return control_names[b] + } + if b >= 0x7f { + return 'del' + } + return [b].bytestr() +} + +// Right align with spaces, which is what the two character formats need: `-tc` +// prints ` a` beside ` \0`, and `-td1` prints ` 97` beside ` -1`. +fn lpad(s string, width int) string { + mut out := s + for out.len < width { + out = ' ' + out + } + return out +} + +// Two different widths, and conflating them is what made `-tx1` print eight digit +// bytes. This one is the zero pad on the digits themselves; `unit_column` is how +// wide the field is set in the line. All figures measured on 61 00 62 ff 63 0a. +fn zero_pad_to(f Fmt) int { + return match f.kind { + 'a', 'c' { 0 } + // Octal, hex and `-b` zero pad. Decimal does not: `-td2` prints ` 97` and + // `-tu2` prints ` 65378`, so the digits go through untouched and the column + // does the spacing. Measured on 61 00 62 ff 63 0a. + 'b' { 3 } + // A machine word prints at its own length: `-to` gives `37730400141` in + // eleven columns, which is fewer than twelve would be, so the pad is not + // simply three per byte. Measured on 61 00 62 ff 63 0a. + 'o' { + if f.has_wide { 11 } else { 3 * f.size } + } + 'd', 'u' { 0 } + else { 2 * f.size } + } +} + +// The column a field is set in. A sized format is as wide as the value it can +// print, since nothing is wider: `-tx1` two, `-tx2` four, `-to2` six, `-td2` five. +// A machine word unit is wider, because the sign and the bytes past the end can +// push it out: `-td` sets eleven so `-10354591` and `2659` line up, and `-tx` sets +// eight. `-a` and `-c` are three. Measured on 61 00 62 ff 63 0a. +fn unit_column(f Fmt) int { + return match f.kind { + 'a', 'c' { 3 } + 'b' { 3 } + 'o' { + if f.has_wide { 11 } else { 3 * f.size } + } + 'd', 'u' { + if f.has_wide { 11 } else { 5 } + } + else { + if f.has_wide { 8 } else { 2 * f.size } + } + } +} + +fn unit_text(f Fmt, data []u8, pos int) string { + pad := zero_pad_to(f) + match f.kind { + 'a' { return control_name(data[pos]) } + 'c' { return escape_char(data[pos]) } + 'b' { return base_str(u64(data[pos]), 8, pad) } + 'o' { return base_str(word(data, pos, f.size), 8, pad) } + 'd' { return signed_text(f, data, pos, pad) } + 'u' { return base_str(word(data, pos, f.size), 10, pad) } + else { return base_str(word(data, pos, f.size), 16, pad) } + } +} + +fn offset_text(off int, base string) string { + return match base { + 'd' { base_str(u64(off), 10, 7) } + 'x' { base_str(u64(off), 16, 6) } + 'n' { '' } + else { base_str(u64(off), 8, 7) } + } +} + +// `u8.str()` is the decimal code, not the character, so a format letter must be +// spelled out rather than converted from its byte. + +// `-tx2`, `-t o2`, `-tac`. A digit sets the unit size for the letters before it, +// except for the three formats that are only ever one byte wide. +fn parse_spec(spec string) ![]Fmt { + mut fmts := []Fmt{} + for ch in spec { + match ch { + `0`...`9` { + if fmts.len > 0 { + last := fmts.len - 1 + n := int(ch - `0`) + if fmts[last].has_wide { + // A bare `o`, `d`, `u` and `x` are machine words until a digit narrows + // them: `-tx` is 4 on this host, `-tx2` is 2. The word flag has to be + // cleared as well as the size, because it is what widens the column: + // `-td2` is five columns wide and `-td` is eleven. + fmts[last].size = n + fmts[last].has_wide = false + } else if n == 1 || n == 2 || n == 4 { + fmts[last].size = n + } else { + return error("invalid type string '${spec}'") + } + } + } + `a` { + fmts << Fmt{ + kind: 'a' + size: 1 + } + } + `b` { + return error("invalid character '${[ch].bytestr()}' in type string '${spec}'") + } + `c` { + fmts << Fmt{ + kind: 'c' + size: 1 + } + } + `o` { + // A bare `o` reads a machine word, like a bare `x`. + fmts << Fmt{ + kind: 'o' + size: 4 + has_wide: true + } + } + `d` { + fmts << Fmt{ + kind: 'd' + size: 4 + has_wide: true + signed: true + } + } + `x` { + // A bare `x` reads a machine word: 4 bytes on this host. + fmts << Fmt{ + kind: 'x' + size: 4 + has_wide: true + } + } + `u` { + fmts << Fmt{ + kind: 'u' + size: 4 + has_wide: true + } + } + else { + return error("invalid character '${[ch].bytestr()}' in type string '${spec}'") + } + } + } + if fmts.len == 0 { + return error('no type specifier given') + } + return fmts +} + +// `upto` is the absolute offset the dump stops at, or -1 for the whole stream, so +// that `-N` cuts mid line: `-N2 -j1` ends after one unit at offset 3, not at the +// end of the sixteen byte line. +// Fields arrive the right width for every format except the signed decimal, where +// the value's own padding cannot know whether the sign will be there. So `-td1` +// pads its digits to five and this puts the sign in front, making `-1` six wide, +// which is what GNU does: measured ` 97` and ` -1` beside each other. +// `-a` and `-c` right align in three columns, so ` a` sits beside ` \0`, and the +// numeric ones are already the right width from their zero padding. Only the +// decimal formats need a second pass, because a signed value is one wider than +// its unsigned column and `-td1` prints ` 97` beside ` -1`. Measured. +// Every row of a multi format line as one string, so the `*` collapse can compare +// the line as a whole rather than one format's half of it. +fn text_of(passes [][]string, fmts []Fmt) string { + mut out := []string{cap: passes.len} + for pass, row in passes { + out << join_fields(row, fmts[pass]) + } + return out.join('|') +} + +fn join_fields(fields []string, f Fmt) string { + // Only the two character formats are space padded. `-b` zero pads like the + // numeric ones, which is what keeps `-b` giving `000` where `-tb` would have + // given ` 0`. Measured. + if f.kind == 'a' || f.kind == 'c' { + mut out := []string{cap: fields.len} + for x in fields { + out << lpad(x, 3) + } + return out.join(' ') + } + if f.kind == 'd' || f.kind == 'u' { + col := unit_column(f) + mut out := []string{cap: fields.len} + for x in fields { + out << lpad(x, col) + } + return out.join(' ') + } + return fields.join(' ') +} + +fn (mut p Printer) write(data []u8, base_off int, upto int, skip int) { + base := p.base + // `base_off` is where this part starts in the stream and `skip` is how much of it + // was already consumed by `-j`, so the offset the dump ends at is + // `base_off + skip + bytes of this part`. Measured: `-j2` on a six byte file ends + // at `0000006`, not at the eight the part's own length would give. + mut stop := data.len + if upto >= 0 { + stop = upto - base_off + if stop > data.len { + stop = data.len + } + } + for start := 0; start < stop; start += bytes_per_line { + end := if start + bytes_per_line < stop { + start + bytes_per_line + } else { + stop + } + // Filled once per format over the whole line, so `-tac` gets six fields rather + // than three: each format walks the line by itself and the results are kept + // in format order. + mut passes := [][]string{cap: p.fmts.len} + for f in p.fmts { + mut row := []string{cap: (end - start) / f.size} + for pos := start; pos < end; pos += f.size { + row << unit_text(f, data, pos) + } + passes << row + } + // One line per format, not one line of everything: `-tac` prints the `-a` + // pass on the first line and the `-c` pass on the second, measured. So each + // format gets its own set of columns rather than sharing the line, and the + // line is filled once per format so `-tac` gets six fields rather than three. + // The `*` collapse holds a run across the whole multi format group: `-tac` over 40 + // copies of 0x41 prints sixteen rows of each format, then one `*`, then the + // remaining eight of each. So a repeated group prints nothing at all rather + // than half of it. Measured. + // One row per format, so `-tac` prints two lines per sixteen bytes, and the `*` + // collapse compares the whole group: a repeat prints nothing at all. The first + // row carries the address and the rest are indented to where it would have been. + head := offset_text(base_off + start, p.base) + indent := if head == '' { ' ' } else { ' '.repeat(head.len + 1) } + joined := text_of(passes, p.fmts) + if !p.verbose && joined == p.prev { + if !p.starred { + println('*') + p.starred = true + } + continue + } + p.starred = false + p.prev = joined + for pass, row in passes { + println((if pass == 0 { head + ' ' } else { indent }) + + join_fields(row, p.fmts[pass])) + } + } + // GNU closes a dump with a line holding only the final offset, unless the + // address was suppressed: measured on 6 bytes and on `-N4`, both of which end + // in a bare offset. With `-An` it prints nothing extra. + // The closing offset line, which carries the offset the dump ended at, not the one + // it started at: `-j 40` on a 40 byte file prints `0000050` alone. With no + // address requested there is no closing line either. Measured. + // The closing offset is where the dump ended in the stream, not where this part + // ended: `-j2` on a six byte file prints `0000006` because two bytes were + // skipped and four dumped, and `-j 40` on a 40 byte file prints `0000050`. + // Measured. + if base != 'n' { + println(offset_text(skip + stop, base)) + } +} + +// A scalar `mut` parameter is not allowed in V, so the three settings travel in +// one mutable struct. +struct Settings { +mut: + skip int + limit int + base string +} + +// Applied the moment the value is read, because `-j1 -N2` names two different +// options and a single slot for the pending value lost whichever came first. +fn apply_option(opt u8, value string, mut s Settings) !void { + match opt { + `N` { + if !numeric_run(value) { + return error("invalid -N argument '${value}'") + } + s.limit = value.int() + } + `j` { + if !numeric_run(value) { + return error("invalid -j argument '${value}'") + } + s.skip = value.int() + } + `A` { + if value.len != 1 || value[0] !in [`o`, `d`, `x`, `n`] { + return error("invalid -A argument '${value}'") + } + s.base = value + } + else {} + } +} + +fn numeric_run(s string) bool { + if s.len == 0 { + return false + } + for ch in s { + if !ch.is_digit() { + return false + } + } + return true +} + +const version_text = 'od (V coreutils) ${common.version}' + +// The arguments are read by hand rather than through the shared flag parser. The +// parser reads `-tx2` as a clustered short option group and swallows the type +// letters, and it panics on `-j` outright because no option is registered for it. +// Handing it the command line is what makes it print `--help` and `--version`, so +// those two are answered here and the parser is not used at all. +fn main() { + if os.args[1..].any(it == '--version') { + println(version_text) + return + } + if os.args[1..].any(it == '--help') { + println('Usage: od [OPTION]... [FILE]...') + println('Print the contents of FILE in octal and other formats.') + println('') + println(common.coreutils_footer()) + return + } + + mut set := Settings{ + limit: -1 + base: 'o' + } + mut verbose := false + + mut fmts := []Fmt{} + mut files := []string{} + mut literal := false + mut takes_value := u8(0) + for p in os.args[1..] { + // The value of a separated option (`-N 4`) is consumed here; an attached + // one (`-N4`) is recognised further down and applied in the same pass. + if takes_value != 0 { + apply_option(takes_value, p, mut set) or { + eprintln('od: ' + err.msg()) + exit(1) + } + takes_value = 0 + continue + } + if literal || !p.starts_with('-') || p.len < 2 { + files << p + continue + } + if p == '--' { + literal = true + continue + } + // `-t` is the only short option GNU takes with a separate argument, and + // it is read by hand; everything else is a type letter. `-x` and `-b` are + // bare letters, so they need no handling here. + // `-N`, `-j` and `-A` take their value either attached (`-N4`) or as the next + // word (`-N 4`), and GNU also accepts `-t` with the letters split off. The + // flag parser only copes with the separated form, so every word is read + // here and the parser is never asked about them. + if p == '-N' || p == '-j' || p == '-A' { + takes_value = p[1] + continue + } + if p.len > 1 && p[1] in [`N`, `j`, `A`] { + apply_option(p[1], p[2..], mut set) or { + eprintln('od: ' + err.msg()) + exit(1) + } + continue + } + if p == '-v' { + verbose = true + continue + } + mut letters := p[1..] + // `-b` is a format in its own right, but GNU rejects `b` inside a `-t` string, so + // the two spellings are not the same thing. Measured: `-b` prints and `-tb` exits 1. + if letters[0] == `b` { + fmts << Fmt{ + kind: 'b' + size: 1 + } + continue + } + if letters[0] == `t` { + letters = letters[1..] + } + for f in parse_spec(letters) or { + eprintln('od: ' + err.msg()) + exit(1) + } { + fmts << f + } + } + if set.skip < 0 || set.limit < -1 { + eprintln('od: invalid argument') + exit(1) + } + if fmts.len == 0 { + // GNU's default, measured: two byte units printed as six digit octal. + fmts = [Fmt{ + kind: 'o' + size: 2 + }] + } + + mut printer := Printer{ + base: set.base + verbose: verbose + fmts: fmts + } + mut off := 0 + mut left := set.limit + mut skip_left := set.skip + for path in files { + mut part := []u8{} + if path == '' { + part = io.read_all(io.ReadAllConfig{}) or { []u8{} } + } else { + if !os.exists(path) { + eprintln('od: ${path}: No such file or directory') + exit(1) + } + // `os.read_file` is the text form in this V and translates CRLF on Windows, + // which would corrupt the very bytes od exists to show. + part = os.read_bytes(path)! + } + if skip_left > 0 { + // A skip exactly equal to the length is not an error: `-j 40` on a 40 + // byte file prints the closing offset `0000050` and exits 0. Only a skip + // past the end fails, which measured on `-j 40` against a six byte file + // gives `cannot skip past end of combined input`. + if skip_left > part.len { + eprintln('od: cannot skip past end of combined input') + exit(1) + } + // `part[part.len..]` is out of range even though the offset is legal, + // which panicked on an exact skip, so the slice is guarded here. + if skip_left == part.len { + part = [] + } else { + part = part[skip_left..] + } + off += skip_left + skip_left = 0 + } + mut stop := -1 + if left >= 0 { + stop = off + left + take := stop - off + if take < part.len { + part = part[..take] + } + left -= part.len + } + printer.write(part, off, stop, set.skip) + off += part.len + if left == 0 { + break + } + } +} diff --git a/src/od/od_test.v b/src/od/od_test.v new file mode 100644 index 00000000..1270540c --- /dev/null +++ b/src/od/od_test.v @@ -0,0 +1,222 @@ +// Every expectation here was measured against GNU 9.4's od on these bytes, and a +// differential run confirmed each one byte for byte. What is not covered: `-t f` +// (float), which GNU accepts and this does not, and stdin, whose binary mode the +// Windows build of this V does not provide. +module main + +import common.testing +import os + +const rig = testing.prepare_rig(util: 'od') + +// 61 00 62 ff 63 0a: a printable byte, a NUL, a high byte, a printable and a +// newline, chosen so every format has something to say about every byte. +const sample_path = 'od_sample.bin' + +// 40 copies of 0x41: more than one line, so the sixteen byte wrap and the `*` +// collapse are both exercised. +const repeats_path = 'od_repeats.bin' + +// 17 copies of 0xff: a length that ends a line mid unit. +const odd_path = 'od_odd.bin' + +// The rig moves the working directory to its own temporary folder before any test +// runs, so the fixtures are named relative to wherever that is. +fn testsuite_begin() { + rig.assert_platform_util() + // `os.write_file` takes text in this V, which would append a newline and turn a + // NUL into a space. The bytes have to go in whole, so the file is built by hand. + os.write_file(sample_path, bytes_of([0x61, 0x00, 0x62, 0xff, 0x63, 0x0a]))! + os.write_file(repeats_path, bytes_of(repeated(0x41, 40)))! + os.write_file(odd_path, bytes_of(repeated(0xff, 17)))! +} + +// `os.write_file` in this V is the text form, so a byte list cannot go through it +// directly: a NUL would be written as a space and the file would gain a trailing +// newline, which is exactly the sort of corruption od exists to reveal. +fn bytes_of(values []int) string { + mut s := []u8{cap: values.len} + for v in values { + s << u8(v) + } + return s.bytestr() +} + +fn repeated(byte int, count int) []int { + mut out := []int{cap: count} + for _ in 0 .. count { + out << byte + } + return out +} + +fn testsuite_end() { + os.rm(sample_path)! + os.rm(repeats_path)! + os.rm(odd_path)! +} + +fn test_help_and_version() { + rig.assert_help_and_version_options_work() +} + +// GNU quotes the type string with U+2018 and U+2019, and prefixes its messages with +// its own argv[0]. The text is checked rather than left out, so a wrong reason still +// fails: only the program name and the quotes differ. +fn error_text_of(args string) string { + res := os.execute('${rig.executable_under_test} ${args}') + assert res.exit_code == 1 + return res.output.split_into_lines()[0] +} + +// GNU accepts a bare `-b` but rejects `b` inside a `-t` string, so `-b` works and +// `-tb` does not. Measured. +fn test_b_works_on_its_own() { + assert first_line('-b ${sample_path}') == '0000000 141 000 142 377 143 012' +} + +fn test_b_rejects_inside_a_type_string() { + assert error_text_of('-tb ${sample_path}').contains("invalid character 'b' in type string") +} + +fn test_skip_past_the_end_fails() { + assert error_text_of('-j 40 ${sample_path}') == + 'od: cannot skip past end of combined input' +} + +fn test_unknown_type_letter_fails() { + assert error_text_of('-tq ${sample_path}').contains("invalid character 'q' in type string") +} + +// One line is enough to pin a format's columns; the rest of the dump is covered by +// the cases that assert the whole output. +fn first_line(args string) string { + return all_lines(args)[0] +} + +fn all_lines(args string) []string { + res := os.execute('${rig.executable_under_test} ${args}') + // The failure has to say which invocation failed, because the assertion is in + // here and not at the call site. + assert res.exit_code == 0, 'od ${args}: exit ${res.exit_code}, output: ${res.output}' + return res.output.split_into_lines() +} + +fn test_default_is_two_byte_octal() { + assert first_line(sample_path) == '0000000 000141 177542 005143' +} + +fn test_hex_one_byte() { + assert first_line('-tx1 ${sample_path}') == '0000000 61 00 62 ff 63 0a' +} + +fn test_hex_two_bytes_is_little_endian() { + assert first_line('-tx2 ${sample_path}') == '0000000 0061 ff62 0a63' +} + +fn test_bare_x_is_a_machine_word() { + assert first_line('-tx ${sample_path}') == '0000000 ff620061 00000a63' +} + +fn test_octal_one_byte() { + assert first_line('-to1 ${sample_path}') == '0000000 141 000 142 377 143 012' +} + +fn test_char_names() { + assert first_line('-tc ${sample_path}') == '0000000 a \\0 b 377 c \\n' +} + +fn test_control_names() { + assert first_line('-ta ${sample_path}') == '0000000 a nul b del c nl' +} + +fn test_two_character_formats_print_one_line_each() { + assert all_lines('-tac ${sample_path}') == ['0000000 a nul b del c nl', + ' a \\0 b 377 c \\n', '0000006'] +} + +fn test_signed_decimal_is_a_machine_word() { + assert first_line('-td ${sample_path}') == '0000000 -10354591 2659' +} + +fn test_signed_decimal_two_bytes_is_positive() { + // 0x0061 loads as 97 where the four byte load of the same bytes is negative: + // the sign is the loaded word's own top bit, not the file's. Two bytes are set + // in four columns, because a signed value is one wider than its digits: `-158` + // fills its column and ` 97` is padded to match. + assert first_line('-td2 ${sample_path}') == '0000000 97 -158 2659' +} + +fn test_signed_decimal_one_byte_is_signed() { + assert first_line('-td1 ${sample_path}') == '0000000 97 0 98 -1 99 10' +} + +fn test_unsigned_decimal() { + // Unsigned decimal is the same width as signed, so `2659` is padded like the + // others rather than being left at its natural length. + assert first_line('-tu2 ${sample_path}') == '0000000 97 65378 2659' +} + +fn test_skip_and_limit() { + assert all_lines('-tx1 -N2 -j1 ${sample_path}') == ['0000001 00 62', '0000003'] +} + +fn test_limit_alone() { + assert all_lines('-N4 ${sample_path}') == ['0000000 000141 177542', '0000004'] +} + +fn test_skip_alone() { + assert all_lines('-j2 ${sample_path}') == ['0000002 177542 005143', '0000006'] +} + +fn test_address_radix_decimal() { + assert first_line('-Ad -tx1 -N8 ${repeats_path}') == '0000000 41 41 41 41 41 41 41 41' +} + +fn test_address_radix_hex() { + assert first_line('-Ax -tx1 -N8 ${repeats_path}') == '000000 41 41 41 41 41 41 41 41' +} + +fn test_address_suppressed_keeps_the_leading_space() { + // GNU prints the space the offset column would have taken, so the fields still + // line up under the addressed form. + assert all_lines('-An ${sample_path}') == [' 000141 177542 005143'] +} + +fn test_closing_offset_ends_the_dump() { + lines := all_lines(sample_path) + assert lines[lines.len - 1] == '0000006' +} + +fn test_repeated_lines_collapse() { + assert all_lines('-tx1 ${repeats_path}') == + ['0000000 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41', '*', + '0000040 41 41 41 41 41 41 41 41', '0000050'] +} + +fn test_verbose_prints_every_line() { + lines := all_lines('-tx1 -v ${repeats_path}') + assert !lines.join('\n').contains('*') + assert lines.len == 4 +} + +fn test_seventeen_bytes_ends_mid_word() { + // The last unit has one byte and its top bytes fell off the end, which are read + // as zero: `000000ff` rather than `ffffffff`. + assert all_lines('-tx ${odd_path}') == + ['0000000 ffffffff ffffffff ffffffff ffffffff', '0000020 000000ff', '0000021'] +} + +fn test_two_character_formats_collapse_as_a_group() { + // The `*` compares the whole group, so a repeat prints neither row: two rows, + // then the star, then two rows, then the closing offset. + lines := all_lines('-tac ${repeats_path}') + assert lines == [ + '0000000 A A A A A A A A A A A A A A A A', + ' A A A A A A A A A A A A A A A A', + '*', + '0000040 A A A A A A A A', + ' A A A A A A A A', + '0000050', + ] +} From 98f9139ab50df457e57aefdbf1b1e10dc6e81f47 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 6 Oct 2026 02:03:03 +0330 Subject: [PATCH 3/4] build.vsh: run the compiler with os.exec_or_panic execute_or_panic takes a command string and is deprecated, which -W turns into an error, so the build script was failing the warning check that this repository's own CI runs. os.exec_or_panic takes an argument array, so the arguments are collected here instead of concatenated into a string. This also removes the quoting from the output path: each argument is passed as it is, so a directory with a space in its name works. --- build.vsh | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/build.vsh b/build.vsh index 3112ad46..9308ea45 100755 --- a/build.vsh +++ b/build.vsh @@ -117,13 +117,14 @@ for dir in dirs_to_compile { // Released _ := <-ch } - mut final_args := '-Wimpure-v' - for arg in vargs { - final_args += ' ' + arg - } - cmd := @VEXE + ' ${final_args} -o "${curdir}/bin/${dir}" "./${dir}"' + // `os.exec` takes an argument array rather than a command string, so the + // word splitting happens here instead of in a shell. Each argument is passed + // as it is, which is why a path with a space in it needs no quoting. + mut cmd := [@VEXE, '-Wimpure-v'] + cmd << vargs + cmd << ['-o', '${curdir}/bin/${dir}', './${dir}'] sw := time.new_stopwatch() - execute_or_panic(cmd) + os.exec_or_panic(cmd) print_ch <- 'compiling ${dir:-20s}... took ${sw.elapsed().milliseconds()}ms .\n' }() } From e8e7c08a98595150cf93fc1433e0a77c1e2be2f5 Mon Sep 17 00:00:00 2001 From: metif12 Date: Tue, 6 Oct 2026 02:03:16 +0330 Subject: [PATCH 4/4] numfmt: drop the stale number_str that stops the build Two number_str definitions in one file: the one from main prints a large value without exponent notation, and the copy added earlier in this branch shadowed it, so the build failed with a redefinition. The stale copy is removed. Also renames the local round to round_method, because it shadows the round function and -W reports that as an error too, and removes the unused print_space, which -W reports as a notice. --- README.md | 2 +- src/numfmt/numfmt.v | 16 ---------------- src/numfmt/options.v | 10 ++++++---- 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c3c04733..8d9ae841 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | nohup | Run a command immune to hangups | ✓ | | ✓ | nproc | Print the number of available processors | ✓ | | ✓ | numfmt | Reformat numbers | ✓ | -| | od | Write files in octal or other formats | ✓ | +| ✓ | od | Write files in octal or other formats | ✓ | | ✓ | paste | Merge lines of files | ✓ | | | pathchk | Check file name validity and portability | ✓ | | | pinky | Lightweight finger | | diff --git a/src/numfmt/numfmt.v b/src/numfmt/numfmt.v index 0e707ebf..8024dbd6 100644 --- a/src/numfmt/numfmt.v +++ b/src/numfmt/numfmt.v @@ -185,18 +185,6 @@ fn readable_size(size f64, unit Unit, rounding string) !string { return number_str(size) } -// number_str renders n the way GNU numfmt does: plain decimal digits, never -// exponent notation, and no trailing ".0" on an integral value. f64.str() -// switches to scientific notation above a million and always keeps the -// fractional part, which would turn 2000000 into "2e+06" and 2000 into "2000.0". -fn number_str(n f64) string { - s := n.strlong() - if s.ends_with('.0') { - return s[..s.len - 2] - } - return s -} - fn commaize(num f64) string { str := strconv.f64_to_str_l(num) n, _ := str.split_once('.') or { return 'oops' } @@ -270,10 +258,6 @@ fn handle_error(msg string, mut app App, options Options) string { return '' } -fn print_space() { - print_character(` `) -} - @[noreturn] fn exit_message(msg string, exit_code int) { eprintln(msg) diff --git a/src/numfmt/options.v b/src/numfmt/options.v index c6268fd6..b81995ab 100644 --- a/src/numfmt/options.v +++ b/src/numfmt/options.v @@ -54,7 +54,9 @@ fn get_options() Options { 'right-align; negative N will left-align; padding is\n${flag.space}' + 'ignored if the output is wider than N; the default is to\n${flag.space}' + 'automatically pad if a whitespace is found') - round := fp.string('round', 0, 'from-zero', + // Named `round_method`, because a local `round` shadows the `round` function + // and `-W` turns that notice into an error. + round_method := fp.string('round', 0, 'from-zero', 'use METHOD for rounding when scaling; METHOD can be:\n${flag.space}' + 'up, down, from-zero (default), towards-zero, nearest') suffix := fp.string('suffix', 0, '', @@ -122,8 +124,8 @@ fn get_options() Options { exit_error('unrecognized scale option ${to}') } - if round !in rounds { - exit_error('unrecognized rounding option ${round}') + if round_method !in rounds { + exit_error('unrecognized rounding option ${round_method}') } return Options{ @@ -136,7 +138,7 @@ fn get_options() Options { numbers: numbers padding: padding pformat: pformat - round: round + round: round_method suffix: suffix to: to to_unit: to_unit