From 6ee67dc183d0c5d8015bd129396690303f040872 Mon Sep 17 00:00:00 2001 From: metif12 Date: Sun, 11 Oct 2026 11:20:01 +0330 Subject: [PATCH] ptx: implement it Produces a permuted index: one row per keyword occurrence with the surrounding context, laid out so every keyword lands at the same column. The measured layout is the tail right-justified in line_width / 2, then the gap, then the keyword, then one space and the head. The word is a run of letters by default, not letters and digits, which is what GNU does: '42nd' yields the keyword nd with 42 left in the context. Verification was by cross-compiling for Linux and running under WSL, since V emits Linux C from Windows but cannot link it. Three of the behaviours were wrong on the first attempt and the differential caught all three: the keyword was padded, which shifted the head one column right on rows with a short keyword; the head was truncated to the keyword width, which cut 'beta gamma' to 'beta '; and io.read_all stopped early on this platform, which produced a single truncated row instead of the whole index. Two known gaps, documented rather than faked. Truncation of a tail or head that does not fit the line width is not implemented: the marker is emitted but the column it lands in does not match GNU, and I did not decode the rule. And -r, -A, -t, -M, and the -o/-i/-b file filters parse but have no effect. --- common/ptx/ptx.v | 327 +++++++++++++++++++++++++++++++++++++++++++++ src/ptx/main.v | 53 ++++++++ src/ptx/ptx_test.v | 136 +++++++++++++++++++ 3 files changed, 516 insertions(+) create mode 100644 common/ptx/ptx.v create mode 100644 src/ptx/main.v create mode 100644 src/ptx/ptx_test.v diff --git a/common/ptx/ptx.v b/common/ptx/ptx.v new file mode 100644 index 00000000..c150c618 --- /dev/null +++ b/common/ptx/ptx.v @@ -0,0 +1,327 @@ +module ptx + +import common + +const app_name = 'ptx' + +pub struct Settings { +pub mut: + width int = 72 + gap int = 3 + ignore_case bool // -f + references bool // -r + auto_reference bool // -A + truncate_marker string = '/' // -F + word_regexp string // -W + sentence_regexp string // -S + macro_name string // -M + typeset bool // -t / -G + only_file string // -o + ignore_file string // -i + break_file string // -b + operands []string +} + +// Entry is one output row: a keyword and the context on either side. +pub struct Entry { +pub mut: + keyword string + before string + after string +} + +// build_entries turns one text into one entry per keyword occurrence. The +// default keyword is a run of letters, which is GNU's default word regexp: +// measured against GNU 9.4, "hello, world! it's 42nd" produces the keywords +// hello, it, nd, s and world — "42" is context, not a keyword, so digits are +// excluded from the default word. +pub fn build_entries(text string, set Settings) []Entry { + mut entries := []Entry{} + for line in text.split('\n') { + if line.trim_space() == '' { + continue + } + mut i := 0 + for i < line.len { + word, end := word_at(line, i) + if word == '' { + i++ + continue + } + entries << Entry{ + keyword: word + before: rtrim(line[..i], ` `) + after: ltrim(line[end..], ` `) + } + i = end + } + } + return entries +} + +fn word_at(line string, i int) (string, int) { + is_word := fn (c u8) bool { + return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) + } + if !is_word(line[i]) { + return '', i + } + mut end := i + for end < line.len && is_word(line[end]) { + end++ + } + return line[i..end], end +} + +// sort_entries orders the index by keyword, which is the only ordering GNU +// gives the permuted index. +pub fn sort_entries(mut entries []Entry) { + entries.sort_with_compare(fn (a &Entry, b &Entry) int { + if a.keyword < b.keyword { + return -1 + } + if a.keyword > b.keyword { + return 1 + } + return 0 + }) +} + +// render lays the entries out. The measured rule is: +// +// tail right-justified in line_width / 2, then gap spaces, then the keyword +// left-justified to the widest keyword, then the head. +// +// Putting every keyword at the same column is what makes a permuted index +// readable: each row is centred on a different word. +pub fn render(entries []Entry, set Settings) []string { + if entries.len == 0 { + return []string{} + } + mut keyword_w := 0 + for e in entries { + if e.keyword.len > keyword_w { + keyword_w = e.keyword.len + } + } + tail_w := set.width / 2 + + mut rows := []string{cap: entries.len} + for e in entries { + mut tail := e.before + mut head := e.after + if tail.len > tail_w { + // A tail that is too long loses its start, keeping the words + // nearest the keyword, and gains the truncation marker. + mut keep := tail_w - set.truncate_marker.len + if keep < 0 { + keep = 0 + } + tail = tail[tail.len - keep..] + set.truncate_marker + } + // The head takes whatever is left of the line after the tail, the gap + // and the keyword. Truncating it to the keyword width instead would + // cut "beta gamma" to "beta " in the default three-word case. + mut head_w := set.width - tail_w - set.gap - keyword_w - 1 + if head_w < 0 { + head_w = 0 + } + if head.len > head_w { + head = head[..head_w] + } + // The keyword is not padded: GNU puts exactly one space between the + // keyword and its head, so a short keyword does not shift the head + // right. When the head is empty the last word leaves no trailing + // space either. + row := if head == '' { + '${rjust(tail, tail_w)}${' '.repeat(set.gap)}${e.keyword}' + } else { + '${rjust(tail, tail_w)}${' '.repeat(set.gap)}${e.keyword} ${head}' + } + rows << row + } + return rows +} + +fn rjust(s string, w int) string { + if s.len >= w { + return s + } + return ' '.repeat(w - s.len) + s +} + +fn ljust(s string, w int) string { + if s.len >= w { + return s + } + return s + ' '.repeat(w - s.len) +} + +fn rtrim(s string, c u8) string { + mut out := s + for out.len > 0 && out[out.len - 1] == c { + out = out[..out.len - 1] + } + return out +} + +fn ltrim(s string, c u8) string { + mut out := s + for out.len > 0 && out[0] == c { + out = out[1..] + } + 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 == '--help' { + print_help() + exit(0) + } + if a == '--version' { + println('ptx (V coreutils) ${common.version}') + exit(0) + } + if a == '--' { + operands << args[i + 1..] + break + } + if a.starts_with('--width=') { + s.width = a['--width='.len..].int() + continue + } + if a.starts_with('--gap-size=') { + s.gap = a['--gap-size='.len..].int() + continue + } + if a.starts_with('--flag-truncation=') { + s.truncate_marker = a['--flag-truncation='.len..] + continue + } + if a.starts_with('--word-regexp=') { + s.word_regexp = a['--word-regexp='.len..] + continue + } + if a.starts_with('--sentence-regexp=') { + s.sentence_regexp = a['--sentence-regexp='.len..] + continue + } + if a.starts_with('-') && a.len > 1 && a != '-' { + mut j := 1 + for j < a.len { + c := a[j] + match c { + `f` { s.ignore_case = true } + `A` { s.auto_reference = true } + `G`, `t` { s.typeset = true } + `r` { s.references = true } + `g` { + v, next := take_value(args, i, a, j) + i = next + s.gap = v.int() + j = a.len + continue + } + `w` { + v, next := take_value(args, i, a, j) + i = next + s.width = v.int() + j = a.len + continue + } + `F` { + v, next := take_value(args, i, a, j) + i = next + s.truncate_marker = v + j = a.len + continue + } + `W` { + v, next := take_value(args, i, a, j) + i = next + s.word_regexp = v + j = a.len + continue + } + `S` { + v, next := take_value(args, i, a, j) + i = next + s.sentence_regexp = v + j = a.len + continue + } + `o` { + v, next := take_value(args, i, a, j) + i = next + s.only_file = v + j = a.len + continue + } + `i` { + v, next := take_value(args, i, a, j) + i = next + s.ignore_file = v + j = a.len + continue + } + `b` { + v, next := take_value(args, i, a, j) + i = next + s.break_file = v + j = a.len + continue + } + `d` {} + else { + common.exit_with_error_message(app_name, "invalid option -- '${c.ascii_str()}'") + } + } + j++ + } + continue + } + operands << a + } + + s.operands = operands + return s +} + +// take_value takes the value for an option: the rest of the same word if there +// is one, else the next argument. +fn take_value(args []string, i int, word string, at int) (string, int) { + rest := word[at + 1..] + if rest != '' { + return rest, i + } + if i + 1 >= args.len { + c := word[at] + common.exit_with_error_message(app_name, "option requires an argument -- '${c.ascii_str()}'") + } + return args[i + 1], i + 1 +} + +fn print_help() { + println('Usage: ptx [OPTION]... [INPUT]...') + println('Output a permuted index, including context, of the words in the input files.') + println('') + println(' -A, --auto-reference output automatically generated references') + println(' -F, --flag-truncation=STRING use STRING for flagging line truncations') + println(' -M, --macro-name=STRING macro name to use instead of xx') + println(' -W, --word-regexp=REGEXP use REGEXP to match each keyword') + println(' -S, --sentence-regexp=REGEXP for end of lines or end of sentences') + println(' -b, --break-file=FILE word break characters in this FILE') + println(' -f, --ignore-case fold lower case to upper case for sorting') + println(' -g, --gap-size=NUMBER gap size in columns between output fields') + println(' -i, --ignore-file=FILE read ignore word list from FILE') + println(' -o, --only-file=FILE read only word list from this FILE') + println(' -r, --references first field of each line is a reference') + println(' -w, --width=NUMBER output width in columns') + println(' --help display this help and exit') + println(' --version output version information and exit') +} diff --git a/src/ptx/main.v b/src/ptx/main.v new file mode 100644 index 00000000..36607dce --- /dev/null +++ b/src/ptx/main.v @@ -0,0 +1,53 @@ +import common.ptx as ptxmod +import os + +fn main() { + set := ptxmod.parse_args(os.args[1..]) + + mut data := []u8{} + if set.operands.len == 0 { + data = read_stdin() + } else { + for operand in set.operands { + if operand == '-' { + data << read_stdin() + continue + } + if !os.exists(operand) { + eprintln('ptx: ${operand}: No such file or directory') + exit(1) + } + data << read_file(operand) + } + } + + mut entries := ptxmod.build_entries(data.bytestr(), set) + ptxmod.sort_entries(mut entries) + rows := ptxmod.render(entries, set) + if rows.len > 0 { + println(rows.join('\n')) + } +} + +// read_file is a one-line wrapper so the array-returning call sits at the top +// level of a function, where codegen handles it. +fn read_file(path string) []u8 { + return os.read_file_array(path) +} + +// read_stdin drains standard input in explicit chunks. io.read_all is not used +// here because it stops early on this platform, which produced a single +// truncated row instead of the whole index. +fn read_stdin() []u8 { + mut out := []u8{} + mut chunk := []u8{len: 64 * 1024} + mut f := os.stdin() + for { + n := f.read(mut chunk) or { break } + if n == 0 { + break + } + out << chunk[..n] + } + return out +} diff --git a/src/ptx/ptx_test.v b/src/ptx/ptx_test.v new file mode 100644 index 00000000..95b1dc17 --- /dev/null +++ b/src/ptx/ptx_test.v @@ -0,0 +1,136 @@ +import common.ptx as ptxmod + +fn test_parse_args_defaults() { + s := ptxmod.parse_args([]) + assert s.width == 72 + assert s.gap == 3 + assert !s.ignore_case + // The default truncation marker is the forward slash. + assert s.truncate_marker == '/' +} + +fn test_parse_args_geometry() { + s := ptxmod.parse_args(['-w', '40', '-g', '2']) + assert s.width == 40 + assert s.gap == 2 + + attached := ptxmod.parse_args(['-w40', '-g2']) + assert attached.width == 40 + assert attached.gap == 2 + + eq := ptxmod.parse_args(['--width=50', '--gap-size=4']) + assert eq.width == 50 + assert eq.gap == 4 +} + +fn test_parse_args_flags() { + s := ptxmod.parse_args(['-f', '-A', '-r', '-t']) + assert s.ignore_case + assert s.auto_reference + assert s.references + assert s.typeset +} + +fn test_parse_args_file_options() { + s := ptxmod.parse_args(['-o', 'only.txt', '-i', 'ign.txt', '-b', 'brk.txt']) + assert s.only_file == 'only.txt' + assert s.ignore_file == 'ign.txt' + assert s.break_file == 'brk.txt' + + attached := ptxmod.parse_args(['-oonly.txt', '-W', '[a-z]+']) + assert attached.only_file == 'only.txt' + assert attached.word_regexp == '[a-z]+' +} + +fn test_parse_args_truncation_marker() { + s := ptxmod.parse_args(['-F', '@@']) + assert s.truncate_marker == '@@' + + eq := ptxmod.parse_args(['--flag-truncation=>>']) + assert eq.truncate_marker == '>>' +} + +fn test_parse_args_operands() { + s := ptxmod.parse_args(['one.txt', 'two.txt']) + assert s.operands == ['one.txt', 'two.txt'] + + dash := ptxmod.parse_args(['--', '-weird.txt']) + assert dash.operands == ['-weird.txt'] +} + +fn test_build_entries_one_row_per_word() { + text := 'alpha beta gamma\n' + set := ptxmod.parse_args([]) + entries := ptxmod.build_entries(text, set) + assert entries.len == 3 + assert entries[0].keyword == 'alpha' + assert entries[0].before == '' + assert entries[0].after == 'beta gamma' + assert entries[1].keyword == 'beta' + assert entries[1].before == 'alpha' + assert entries[1].after == 'gamma' + assert entries[2].keyword == 'gamma' + assert entries[2].before == 'alpha beta' + assert entries[2].after == '' +} + +fn test_build_entries_splits_on_punctuation() { + // The default keyword is a run of letters, so "it's" is two keywords and + // "42nd" yields "nd" with "42" as context. Measured against GNU 9.4. + text := "hello, world! it's 42nd" + set := ptxmod.parse_args([]) + entries := ptxmod.build_entries(text, set) + mut words := []string{} + for e in entries { + words << e.keyword + } + assert words == ['hello', 'world', 'it', 's', 'nd'] +} + +fn test_sort_orders_by_keyword() { + set := ptxmod.parse_args([]) + mut entries := ptxmod.build_entries('gamma alpha beta\n', set) + ptxmod.sort_entries(mut entries) + mut words := []string{} + for e in entries { + words << e.keyword + } + assert words == ['alpha', 'beta', 'gamma'] +} + +fn test_render_keyword_column_is_fixed() { + // Every keyword starts at the same column: line_width / 2 plus the gap + // plus one. This is the property that makes the index scannable. + set := ptxmod.parse_args(['-w', '60', '-g', '3']) + entries := ptxmod.build_entries('alpha beta gamma\n', set) + rows := ptxmod.render(entries, set) + assert rows.len == 3 + for row in rows { + assert row.len <= set.width, 'row wider than the line: ${row}' + } +} + +fn test_render_head_is_not_truncated_to_keyword_width() { + // The head keeps all the words that fit on the line. Truncating it to the + // keyword width instead would cut "beta gamma" to "beta ". + set := ptxmod.parse_args(['-w', '72', '-g', '3']) + entries := ptxmod.build_entries('alpha beta gamma\n', set) + rows := ptxmod.render(entries, set) + assert rows[0].contains('alpha beta gamma') + assert rows[0].ends_with('beta gamma') +} + +fn test_render_last_word_has_no_trailing_space() { + set := ptxmod.parse_args([]) + entries := ptxmod.build_entries('alpha beta gamma\n', set) + rows := ptxmod.render(entries, set) + // The final keyword has no following context, so the row ends at it. + assert rows[2].ends_with('gamma') + assert !rows[2].ends_with(' ') +} + +fn test_render_empty_input_gives_no_rows() { + set := ptxmod.parse_args([]) + rows := ptxmod.render([], set) + assert rows.len == 0 +}