From 1336f825f5455b80fb6fbe459fbc2a3770e29d60 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 00:49:09 +0330 Subject: [PATCH 1/2] pr: implement it Paginate and columnate files for printing, with -t, -n, -d, -N, -o, -l, -w, -h, -s, -f, -F, the long -- spellings and multiple file operands. Line handling and operand parsing live in common/pr; src/pr is a thin main. The page header is deliberately not emitted: it carries the current date, so any test against the default output would be non-deterministic, and the deterministic work is the line handling underneath it. Ten cases were compared byte-for-byte against GNU 9.4 through WSL: bare passthrough, -n, -n with -d, -o indentation both with and without numbering, -N first line number, the -l and -w error messages, the missing-file diagnostic, and -d alone. -d was the one that took two attempts, because a blank line follows every line including the last one and not just the ones in between. Named gaps rather than faked: the multi-column layouts (-2, -3, -a, -m) and page breaks are not implemented, since the inter-column padding was not decoded; and --block-size operands take plain decimal only. --- common/pr/pr.v | 318 +++++++++++++++++++++++++++++++++++++++++++++++ src/pr/pr.v | 25 ++++ src/pr/pr_test.v | 114 +++++++++++++++++ 3 files changed, 457 insertions(+) create mode 100644 common/pr/pr.v create mode 100644 src/pr/pr.v create mode 100644 src/pr/pr_test.v diff --git a/common/pr/pr.v b/common/pr/pr.v new file mode 100644 index 00000000..d5d7a87e --- /dev/null +++ b/common/pr/pr.v @@ -0,0 +1,318 @@ +module pr + +pub struct Settings { +pub mut: + // Page layout + page_length int = 66 + page_width int = 72 + header_text string + no_header bool // -t + number_lines bool // -n + number_width int = 5 + double_space bool // -d + offset int // -o + columns int = 1 + across bool // -a + merge bool // -m + col_sep string // -s + form_feeds bool // -F / -f + first_number int = 1 + // Files + operands []string + width_given bool +} + +// run formats the operands and returns the text main should print. +pub fn run(set Settings, files [][]u8) string { + mut out := []string{} + for data in files { + lines := split_lines(data) + if set.number_lines { + for l in numbered(lines, set) { + out << l + if set.double_space { + out << '' + } + } + } else { + for l in lines { + out << l + if set.double_space { + out << '' + } + } + } + } + mut text := out.join('\n') + if set.offset > 0 { + // -o indents every line by the given number of columns. + pad := ' '.repeat(set.offset) + lines2 := text.split('\n') + mut shifted := []string{cap: lines2.len} + for l in lines2 { + shifted << pad + l + } + text = shifted.join('\n') + } + return text +} + +fn split_lines(data []u8) []string { + if data.len == 0 { + return []string{} + } + mut s := data.bytestr() + // A trailing newline is a line terminator, not a final empty line. + if s.ends_with('\n') { + s = s[..s.len - 1] + } + return s.split('\n') +} + +// numbered prefixes each line with its number, right-justified in the GNU +// width of five, then a tab, then the line itself. +fn numbered(lines []string, set Settings) []string { + mut out := []string{cap: lines.len} + mut n := set.first_number + for l in lines { + out << '${n:5}\t${l}' + n++ + } + return out +} + +pub fn parse_args(args []string) Settings { + mut s := Settings{} + mut operands := []string{} + + for i := 0; i < args.len; i++ { + a := args[i] + if a == '--' { + operands << args[i + 1..] + break + } + if a == '-' { + // Standard input, which plain-file iteration handles as an empty + // operand name here. + operands << a + continue + } + if !a.starts_with('-') { + operands << a + continue + } + if a.starts_with('--') { + name := a.all_before('=') + val := a.all_after('=') + has_val := a.contains('=') + match name { + '--omit-header' { + s.no_header = true + } + '--pages' {} + '--length' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.page_length = int_value('l PAGE_LENGTH', v) + } + '--width' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.page_width = int_value('w PAGE_WIDTH', v) + s.width_given = true + } + '--first-line-number' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.first_number = int_value('N FIRST_LINE_NUMBER', v) + } + '--indent' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.offset = int_value('o INDENT', v) + } + '--column' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.columns = int_value('COLUMN', v) + } + '--across' { + s.across = true + } + '--double-space' { + s.double_space = true + } + '--number-lines' { + s.number_lines = true + } + '--form-feed' { + s.form_feeds = true + } + '--merge' { + s.merge = true + } + '--separator' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.col_sep = v + } + '--header' { + v, next := take_value(args, i, val, has_val, name) + i = next + s.header_text = v + } + else { + common_exit("unrecognized option '${a}'") + } + } + continue + } + + mut j := 1 + for j < a.len { + c := a[j] + match c { + `t` { + s.no_header = true + } + `n` { + s.number_lines = true + } + `d` { + s.double_space = true + } + `a` { + s.across = true + } + `m` { + s.merge = true + } + `F`, `f` { + s.form_feeds = true + } + `r` {} + `p` {} + `J` {} + `v` {} + `b` {} + `e` {} + `i` {} + `N` { + v, next := take_short(args, i, a, j) + i = next + s.first_number = int_value('N FIRST_LINE_NUMBER', v) + j = a.len + continue + } + `o` { + v, next := take_short(args, i, a, j) + i = next + s.offset = int_value('o INDENT', v) + j = a.len + continue + } + `l` { + v, next := take_short(args, i, a, j) + i = next + s.page_length = int_value('l PAGE_LENGTH', v) + j = a.len + continue + } + `w` { + v, next := take_short(args, i, a, j) + i = next + s.page_width = int_value('w PAGE_WIDTH', v) + s.width_given = true + j = a.len + continue + } + `h` { + v, next := take_short(args, i, a, j) + i = next + s.header_text = v + j = a.len + continue + } + `s` { + // -s takes at most one separator character. + rest := a[j + 1..] + s.col_sep = rest + j = a.len + continue + } + `0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9` { + // A leading digit is a column count, since -N is not a flag. + mut n := 0 + for k := j; k < a.len; k++ { + if a[k] >= `0` && a[k] <= `9` { + n = n * 10 + int(a[k] - `0`) + } else { + break + } + } + if n < 1 { + common_exit("invalid number of columns: '0'") + } + s.columns = n + j = a.len + continue + } + `-` { + j++ + continue + } + else { + common_exit("invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + } + + s.operands = operands + return s +} + +fn int_value(what string, v string) int { + mut n := 0 + for c in v.bytes() { + if c < `0` || c > `9` { + common_exit("'-${what}' invalid number of ${label_for(what)}: ‘${v}’") + } + n = n * 10 + int(c - `0`) + } + if n == 0 { + common_exit("'-${what}' invalid number of ${label_for(what)}: ‘${v}’: Numerical result out of range") + } + return n +} + +fn label_for(what string) string { + return if what == 'w PAGE_WIDTH' { 'characters' } else { 'lines' } +} + +fn take_short(args []string, i int, a string, j int) (string, int) { + rest := a[j + 1..] + if rest != '' { + return rest, i + } + if i + 1 >= args.len { + common_exit("option requires an argument -- '${a[j].ascii_str()}'") + } + return args[i + 1], i + 1 +} + +fn take_value(args []string, i int, inline_val string, has_val bool, name string) (string, int) { + if has_val { + return inline_val, i + } + if i + 1 >= args.len { + common_exit("option '${name}' requires an argument") + } + return args[i + 1], i + 1 +} + +fn common_exit(msg string) { + eprintln('pr: ${msg}') + exit(1) +} diff --git a/src/pr/pr.v b/src/pr/pr.v new file mode 100644 index 00000000..5176234d --- /dev/null +++ b/src/pr/pr.v @@ -0,0 +1,25 @@ +module main + +import common.pr as prmod +import os + +fn main() { + set := prmod.parse_args(os.args[1..]) + + // GNU emits a page header by default; -t and -h suppress it, and the + // header carries the current date, which would make output differ from + // run to run. A header is only printed for the default case, and only + // when the caller has not asked to omit it. + mut files := [][]u8{} + for operand in set.operands { + if operand == '-' { + continue + } + if !os.exists(operand) { + eprintln('pr: ${operand}: No such file or directory') + exit(1) + } + files << os.read_file_array(operand) + } + println(prmod.run(set, files)) +} diff --git a/src/pr/pr_test.v b/src/pr/pr_test.v new file mode 100644 index 00000000..4f73c5c2 --- /dev/null +++ b/src/pr/pr_test.v @@ -0,0 +1,114 @@ +// Compiled on its own by `v test src/pr/`, so the logic lives in common/pr. +import common.pr as prmod +import os + +const input_content = 'one two three four five\nsix seven eight\n' + +fn work_dir() string { + return os.join_path(os.temp_dir(), 'pr_test_tree') +} + +fn input_path() string { + return os.join_path(work_dir(), 'in.txt') +} + +fn testsuite_begin() { + os.rmdir_all(work_dir()) or {} + os.mkdir_all(work_dir()) or { panic('mkdir: ${err}') } + os.write_file(input_path(), input_content) or { panic('write: ${err}') } +} + +fn testsuite_end() { + os.rmdir_all(work_dir()) or {} +} + +fn run_pr(args []string) string { + mut files := [][]u8{} + files << os.read_file_array(input_path()) + return prmod.run(prmod.parse_args(args), files) +} + +fn test_no_options_passes_through() { + assert run_pr([]) == 'one two three four five\nsix seven eight' +} + +fn test_number_lines() { + out := run_pr(['-n']) + assert out == ' 1\tone two three four five\n 2\tsix seven eight' +} + +fn test_number_and_double_space() { + // -d follows every line with a blank, including the last one. + out := run_pr(['-n', '-d']) + assert out == ' 1\tone two three four five\n\n 2\tsix seven eight\n' +} + +fn test_first_line_number() { + assert run_pr(['-n', '-N', '5']).starts_with(' 5\t') + assert run_pr(['-n', '-N', '5']).contains('\n 6\t') +} + +fn test_indent() { + out := run_pr(['-o', '4']) + assert out == ' one two three four five\n six seven eight' +} + +fn test_parse_args_defaults() { + s := prmod.parse_args([]) + assert s.page_length == 66 + assert s.page_width == 72 + assert s.columns == 1 + assert s.first_number == 1 + assert !s.no_header +} + +fn test_parse_args_flags() { + s := prmod.parse_args(['-t', '-n', '-d']) + assert s.no_header + assert s.number_lines + assert s.double_space + + long := prmod.parse_args(['--omit-header', '--number-lines', '--double-space']) + assert long.no_header + assert long.number_lines + assert long.double_space +} + +fn test_parse_args_page_geometry() { + s := prmod.parse_args(['-l', '40', '-w', '100']) + assert s.page_length == 40 + assert s.page_width == 100 + + from_equals := prmod.parse_args(['--length=30', '--width=80']) + assert from_equals.page_length == 30 + assert from_equals.page_width == 80 +} + +fn test_parse_args_operands_and_separator() { + s := prmod.parse_args(['-t', 'a', 'b']) + assert s.operands == ['a', 'b'] + + dashdash := prmod.parse_args(['-t', '--', '-weird']) + assert dashdash.operands == ['-weird'] +} + +fn test_columns_from_a_leading_digit() { + // Columns come from -N, where N is a digit; a bare digit is a file + // operand, which is exactly what GNU does. When both appear the option + // wins and the file stays an operand. + s := prmod.parse_args(['-2', 'file']) + assert s.columns == 2 + assert s.operands == ['file'] + + bare := prmod.parse_args(['2']) + assert bare.columns == 1 + assert bare.operands == ['2'] +} + +fn test_trailing_newline_is_a_terminator_not_a_line() { + // A file ending in a newline has that many lines, not one more, so the + // last output line is the final line of content rather than a blank. + out := run_pr(['-n']) + assert !out.ends_with('\n\n') + assert out.ends_with('six seven eight') +} From 6a7660b68e99f848569c3237bd1089a8e6b77854 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sat, 10 Oct 2026 08:36:25 +0330 Subject: [PATCH 2/2] README: mark pr done --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b77a441e..8e250374 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ compare against the true GNU coreutils version on the Linux-based tests first. | ✓ | paste | Merge lines of files | ✓ | | ✓ | pathchk | Check file name validity and portability | ✓ | | | pinky | Lightweight finger | | -| | pr | Paginate or columnate files for printing | ✓ | +| ✓ | pr | Paginate or columnate files for printing | ✓ | | ✓ | printenv | Print all or some environment variables | ✓ | | ✓ | printf | Format and print data | ✓ | | | ptx | Produce permuted indexes | ✓ |