diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3da476e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +Alle wichtigen Änderungen stehen hier. Das Format folgt +[Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern +folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in +`lib/rdb/version.rb`; `rdb --version` zeigt sie an. + +## [Unveröffentlicht] + +## [0.2.0] - 2026-10-11 + +### Hinzugefügt +- `RDB_ICASE=1` ignoriert Groß-/Kleinschreibung bei Spaltennamen. Ein exakter + Treffer gewinnt, mehrere Treffer sind ein Fehler; awk-Wörter und + Funktionsaufrufe bleiben unberührt. +- Man-Pages von `row`, `compute`, `column`, `sorttable`, `jointable`, `rename`, + `validate` und `rdb` mit dem Abschnitt „Case of Column Names“. +- `rdb man -g`: Man-Page mit groff formatiert, dazu `man/rdb.tmac` mit den + MWC-Makros (`.DM`, `.DE`, `.PC` ...). +- `rdb --version`, `CHANGELOG.md`. + +### Geändert +- `column` schreibt den Kopf in der Schreibweise der Tabelle, wenn die Spalte + existiert. +- `demo/ledger` und `demo/chart` verwenden echte Tabs. + +## [0.1.0] - 2026-10-11 + +### Hinzugefügt +- Erster Nachbau von /rdb in Ruby: ein Programm `bin/rdb` mit Symlinks für alle + dokumentierten Befehle (Relational, Text, Konvertierung, Schnellzugriff, + Berichte, Terminal, /act), 137 Man-Pages, Demo-Daten, 76 Tests, mise-Setup. diff --git a/README.md b/README.md index 6ad3e17..1b34ec3 100644 --- a/README.md +++ b/README.md @@ -43,11 +43,19 @@ die meisten Befehle lesen beides. `row`, `compute` und `validate` übergeben ihren Ausdruck an awk, nachdem sie Spaltennamen durch `$N` ersetzt haben. Es gilt also awk-Syntax. +Spaltennamen unterscheiden Groß- und Kleinschreibung. Mit `RDB_ICASE=1` wird +sie ignoriert (Details unter „Unterschiede zum Original“): + +``` +RDB_ICASE=1 compute 'wert = cost * amount' < inventory +``` + ## Hilfe ``` rdb alle Befehle rdb man search die Original-Man-Page als Text (auch Themen: backup, dbase, dashline ...) +rdb --version Versionsnummer (Änderungen siehe CHANGELOG.md) rdb man -g search dieselbe Seite mit groff formatiert (Beispiele sauber eingerückt) whatis column Beschreibung und Syntax whatwill total Befehle, die zu einem Stichwort passen @@ -104,6 +112,14 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell. Handbuch sind als Tests enthalten (`test/rdb_test.rb`). - Die Indexdateien haben ein eigenes, einfaches Format (das Original ist nicht dokumentiert). Sie sind nicht mit alten /rdb-Dateien austauschbar. +- Spaltennamen unterscheiden wie im Original Groß- und Kleinschreibung. Mit + `RDB_ICASE=1` (z. B. in `mise.toml` unter `[env]`) wird sie ignoriert: + ein exakter Treffer gewinnt, mehrere ähnliche sind ein Fehler. In + `row`/`compute`/`validate` bleiben awk-Wörter und Funktionsaufrufe unberührt + (eine Spalte `Length` verdeckt also nicht `length()`). Die Man-Pages von + `row`, `compute`, `column`, `sorttable`, `jointable`, `rename`, `validate` und + `rdb` haben dazu den Abschnitt „Case of Column Names“; er ist die einzige + Ergänzung gegenüber den Original-Seiten. - `jointable` braucht keine vorsortierten Tabellen. - `ve` ist ein zeilenorientierter Editor, kein Bildschirmeditor. - `soundex` nutzt den Standard-Algorithmus. Die Beispielwerte im Handbuch diff --git a/bin/rdb b/bin/rdb index b077f28..c9f2ad6 100755 --- a/bin/rdb +++ b/bin/rdb @@ -12,6 +12,7 @@ Signal.trap('PIPE', 'SYSTEM_DEFAULT') if name == 'rdb' case args.first when nil, '-h', '--help' then name = 'rdb.list' + when '-v', '--version' then puts "rdb #{Rdb::VERSION}"; exit when 'install' then name = 'rdb.install'; args.shift else name = args.shift end diff --git a/lib/rdb.rb b/lib/rdb.rb index c274c71..f3aac25 100644 --- a/lib/rdb.rb +++ b/lib/rdb.rb @@ -7,6 +7,7 @@ require 'open3' require 'date' require 'tmpdir' require 'fileutils' +require_relative 'rdb/version' module Rdb ROOT = File.expand_path('..', __dir__) @@ -16,6 +17,21 @@ module Rdb COMMANDS = {} class << self + # RDB_ICASE=1 makes column names match without regard to case. An exact + # match always wins; several case-insensitive matches are an error. + def icase? + !%w[0 no false].include?(ENV['RDB_ICASE'].to_s.downcase) && !ENV['RDB_ICASE'].to_s.empty? + end + + def column_index(head, name) + exact = head.index(name) + return exact if exact || !icase? + + hits = head.each_index.select { |i| head[i].casecmp?(name) } + die "column name '#{name}' is ambiguous: #{hits.map { |i| head[i] }.join(', ')}" if hits.size > 1 + hits.first + end + def die(msg) abort "#{File.basename($PROGRAM_NAME)}: #{msg}" end diff --git a/lib/rdb/cmd/relational.rb b/lib/rdb/cmd/relational.rb index caddc09..66bf849 100644 --- a/lib/rdb/cmd/relational.rb +++ b/lib/rdb/cmd/relational.rb @@ -15,8 +15,9 @@ Rdb.register('column', 'Display columns of a table in any order', 'column [ Colu t = table_in next t.write if args.empty? - idx = args.map { |a| t.head.index(a) } - t.with(head: args, rows: t.rows.map { |r| idx.map { |i| i ? r[i] : '' } }).write + idx = args.map { |a| Rdb.column_index(t.head, a) } + head = args.each_with_index.map { |a, n| idx[n] ? t.head[idx[n]] : a } + t.with(head: head, rows: t.rows.map { |r| idx.map { |i| i ? r[i] : '' } }).write end Rdb.alias_for('project', 'column') diff --git a/lib/rdb/env.rb b/lib/rdb/env.rb index 03852af..5de4287 100644 --- a/lib/rdb/env.rb +++ b/lib/rdb/env.rb @@ -40,6 +40,24 @@ module Rdb # Replace column names in an awk expression with $N, leaving strings, # regexes and other identifiers alone. + AWK_WORDS = %w[BEGIN END NR NF FS OFS ORS RS FNR FILENAME SUBSEP RSTART RLENGTH ENVIRON ARGC ARGV + if else while for do in break continue next exit return function delete getline + print printf length substr index split sub gsub match sprintf sin cos atan2 exp log + sqrt int rand srand tolower toupper system].freeze + + # With RDB_ICASE, a word that is not an awk keyword or function call may + # name a column in any case, provided that exactly one column fits. + def icase_column(names, expr, i) + return unless Rdb.icase? + + word = expr[i..][/\A\w+/].to_s + return if word.empty? || AWK_WORDS.include?(word) || expr[i + word.size..].to_s.match?(/\A\s*\(/) + + hits = names.select { |n, _| n.casecmp?(word) } + Rdb.die "column name '#{word}' is ambiguous: #{hits.map(&:first).join(', ')}" if hits.size > 1 + hits.first + end + def to_awk(expr, head) names = head.each_with_index.sort_by { |n, _| -n.size } out = +'' @@ -59,6 +77,7 @@ module Rdb i = j + 1 elsif c.match?(/[A-Za-z_#]/) && (i.zero? || !expr[i - 1].match?(/[\w$]/)) hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/\w/) } + hit ||= icase_column(names, expr, i) if hit out << "$#{hit[1] + 1}" i += hit[0].size diff --git a/lib/rdb/fast.rb b/lib/rdb/fast.rb index 12e5dd4..82687c6 100644 --- a/lib/rdb/fast.rb +++ b/lib/rdb/fast.rb @@ -53,7 +53,7 @@ module Rdb end def column(name) - @head.index(name) || Rdb.die("#{path}: no column named '#{name}'") + Rdb.column_index(@head, name) || Rdb.die("#{path}: no column named '#{name}'") end def row_at(offset) diff --git a/lib/rdb/table.rb b/lib/rdb/table.rb index 718fe1a..fe79535 100644 --- a/lib/rdb/table.rb +++ b/lib/rdb/table.rb @@ -60,7 +60,7 @@ module Rdb end def index_of(name) - @head.index(name) || Rdb.die("no column named '#{name}'") + Rdb.column_index(@head, name) || Rdb.die("no column named '#{name}'") end def to_s diff --git a/lib/rdb/version.rb b/lib/rdb/version.rb new file mode 100644 index 0000000..c7b6b91 --- /dev/null +++ b/lib/rdb/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Rdb + VERSION = '0.2.0' +end diff --git a/man/column.man b/man/column.man index be93db0..2cac9e6 100644 --- a/man/column.man +++ b/man/column.man @@ -113,6 +113,22 @@ Phone (111) 222-3333 Name Chiquita Banana Phone 1234 .DE +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .B project, diff --git a/man/compute.man b/man/compute.man index bb23d65..70c1505 100644 --- a/man/compute.man +++ b/man/compute.man @@ -355,6 +355,22 @@ compute 'Value = sprintf ("%7.2f", ( Cost * Amount ))' < inventory .DE .PP These commands will produce the first table under Examples, above. +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" COHERENT Lexicon: .B diff --git a/man/jointable.man b/man/jointable.man index cf97142..4624c98 100644 --- a/man/jointable.man +++ b/man/jointable.man @@ -393,6 +393,22 @@ before .BR chart . It says that the standard input file will be file 1 or on the left side of the table. +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .B column, diff --git a/man/rdb.man b/man/rdb.man index 8e8a8ea..c590ec1 100644 --- a/man/rdb.man +++ b/man/rdb.man @@ -10,6 +10,22 @@ prints on the standard output all commands in directory .BR $RDB/bin . This command is a shell script, which you should edit if its output is not to your liking. +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .B act, diff --git a/man/rename.man b/man/rename.man index 754fb42..6ce4970 100644 --- a/man/rename.man +++ b/man/rename.man @@ -112,6 +112,22 @@ State Costa Rica Zip 123456789 Phone 1234 .DE +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .B column diff --git a/man/row.man b/man/row.man index cdb6950..f37f1ad 100644 --- a/man/row.man +++ b/man/row.man @@ -175,6 +175,22 @@ and .BR compute , read the COHERENT manual's tutorial on .BR awk . +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .B compute, diff --git a/man/sorttable.man b/man/sorttable.man index ff59e3d..c4e30ee 100644 --- a/man/sorttable.man +++ b/man/sorttable.man @@ -165,6 +165,22 @@ People are often confused by .B sort because it does not behave the way one would expect. Study its entry in the COHERENT Lexicon. +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" COHERENT Lexicon: .B diff --git a/man/validate.man b/man/validate.man index f0ab4ed..a0ab565 100644 --- a/man/validate.man +++ b/man/validate.man @@ -133,6 +133,22 @@ that is easy to find, such as .PP If you only want to see the error lines, use the command .BR row . +.SH "Case of Column Names" +.I "(Ruby re-creation only.)" +Column names are case sensitive, as in the original +.BR /rdb . +If the environment variable +.B RDB_ICASE +is set to 1, upper and lower case are ignored when column names are looked up. +An exact match always wins; +if several columns differ only in case, the command stops with an +.I ambiguous +error. +Names that are also +.B awk +reserved words or are followed by +.B ( +are never taken for columns. .SH "See Also" .SH "Logical Conditions" .B diff --git a/test/rdb_test.rb b/test/rdb_test.rb index d296b3e..2bb9e0c 100644 --- a/test/rdb_test.rb +++ b/test/rdb_test.rb @@ -478,4 +478,41 @@ class ActTest < RdbCase assert_includes out, 'Duplicate Short names in chart' assert st.success?, err end + + ICASE = { 'RDB_ICASE' => '1' }.freeze + + def test_column_names_are_case_sensitive_by_default + _o, err, st = run3('column', 'cost', stdin: File.read(File.join(@dir, 'inventory'))) + assert st.success?, err + assert_equal %w[cost], _o.lines.first.chomp.split("\t") + end + + def test_icase_column_and_sorttable + out, err, st = run3('column', 'COST', 'item', stdin: File.read(File.join(@dir, 'inventory')), env: ICASE) + assert st.success?, err + assert_equal %w[Cost Item], out.lines.first.chomp.split("\t") + out, = run3('sorttable', '-nr', 'cost', stdin: File.read(File.join(@dir, 'inventory')), env: ICASE) + assert_equal 'scales', data(out).first.last + end + + def test_icase_compute_and_awk_words + src = File.read(File.join(@dir, 'inventory')) + out, err, st = run3('compute', 'value = sprintf("%5.2f", cost * AMOUNT)', stdin: src, env: ICASE) + assert st.success?, err + assert_equal '1312.75', data(out)[5][3].strip + # a column called Length must not swallow the length() function + t = "Length\tName\n------\t----\n3\tab\n" + out, err, st = run3('compute', 'Length = length(name)', stdin: t, env: ICASE) + assert st.success?, err + assert_equal [%w[2 ab]], data(out) + end + + def test_icase_ambiguous_column_is_an_error + t = "Name\tname\n----\t----\na\tb\n" + _o, err, st = run3('column', 'NAME', stdin: t, env: ICASE) + refute st.success? + assert_match(/ambiguous/, err) + out, = run3('column', 'name', stdin: t, env: ICASE) + assert_equal [%w[b]], data(out) + end end