diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c9cb0..e5317cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,11 @@ folgen [Semantic Versioning](https://semver.org/lang/de/). Die Version steht in ### Verbessert - Schnellzugriff: `search`, `seek`, `delete` und `update.rdb` lesen bei `-mh`, `-mi` und `-mr` nur noch den Indexeintrag und den einen Satz (Seek auf Byte-Positionen, bei `.i` Halbierung über die Datei), nicht mehr Tabelle und Index vollständig. `Data` liest die Zeilen erst, wenn eine Methode alle braucht. - Die Hashfunktion ist FNV-1a statt der Bytesumme. Vorher fielen 3000 fünfstellige Nummern in 30 Plätze (größter Eimer 223), jetzt liegt der größte Eimer bei etwa 4. **Hashindizes (`.h`) müssen neu gebaut werden** (`index -mh`). -- `total`, `mean`, `subtotal` und die /act-Befehle rechnen mit `BigDecimal` statt `Float`: `0.1 + 0.2 + 0.7` ergibt `1.0`, Beträge summieren sich auf den Rappen genau, und es wird kaufmännisch gerundet (halb auf). +- `total`, `mean`, `subtotal` und die /act-Befehle rechnen exakt mit `Rational` statt `Float`: `0.1 + 0.2 + 0.7` ergibt `1.0`, Beträge summieren sich auf den Rappen genau, und es wird kaufmännisch gerundet (halb auf). + +- `bin/rdb` startet mit `ruby --disable-gems`: jeder Befehl ist in etwa doppelt so schnell gestartet (ca. 85 statt 170 ms), weil RubyGems nicht geladen wird. `rdb` braucht nur die Standardbibliothek. +- Die Tests laufen parallel (`parallelize_me!`; `MT_CPU=1` für nacheinander). Zusammen mit dem schnelleren Start dauert die Suite 21 statt 70 Sekunden. +- Intern: Hilfsfunktionen der Befehlsdateien liegen in `Rdb::Helpers` statt auf `Object`, die Konstanten (`STATS`, `TERM_CAPS` ...) in `Rdb`. Kopf- und Strichzeile entstehen an einer Stelle (`Table.head_text`, `dash_cells`, `headed?`), `awk` baut auf `awk_text` auf. ### Sicherheit - `report`: Spaltenwerte in `!>` wurden unmaskiert in einen Shell-Aufruf eingesetzt, eine Zelle konnte so Befehle ausführen. Sie werden jetzt mit `Shellwords.escape` eingesetzt. diff --git a/README.md b/README.md index 49a8c30..ccd6859 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ er nicht mehr passt (außer mit `-a`). Beide halten die Indizes aktuell. die Hashtabelle `.h` hat 2n statt 2n+1 Plätze (Hashfunktion FNV-1a), und in `.i` steht der Schlüssel vor dem Offset statt dahinter. Alte /rdb-Indizes und `.h`-Dateien früherer Versionen dieses Nachbaus muss man neu bauen. -- Summen, Mittelwerte und /act-Beträge sind Dezimalzahlen (`BigDecimal`), gerundet wird halb auf. +- Summen, Mittelwerte und /act-Beträge sind Dezimalzahlen (`Rational`), gerundet wird halb auf. - Dateien, die kein UTF-8 sind, liest `rdb` als Latin-1 und schreibt sie als UTF-8. Fehler erscheinen als eine Zeile; mit `RDB_DEBUG=1` kommt der Ruby-Backtrace. Werte mit Tab oder Zeilenumbruch lehnen `append`, `enter`, `replace` und `ve` ab. diff --git a/bin/rdb b/bin/rdb index c9f2ad6..ac2c119 100755 --- a/bin/rdb +++ b/bin/rdb @@ -1,8 +1,10 @@ -#!/usr/bin/env ruby +#!/usr/bin/env -S ruby --disable-gems # frozen_string_literal: true # rdb -- every /rdb command is this one program, looking at the name it was # called by. `row`, `column`, `total` ... are symlinks to it (`rdb install`). +# --disable-gems: rdb needs only the standard library, and skipping RubyGems +# makes each command start about twice as fast. require_relative '../lib/rdb' diff --git a/doc/review.md b/doc/review.md index 76d6a57..a2a14fe 100644 --- a/doc/review.md +++ b/doc/review.md @@ -81,22 +81,26 @@ Index ohne Entsprechung in der Suche offen. 2. **Schlechte Hash-Funktion — Behoben** (FNV-1a, größter Eimer 4). Ursprünglich: ([fast.rb:68](../lib/rdb/fast.rb)): `bytes.sum % slots`. Bestätigt: 3000 fünfstellige Nummern landen in 30 Slots, größter Bucket 223. Abhilfe: FNV-1a o. Ä., Primzahl-Slotzahl. -3. **Geld in `Float` — Behoben** (`BigDecimal` in `total`/`mean`/`subtotal` und /act). Ursprünglich: ([act.rb:11](../lib/rdb/cmd/act.rb), `to_f` im /act-Teil +3. **Geld in `Float` — Behoben** (`Rational` in `total`/`mean`/`subtotal` und /act). Ursprünglich: ([act.rb:11](../lib/rdb/cmd/act.rb), `to_f` im /act-Teil und in `total`). Abhilfe: `BigDecimal` oder ganzzahlige Rappen. -4. **awk-Brücke** ([env.rb:119-138](../lib/rdb/env.rb)): ganze Tabelle als String +4. **awk-Brücke — bewusst offen.** `awk` und `awk_text` teilen sich jetzt den Aufruf. Streaming + und ein eigener Ausdrucksparser wären ein Umbau mit Verhaltensrisiko (awk-Syntax ist + dokumentierte Schnittstelle). Ursprünglich: ([env.rb:119-138](../lib/rdb/env.rb)): ganze Tabelle als String durch `capture3`, kein Streaming; handgeschriebener Parser erkennt Strings und Regexe heuristisch (`prev`) und hängt vom awk-Dialekt ab. -5. **Globale Methoden auf `Object`** (`fast_opts`, `lock_file`, `num`, `money`, +5. **Globale Methoden auf `Object` — Behoben** (`Rdb::Helpers`, Konstanten in `Rdb`). Ursprünglich: (`fast_opts`, `lock_file`, `num`, `money`, `ask`, `need`, `show_record` …). In ein Modul kapseln und in `Env` einbinden. -6. **Doppelter Code.** `awk`/`awk_text`; Schlüsseltabellen-Parsing in `fast_opts`/ +6. **Doppelter Code — Behoben** (`Table.head_text`, `dash_cells`, `headed?`; `awk` über `awk_text`). Ursprünglich: `awk`/`awk_text`; Schlüsseltabellen-Parsing in `fast_opts`/ `key_columns`; von Hand gebaute Kopf-/Strichzeilen trotz `Table.dash_row`. -7. **Tests.** Laufzeit ca. 66 s wegen eines Ruby-Prozesses pro Test. Fehlerfälle +7. **Tests — Behoben** (Suite 70 s → 21 s). Parallel, und `bin/rdb` startet mit `--disable-gems` (ca. 85 statt + 170 ms je Befehl). Fehlerfälle haben jetzt Tests. Den Rest der Zeit braucht + `test_testall_runs_demo_script` (rund 300 Befehle nacheinander). Ursprünglich: Laufzeit ca. 66 s wegen eines Ruby-Prozesses pro Test. Fehlerfälle (Encoding, Tab in Wert, Locks, Editor) nicht abgedeckt; Tests direkt gegen `Rdb.run` im Prozess wären schneller. -8. **Repo.** `demo/fact.pl`, `fact_lc.pl`, `rule.pl` sind ungetrackt; +8. **Repo — offen, Entscheidung beim Autor.** Ursprünglich: `demo/fact.pl`, `fact_lc.pl`, `rule.pl` sind ungetrackt; `demo/testall.old` (1187 Zeilen) wäre als Golden-File-Test besser gepflegt. ## Empfohlene Reihenfolge -Schwachstellen, Fehler und Verbesserung 1–3 sind erledigt. Offen sind 4–8: die -awk-Brücke, globale Methoden auf `Object`, doppelter Code, Testlaufzeit, Repo-Aufräumen. +Schwachstellen, Fehler und Verbesserungen 1–3 und 5–7 sind erledigt. +Offen: die awk-Brücke (4, bewusst) und das Repo-Aufräumen (8). diff --git a/lib/rdb.rb b/lib/rdb.rb index ad54bc9..50aba9e 100644 --- a/lib/rdb.rb +++ b/lib/rdb.rb @@ -3,7 +3,6 @@ # A Ruby re-creation of /rdb, the relational database tools Mark Williams # Company sold for COHERENT (~1991). -require 'bigdecimal' require 'open3' require 'date' require 'tmpdir' @@ -33,6 +32,25 @@ module Rdb hits.first end + # Exact decimal arithmetic with Rational (core Ruby, so bin/rdb can start + # without RubyGems): 0.1 + 0.2 is 0.3 and money adds up to the cent. + def decimal(str) + s = str.to_s.strip + return 0r if s.empty? + + Rational(s.match?(NUM) ? s.sub(/\.\z/, '') : s.to_f.to_s) + end + + # num with dec places, rounded half up ("%.2f" rounds the binary value). + def fixed(num, dec) + r = num.is_a?(Float) ? Rational(num.to_s) : num.to_r + r = r.round(dec, half: :up) + return r.to_i.to_s if dec.zero? + + digits = (r.abs * 10**dec).to_i.to_s.rjust(dec + 1, '0') + "#{'-' if r.negative?}#{digits[0...-dec]}.#{digits[-dec..]}" + end + def die(msg) abort "#{File.basename($PROGRAM_NAME)}: #{msg}" end diff --git a/lib/rdb/cmd/access.rb b/lib/rdb/cmd/access.rb index 66df065..dca4afb 100644 --- a/lib/rdb/cmd/access.rb +++ b/lib/rdb/cmd/access.rb @@ -6,54 +6,57 @@ require 'stringio' require_relative '../fast' -# Parse the common fast-access options. Stops at the first non-option, which -# is left in args; returns the option hash. -def fast_opts(args) - o = { m: 's', h: false, l: false, s: false, x: false, exact: false, a: false, v: false, o: nil } - until args.empty? - a = args.first - case a - when /\A-m([bhirs]?)\z/ then o[:m] = Regexp.last_match(1).empty? ? 's' : Regexp.last_match(1) - when '+x' then o[:exact] = true - when '-o' then args.shift; o[:o] = args.first - when /\A-[hslxav]+\z/ then a[1..].each_char { |c| o[c.to_sym] = true } - else break +module Rdb + module Helpers + # Parse the common fast-access options. Stops at the first non-option, which + # is left in args; returns the option hash. + def fast_opts(args) + o = { m: 's', h: false, l: false, s: false, x: false, exact: false, a: false, v: false, o: nil } + until args.empty? + a = args.first + case a + when /\A-m([bhirs]?)\z/ then o[:m] = Regexp.last_match(1).empty? ? 's' : Regexp.last_match(1) + when '+x' then o[:exact] = true + when '-o' then args.shift; o[:o] = args.first + when /\A-[hslxav]+\z/ then a[1..].each_char { |c| o[c.to_sym] = true } + else break + end + args.shift + end + o end - args.shift - end - o -end -def need_index(table, method) - suffix = Rdb::Fast::SUFFIX[method] - return unless suffix && !File.exist?(table + suffix) + def need_index(table, method) + suffix = Rdb::Fast::SUFFIX[method] + return unless suffix && !File.exist?(table + suffix) - Rdb.die("no index #{table}#{suffix}: run index -m#{method} #{table}") -end + Rdb.die("no index #{table}#{suffix}: run index -m#{method} #{table}") + end -# The key columns and, unless keys come from the terminal, the keys. Without key -# columns on the command line a key table names them in its head line -# (handbook p. 105: "search -mh mailtable < name"); otherwise the first column. -def key_columns(args, data) - return [args.empty? ? [data.head[0]] : args, nil] if $stdin.tty? + # The key columns and, unless keys come from the terminal, the keys. Without key + # columns on the command line a key table names them in its head line + # (handbook p. 105: "search -mh mailtable < name"); otherwise the first column. + def key_columns(args, data) + return [args.empty? ? [data.head[0]] : args, nil] if $stdin.tty? - text = $stdin.read.to_s - lines = text.lines.map { |l| l.chomp("\n") } - cols = args - if cols.empty? - keyed = lines.size >= 2 && Rdb::Table.dash_line?(lines[1].split("\t", -1)) - cols = keyed ? lines[0].split("\t", -1).map(&:strip) : [data.head[0]] - end - [cols, Rdb::Fast.read_keys(StringIO.new(text), cols.size)] -end + text = $stdin.read.to_s + lines = text.lines.map { |l| l.chomp("\n") } + cols = args + if cols.empty? + cols = Rdb::Table.headed?(lines) ? lines[0].split("\t", -1).map(&:strip) : [data.head[0]] + end + [cols, Rdb::Fast.read_keys(StringIO.new(text), cols.size)] + end -def each_key(ncols, keys = nil, &block) - if keys - keys.each(&block) - elsif $stdin.tty? - $stdin.each_line { |l| yield l.chomp.split("\t", -1).first(ncols) } - else - Rdb::Fast.read_keys($stdin, ncols).each { |k| yield k } + def each_key(ncols, keys = nil, &block) + if keys + keys.each(&block) + elsif $stdin.tty? + $stdin.each_line { |l| yield l.chomp.split("\t", -1).first(ncols) } + else + Rdb::Fast.read_keys($stdin, ncols).each { |k| yield k } + end + end end end @@ -76,7 +79,7 @@ Rdb.register('search', 'Search a table', data = Rdb::Fast::Data.new(table) need_index(table, o[:m]) keycols, keys = key_columns(args, data) - puts data.head.join("\t"), Rdb::Table.dash_row(data.head) unless o[:h] + $stdout.write Rdb::Table.head_text(data.head) unless o[:h] each_key(keycols.size, keys) do |key| hits = Rdb::Fast.search(data, o[:m], keycols, key, first: o[:s] || o[:l], partial: o[:x], exact: o[:exact]) hits.each do |h| @@ -97,7 +100,7 @@ Rdb.register('seek', 'Return the beginning and ending offset of a row', 'seek [- Rdb::Fast.search(data, o[:m], keycols, key, first: true).each do |h| puts "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}" if o[:o] - File.write(o[:o], data.head.join("\t") + "\n" + Rdb::Table.dash_row(data.head) + "\n" + h.row.line + "\n") + File.write(o[:o], Rdb::Table.head_text(data.head) + h.row.line + "\n") end end end @@ -137,7 +140,7 @@ Rdb.register('replace', 'Insert a record into a file at specified location', text = $stdin.read lines = text.lines.map { |l| l.chomp("\n") } - lines = lines.drop(2) if lines.size >= 2 && Rdb::Table.dash_line?(lines[1].split("\t", -1)) + lines = lines.drop(2) if Rdb::Table.headed?(lines) die 'replace: no input row' if lines.empty? keycol = Rdb::Fast::Data.new(table).head[0] @@ -189,33 +192,41 @@ require 'shellwords' # ---- locks ------------------------------------------------------------------- -# Lock files live in a private per-user directory, not loose in the shared -# /tmp, so other users can neither forge nor delete them. -def lock_dir - dir = File.join(Dir.tmpdir, "rdb-locks-#{Process.uid}") - Dir.mkdir(dir, 0o700) unless File.directory?(dir) - st = File.lstat(dir) - Rdb.die("#{dir}: not a private directory of yours") if st.symlink? || st.uid != Process.uid || (st.mode & 0o077) != 0 - dir -end +module Rdb + module Helpers + # Lock files live in a private per-user directory, not loose in the shared + # /tmp, so other users can neither forge nor delete them. + def lock_dir + dir = File.join(Dir.tmpdir, "rdb-locks-#{Process.uid}") + begin + Dir.mkdir(dir, 0o700) + rescue Errno::EEXIST + nil # made by someone else, maybe at this very moment; checked below + end + st = File.lstat(dir) + Rdb.die("#{dir}: not a private directory of yours") if st.symlink? || st.uid != Process.uid || (st.mode & 0o077) != 0 + dir + end -def lock_file(name) - File.join(lock_dir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}") -end + def lock_file(name) + File.join(lock_dir, "L#{File.basename(name)}.#{Digest::MD5.hexdigest(File.expand_path(name))[0, 8]}") + end -# Read-modify-write of a lock file under an exclusive flock, so two lock or -# unlock calls cannot interleave. The block gets the held locks and returns -# the new list of lines, or nil to leave the file alone. -def with_lock_file(name) - File.open(lock_file(name), File::RDWR | File::CREAT, 0o600) do |f| - f.flock(File::LOCK_EX) - held = f.read.lines(chomp: true).map { |l| l.split.map(&:to_i) } - lines = yield held - next unless lines + # Read-modify-write of a lock file under an exclusive flock, so two lock or + # unlock calls cannot interleave. The block gets the held locks and returns + # the new list of lines, or nil to leave the file alone. + def with_lock_file(name) + File.open(lock_file(name), File::RDWR | File::CREAT, 0o600) do |f| + f.flock(File::LOCK_EX) + held = f.read.lines(chomp: true).map { |l| l.split.map(&:to_i) } + lines = yield held + next unless lines - f.rewind - f.truncate(0) - f.write(lines.map { |l| "#{l}\n" }.join) + f.rewind + f.truncate(0) + f.write(lines.map { |l| "#{l}\n" }.join) + end + end end end @@ -267,20 +278,24 @@ end # ---- interactive editing -------------------------------------------------------- -def show_record(data_head, cells) - data_head.each_with_index { |h, i| puts "#{i + 1} #{h}\t#{cells[i].to_s.strip}" } -end +module Rdb + module Helpers + def show_record(data_head, cells) + data_head.each_with_index { |h, i| puts "#{i + 1} #{h}\t#{cells[i].to_s.strip}" } + end -def edit_cells(head, cells) - require 'tempfile' - Tempfile.create('rdb-edit') do |f| - f.write Rdb::Table.new(head, [cells], list: true).to_s - f.flush - system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) or Rdb.die("editor #{ENV['EDITOR'] || 'vi'} failed") - t = Rdb::Table.parse(File.read(f.path)) - return nil if t.rows.empty? + def edit_cells(head, cells) + require 'tempfile' + Tempfile.create('rdb-edit') do |f| + f.write Rdb::Table.new(head, [cells], list: true).to_s + f.flush + system(*Shellwords.split(ENV['EDITOR'] || 'vi'), f.path) or Rdb.die("editor #{ENV['EDITOR'] || 'vi'} failed") + t = Rdb::Table.parse(File.read(f.path)) + return nil if t.rows.empty? - head.map { |h| (j = t.head.index(h)) ? t.rows[0][j] : '' } + head.map { |h| (j = t.head.index(h)) ? t.rows[0][j] : '' } + end + end end end @@ -443,7 +458,7 @@ end Rdb.register('validate', 'Find invalid data', "validate 'pattern { action } ...' < tableorlist") do |args| die "usage: validate 'pattern { action } ...' < table" unless args.size == 1 t = table_in - puts t.head.join("\t"), Rdb::Table.dash_row(t.head) + $stdout.write Rdb::Table.head_text(t.head) prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}" out, ok = awk_text(prog, t.rows) $stdout.write out @@ -512,27 +527,31 @@ end # ---- exercising the fast-access methods --------------------------------------------- -def sample_table(path, n = 2000) - rows = (1..n).map { |i| [i.to_s, "name#{i * 7919 % n}", "city#{i % 97}"] } - File.write(path, Rdb::Table.new(%w[Id Name City], rows).to_s) -end +module Rdb + module Helpers + def sample_table(path, n = 2000) + rows = (1..n).map { |i| [i.to_s, "name#{i * 7919 % n}", "city#{i % 97}"] } + File.write(path, Rdb::Table.new(%w[Id Name City], rows).to_s) + end -def time_methods(n = 2000) - Dir.mktmpdir do |dir| - path = File.join(dir, 'unixtable') - sample_table(path, n) - %w[s b h i r].each do |m| - t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - Rdb::Fast.build(path, m, 'Name') unless m == 's' - t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - data = Rdb::Fast::Data.new(path) - hits = 0 - (1..200).each do |i| - key = m == 'r' ? i.to_s : "name#{i * 13 % n}" - hits += Rdb::Fast.search(data, m, ['Name'], key).size + def time_methods(n = 2000) + Dir.mktmpdir do |dir| + path = File.join(dir, 'unixtable') + sample_table(path, n) + %w[s b h i r].each do |m| + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + Rdb::Fast.build(path, m, 'Name') unless m == 's' + t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + data = Rdb::Fast::Data.new(path) + hits = 0 + (1..200).each do |i| + key = m == 'r' ? i.to_s : "name#{i * 13 % n}" + hits += Rdb::Fast.search(data, m, ['Name'], key).size + end + t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield m, t1 - t0, t2 - t1, hits + end end - t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC) - yield m, t1 - t0, t2 - t1, hits end end end diff --git a/lib/rdb/cmd/act.rb b/lib/rdb/cmd/act.rb index dc47633..79245d4 100644 --- a/lib/rdb/cmd/act.rb +++ b/lib/rdb/cmd/act.rb @@ -5,43 +5,42 @@ # an accounting package: each command reads and writes plain /rdb tables in # the current directory. -ACT_COMMANDS = %w[act adjust balance bom calcpay calculate cashflow chartdup close consolidate cstate explode +Rdb::ACT_COMMANDS = %w[act adjust balance bom calcpay calculate cashflow chartdup close consolidate cstate explode fillform foot getjournal invoice sale tax update.inv w2].freeze -# Amounts are decimal numbers, not floats: sums of cents stay exact. -def num(str) - s = str.to_s.strip - s.match?(Rdb::NUM) ? BigDecimal(s.sub(/\.\z/, '')) : BigDecimal(s.to_f.to_s) -end +module Rdb + module Helpers + # Amounts are exact decimals (Rational), not floats: sums of cents stay exact. + def num(str) + Rdb.decimal(str) + end -# Two decimals, always (money drops them from whole amounts). -def fixed2(val) - val = BigDecimal(val.to_s) unless val.is_a?(BigDecimal) - val.round(2, :half_up).to_s('F').then { |t| t.sub(/\.(\d*)\z/) { ".#{Regexp.last_match(1).ljust(2, '0')}" } } -end + # Two decimals, always (money drops them from whole amounts). + def fixed2(val) + Rdb.fixed(val, 2) + end -def money(val) - val = BigDecimal(val.to_s) unless val.is_a?(BigDecimal) - return val.to_i.to_s if val == val.to_i + def money(val) + val == val.to_i ? val.to_i.to_s : Rdb.fixed(val, 2) + end - val.round(2, :half_up).to_s('F').sub(/\.(\d)\z/, '.\10') -end + def need(file) + File.exist?(file) || Rdb.die("#{file}: no such table (the /act commands work on tables in the current directory)") + Rdb::Table.open(file) + end -def need(file) - File.exist?(file) || Rdb.die("#{file}: no such table (the /act commands work on tables in the current directory)") - Rdb::Table.open(file) -end + def next_date + ENV['NEXTDATE'] || Date.today.strftime('%y%m%d') + end -def next_date - ENV['NEXTDATE'] || Date.today.strftime('%y%m%d') -end - -def account_key(acct) - [acct.to_f, acct] + def account_key(acct) + [acct.to_f, acct] + end + end end Rdb.register('act', 'List all /act commands', 'act') do |_args| - names = ACT_COMMANDS.sort + names = Rdb::ACT_COMMANDS.sort w = names.map(&:size).max + 2 names.each_slice(5) { |row| puts row.map { |n| n.ljust(w) }.join.rstrip } end @@ -66,7 +65,7 @@ Rdb.register('foot', 'Foot or subtotal Debits and Credit of ledger', 'foot') do out = [] t.rows.sort_by { |r| account_key(r[0]) }.group_by { |r| r[0] }.each_value do |rows| out.concat(rows) - out << t.head.map { |h| '-' * [h.size, 1].max } + out << Rdb::Table.dash_cells(t.head) sub = [''] * t.head.size sub[0] = rows.first[0] sub[d] = money(rows.sum { |r| num(r[d]) }) @@ -84,7 +83,7 @@ Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_arg m = (meta[h['Account']] ||= {}) h.each { |k, v| m[k] = v if !v.to_s.strip.empty? && !%w[Debit Credit].include?(k) && !m.key?(k) } end - net = Hash.new(BigDecimal(0)) + net = Hash.new(0r) [tb, ja].each do |t| d = t.index_of('Debit') c = t.index_of('Credit') @@ -104,11 +103,15 @@ Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_arg File.write('adjustedtrial', Rdb::Table.new(head, rows).to_s) end -def trial_parts(t) - h = t.head - fmt = h.index('Format') - rows = t.rows.map { |r| { acct: r[0], debit: num(r[h.index('Debit')]), credit: num(r[h.index('Credit')]), fmt: r[fmt].to_s, name: r[h.index('Name')].to_s, row: r } } - rows +module Rdb + module Helpers + def trial_parts(t) + h = t.head + fmt = h.index('Format') + rows = t.rows.map { |r| { acct: r[0], debit: num(r[h.index('Debit')]), credit: num(r[h.index('Credit')]), fmt: r[fmt].to_s, name: r[h.index('Name')].to_s, row: r } } + rows + end + end end Rdb.register('balance', 'Create balance sheet from adjusted trial balance', 'balance') do |_args| @@ -183,7 +186,7 @@ Rdb.register('fillform', 'Fill a tax from with adjusted trial balance data', 'fi lines = need("taxform.#{args[0]}") trial = need('adjustedtrial') ti = trial.index_of('Taxline') - sums = Hash.new(BigDecimal(0)) + sums = Hash.new(0r) trial.rows.each { |r| sums[r[ti]] += (num(r[trial.index_of('Debit')]) - num(r[trial.index_of('Credit')])).abs } rows = lines.rows.map { |r| [r[0], money(sums[r[0]]), r[1].to_s] } File.write(args[0], Rdb::Table.new(%w[Taxline Amount Description], rows).to_s) @@ -205,14 +208,14 @@ Rdb.register('tax', 'Compute tax from income and tax table', 'tax income < taxta income = num(args[0]) rows = table_in.rows.map { |r| r.map { |c| num(c) } }.sort_by(&:first) row = rows.reverse.find { |r| r[0] <= income } - puts format('%7d', row ? (row[1] + (income - row[0]) * row[2] / 100.0).round : 0) + puts format('%7d', row ? (row[1] + (income - row[0]) * row[2] / 100).round(half: :up) : 0) end Rdb.register('cashflow', 'Compute balance column of cash table', 'cashflow < cashtable') do |_args| t = table_in a = t.index_of('Amount') b = t.index_of('Balance') - bal = BigDecimal(0) + bal = 0r rows = t.rows.map do |r| r = r.dup if r[a].strip.empty? && !r[b].strip.empty? @@ -250,9 +253,9 @@ end Rdb.register('bom', 'Produce bill-of-materials from parts list', 'bom') do |_args| part = need('part') items = need('saleitem') - qty = Hash.new(BigDecimal(0)) + qty = Hash.new(0r) items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) } - need_total = Hash.new(BigDecimal(0)) + need_total = Hash.new(0r) part.rows.each do |r| code = r[part.index_of('Code')] need_total[r[part.index_of('Subpart')]] += qty[code] * num(r[part.index_of('Count')]) if qty.key?(code) @@ -263,7 +266,7 @@ end Rdb.register('explode', 'Produce table of subparts and their count for a part', 'explode Part Amount [ table ]') do |args| die 'usage: explode Part Amount [table]' if args.size < 2 t = need(args[2] || 'parttable') - totals = Hash.new(BigDecimal(0)) + totals = Hash.new(0r) queue = [[args[0], num(args[1])]] until queue.empty? part, amount = queue.shift @@ -275,20 +278,24 @@ end # ---- sales ------------------------------------------------------------------------------ -def ask(prompt) - print prompt - $stdout.flush - ($stdin.gets || '').strip -end +module Rdb + module Helpers + def ask(prompt) + print prompt + $stdout.flush + ($stdin.gets || '').strip + end -def address_block(customers, number) - c = customers.rows.find { |r| r[0] == number } - return ["Customer Number #{number}"] unless c + def address_block(customers, number) + c = customers.rows.find { |r| r[0] == number } + return ["Customer Number #{number}"] unless c - h = customers.head.zip(c).to_h - ["Mailing Address for Customer Number #{number}", - [h['Title'], h['First'], h['Last'], h['Position']].compact.join(' ').strip, - h['Company'], h['Street'], [h['City'], h['State'], h['Country']].compact.join(' ')].compact.reject(&:empty?) + h = customers.head.zip(c).to_h + ["Mailing Address for Customer Number #{number}", + [h['Title'], h['First'], h['Last'], h['Position']].compact.join(' ').strip, + h['Company'], h['Street'], [h['City'], h['State'], h['Country']].compact.join(' ')].compact.reject(&:empty?) + end + end end Rdb.register('invoice', 'Print invoice for a sale order', 'invoice') do |_args| @@ -359,7 +366,7 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do | end puts address_block(customers, cust) n = Rdb::Table.open('salesorder').rows.map { |r| r[0].to_i }.max.to_i + 1 - gross = BigDecimal(0) + gross = 0r items = [] loop do code = ask('Code (Return when done): ') @@ -375,7 +382,7 @@ Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do | end next if items.empty? - tax = gross * BigDecimal('0.05') + tax = gross * Rational('0.05') order = [n.to_s, cust, next_date, '0', fixed2(gross), fixed2(tax), fixed2(gross + tax)] puts Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], [order]).to_s if ask('Is this correct? (y, n) ').downcase.start_with?('y') diff --git a/lib/rdb/cmd/convert.rb b/lib/rdb/cmd/convert.rb index 84fda77..9b7b567 100644 --- a/lib/rdb/cmd/convert.rb +++ b/lib/rdb/cmd/convert.rb @@ -105,21 +105,25 @@ end # ---- names that sound alike ---------------------------------------------------- -def soundex_code(word) - codes = { 'BFPV' => '1', 'CGJKQSXZ' => '2', 'DT' => '3', 'L' => '4', 'MN' => '5', 'R' => '6' } - letters = word.upcase.gsub(/[^A-Z]/, '').chars - return '' if letters.empty? +module Rdb + module Helpers + def soundex_code(word) + codes = { 'BFPV' => '1', 'CGJKQSXZ' => '2', 'DT' => '3', 'L' => '4', 'MN' => '5', 'R' => '6' } + letters = word.upcase.gsub(/[^A-Z]/, '').chars + return '' if letters.empty? - code = ->(c) { codes.find { |k, _| k.include?(c) }&.last } - out = letters[0].dup - last = code.(letters[0]) - letters.drop(1).each do |c| - d = code.(c) - out << d if d && d != last - last = d unless 'HW'.include?(c) - break if out.size == 4 + code = ->(c) { codes.find { |k, _| k.include?(c) }&.last } + out = letters[0].dup + last = code.(letters[0]) + letters.drop(1).each do |c| + d = code.(c) + out << d if d && d != last + last = d unless 'HW'.include?(c) + break if out.size == 4 + end + out.ljust(4, '0') + end end - out.ljust(4, '0') end Rdb.register('soundex', 'Convert a name into soundex code', 'soundex < file > file.x ; index -mb file.x Soundex') do |_args| diff --git a/lib/rdb/cmd/relational.rb b/lib/rdb/cmd/relational.rb index 4730269..5f47fbe 100644 --- a/lib/rdb/cmd/relational.rb +++ b/lib/rdb/cmd/relational.rb @@ -189,7 +189,7 @@ end # total, mean, maximum, minimum, width, precision and datatype are one program # that decides what to compute from the name it was called by. -STATS = { +Rdb::STATS = { 'total' => 'Sum a column', 'mean' => 'Display the mean of a column', 'maximum' => 'Display the maximum value in a column', @@ -199,7 +199,7 @@ STATS = { 'datatype' => 'Display the data type of each column selected' }.freeze -STATS.each do |cmd, desc| +Rdb::STATS.each do |cmd, desc| Rdb.register(cmd, desc, "#{cmd} [ -l ] [ column ... ] < table") do |args| list = args.first == '-l' args.shift if list @@ -209,7 +209,7 @@ STATS.each do |cmd, desc| sel.each do |i| vals = t.rows.map { |r| r[i] } filled = vals.reject { |v| v.strip.empty? } - nums = filled.all? { |v| numeric?(v) } ? vals.map { |v| v.strip.empty? ? BigDecimal(0) : dec_num(v) } : nil + nums = filled.all? { |v| numeric?(v) } ? vals.map { |v| dec_num(v) } : nil dec = filled.map { |v| decimals(v) }.max.to_i result[i] = case name @@ -260,7 +260,7 @@ Rdb.register('subtotal', 'Output subtotals of columns in a table', end if list out.concat(group) - out << t.head.map { |h| '-' * [h.size, 1].max } + out << Rdb::Table.dash_cells(t.head) end out << sub group = [] diff --git a/lib/rdb/cmd/report.rb b/lib/rdb/cmd/report.rb index a11f482..e44a8b6 100644 --- a/lib/rdb/cmd/report.rb +++ b/lib/rdb/cmd/report.rb @@ -4,19 +4,23 @@ require 'shellwords' -# Fill in a form for one row: is replaced by the value, -# by the output of the shell command, and !> passes data to it. -# Values inside a command are shell-escaped, so a cell can never add commands -# of its own; they arrive as one word each (do not put them in quotes). -def fill_form(form, head, row) - values = head.zip(row).to_h { |h, v| [h.strip, v.to_s] } - form.gsub(/|<([^<>!\n]*)>/m) do - if Regexp.last_match(1) - cmd = Regexp.last_match(1).gsub(/<([^<>!\n]*)>/) { Shellwords.escape(values.fetch(Regexp.last_match(1).strip, Regexp.last_match(0))) } - `#{cmd}` - else - name = Regexp.last_match(2).strip - values.key?(name) ? values[name] : Regexp.last_match(0) +module Rdb + module Helpers + # Fill in a form for one row: is replaced by the value, + # by the output of the shell command, and !> passes data to it. + # Values inside a command are shell-escaped, so a cell can never add commands + # of its own; they arrive as one word each (do not put them in quotes). + def fill_form(form, head, row) + values = head.zip(row).to_h { |h, v| [h.strip, v.to_s] } + form.gsub(/|<([^<>!\n]*)>/m) do + if Regexp.last_match(1) + cmd = Regexp.last_match(1).gsub(/<([^<>!\n]*)>/) { Shellwords.escape(values.fetch(Regexp.last_match(1).strip, Regexp.last_match(0))) } + `#{cmd}` + else + name = Regexp.last_match(2).strip + values.key?(name) ? values[name] : Regexp.last_match(0) + end + end end end end @@ -76,9 +80,13 @@ Rdb.register('screen', 'Convert a form into a screen-input shell program', 'scre puts ': append row', %(echo "#{names.map { |x| "$#{x}" }.join("\t")}") end -def label_lines(head, row) - h = head.zip(row).to_h - [h['Name'], h['Company'], h['Street'], "#{h['City']}, #{h['State']} #{h['ZIP']}".strip].compact +module Rdb + module Helpers + def label_lines(head, row) + h = head.zip(row).to_h + [h['Name'], h['Company'], h['Street'], "#{h['City']}, #{h['State']} #{h['ZIP']}".strip].compact + end + end end Rdb.register('label', 'Print mailing labels from a mailing list', 'label < list') do |_args| diff --git a/lib/rdb/cmd/term.rb b/lib/rdb/cmd/term.rb index 0fc0513..680f8e1 100644 --- a/lib/rdb/cmd/term.rb +++ b/lib/rdb/cmd/term.rb @@ -4,20 +4,24 @@ require 'shellwords' # Help, manual pages, terminal helpers, menus and housekeeping. -ESC = "\e" -TERM_CAPS = { - 'cl' => "#{ESC}[H#{ESC}[2J", 'ho' => "#{ESC}[H", 'ce' => "#{ESC}[K", 'cd' => "#{ESC}[J", - 'cm' => "#{ESC}[%i%d;%dH", 'so' => "#{ESC}[7m", 'se' => "#{ESC}[m", 'us' => "#{ESC}[4m", - 'ue' => "#{ESC}[m", 'mb' => "#{ESC}[5m", 'md' => "#{ESC}[1m", 'me' => "#{ESC}[m", - 'as' => "#{ESC}(0", 'ae' => "#{ESC}(B", 'up' => "#{ESC}[A", 'do' => "#{ESC}[B", 'bs' => "\b" +Rdb::ESC = "\e" +Rdb::TERM_CAPS = { + 'cl' => "#{Rdb::ESC}[H#{Rdb::ESC}[2J", 'ho' => "#{Rdb::ESC}[H", 'ce' => "#{Rdb::ESC}[K", 'cd' => "#{Rdb::ESC}[J", + 'cm' => "#{Rdb::ESC}[%i%d;%dH", 'so' => "#{Rdb::ESC}[7m", 'se' => "#{Rdb::ESC}[m", 'us' => "#{Rdb::ESC}[4m", + 'ue' => "#{Rdb::ESC}[m", 'mb' => "#{Rdb::ESC}[5m", 'md' => "#{Rdb::ESC}[1m", 'me' => "#{Rdb::ESC}[m", + 'as' => "#{Rdb::ESC}(0", 'ae' => "#{Rdb::ESC}(B", 'up' => "#{Rdb::ESC}[A", 'do' => "#{Rdb::ESC}[B", 'bs' => "\b" }.freeze -def command_rows - Rdb::COMMANDS.values.reject { |c| c.name.include?('.list') || c.name == 'rdb.install' }.sort_by(&:name) -end +module Rdb + module Helpers + def command_rows + Rdb::COMMANDS.values.reject { |c| c.name.include?('.list') || c.name == 'rdb.install' }.sort_by(&:name) + end -def syntax_of(cmd) - cmd.syntax.sub(/\A\S+\s*/, '') + def syntax_of(cmd) + cmd.syntax.sub(/\A\S+\s*/, '') + end + end end Rdb.register('rdb.list', 'List all /rdb programs', 'rdb') do |_args| @@ -80,56 +84,60 @@ end # ---- manual pages --------------------------------------------------------------- -def troff_clean(str) - # a literal backslash (\\ or \e) is parked in \0 so that later rules cannot see it - str.gsub('\\\\', "\0").gsub(/\\f[BIRP]/, '').gsub('\\e', "\0").gsub('\\(em', ' -- ').gsub('\\(aa', "'") - .gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ') - .gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub("\0", '\\') -end +module Rdb + module Helpers + def troff_clean(str) + # a literal backslash (\\ or \e) is parked in \0 so that later rules cannot see it + str.gsub('\\\\', "\0").gsub(/\\f[BIRP]/, '').gsub('\\e', "\0").gsub('\\(em', ' -- ').gsub('\\(aa', "'") + .gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ') + .gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub("\0", '\\') + end -def render_man(src) - out = [] - para = [] - verbatim = false - flush = lambda do - unless para.empty? - words = para.join(' ').split - line = '' - words.each { |w| line.size + w.size + 1 > 72 ? (out << line; line = w) : line = (line.empty? ? w : "#{line} #{w}") } - out << line unless line.empty? - para.clear - end - end - src.each_line do |raw| - l = raw.chomp - if l.start_with?('.') - macro, rest = l[1..].split(/\s+/, 2) - rest = rest.to_s.strip.delete_prefix('"').delete_suffix('"') - case macro - when 'TH', 'II', 'ta', 'ne', 'R', 'RE', 'SO', 'B1', 'B2', 'nf', 'fi', 'br' then nil - when '\\"' then nil - when 'PC' then flush.(); out << '' - when 'SH' then flush.(); out << '' << troff_clean(rest).upcase - when 'PP', 'sp' then flush.(); out << '' - when 'DM', 'DS' then flush.(); verbatim = true - when 'DE' then verbatim = false - when 'IP' then flush.(); out << ''; para << troff_clean(rest.split(/\s+\d/).first.to_s) - when 'B', 'I', 'BR', 'IR', 'RB', 'RI' then para << troff_clean(rest.delete('"')).gsub(/\s+/, ' ') unless verbatim - else nil + def render_man(src) + out = [] + para = [] + verbatim = false + flush = lambda do + unless para.empty? + words = para.join(' ').split + line = '' + words.each { |w| line.size + w.size + 1 > 72 ? (out << line; line = w) : line = (line.empty? ? w : "#{line} #{w}") } + out << line unless line.empty? + para.clear + end + end + src.each_line do |raw| + l = raw.chomp + if l.start_with?('.') + macro, rest = l[1..].split(/\s+/, 2) + rest = rest.to_s.strip.delete_prefix('"').delete_suffix('"') + case macro + when 'TH', 'II', 'ta', 'ne', 'R', 'RE', 'SO', 'B1', 'B2', 'nf', 'fi', 'br' then nil + when '\\"' then nil + when 'PC' then flush.(); out << '' + when 'SH' then flush.(); out << '' << troff_clean(rest).upcase + when 'PP', 'sp' then flush.(); out << '' + when 'DM', 'DS' then flush.(); verbatim = true + when 'DE' then verbatim = false + when 'IP' then flush.(); out << ''; para << troff_clean(rest.split(/\s+\d/).first.to_s) + when 'B', 'I', 'BR', 'IR', 'RB', 'RI' then para << troff_clean(rest.delete('"')).gsub(/\s+/, ' ') unless verbatim + else nil + end + elsif verbatim + out << " #{troff_clean(l)}" + elsif l.start_with?("\t", ' ') + flush.() + out << " #{troff_clean(l.strip)}" + elsif l.strip.empty? + flush.() + else + para << troff_clean(l.strip) + end end - elsif verbatim - out << " #{troff_clean(l)}" - elsif l.start_with?("\t", ' ') flush.() - out << " #{troff_clean(l.strip)}" - elsif l.strip.empty? - flush.() - else - para << troff_clean(l.strip) + out.join("\n").squeeze("\n") end end - flush.() - out.join("\n").squeeze("\n") end Rdb.register('man', 'Display the manual page for a command', 'man [-g] command') do |args| @@ -161,21 +169,21 @@ end Rdb.register('termput', 'Get terminal capability from /etc/termcap file', 'termput capability') do |args| die 'usage: termput capability' unless args.size == 1 - $stdout.print TERM_CAPS.fetch(args[0]) { die "no such capability '#{args[0]}'" } + $stdout.print Rdb::TERM_CAPS.fetch(args[0]) { die "no such capability '#{args[0]}'" } end Rdb.register('tset', 'Fetch termcap entry for a terminal type', 'tset [ TERM ]') do |args| term = args.first || ENV['TERM'] || 'ansi' - puts ":#{TERM_CAPS.map { |k, v| "#{k}=#{v.inspect[1..-2]}" }.join(':')}:co##{ENV['COLUMNS'] || 80}:li##{ENV['LINES'] || 24}:tc=#{term}:" + puts ":#{Rdb::TERM_CAPS.map { |k, v| "#{k}=#{v.inspect[1..-2]}" }.join(':')}:co##{ENV['COLUMNS'] || 80}:li##{ENV['LINES'] || 24}:tc=#{term}:" end Rdb.register('clear.rdb', "Clear the terminal's screen", 'clear.rdb') do |_args| - $stdout.print TERM_CAPS['cl'] + $stdout.print Rdb::TERM_CAPS['cl'] end Rdb.register('cursor', 'Move the cursor to the row and column requested', 'cursor row col [ CURSOR ]') do |args| die 'usage: cursor row col [CURSOR]' if args.size < 2 - $stdout.print "#{ESC}[#{args[0].to_i};#{args[1].to_i}H" + $stdout.print "#{Rdb::ESC}[#{args[0].to_i};#{args[1].to_i}H" $stdout.flush end diff --git a/lib/rdb/cmd/text.rb b/lib/rdb/cmd/text.rb index b28e037..ee6303e 100644 --- a/lib/rdb/cmd/text.rb +++ b/lib/rdb/cmd/text.rb @@ -2,16 +2,20 @@ # Formatting tables and working with text. -def justify_numbers(cells) - parts = cells.map { |c| c.empty? ? ['', nil] : c.split('.', 2) } - int_w = parts.map { |i, _| i.size }.max - frac_w = parts.map { |_, f| f ? f.size : 0 }.max - cells.zip(parts).map do |c, (i, f)| - next ' ' * (int_w + (frac_w.zero? ? 0 : frac_w + 1)) if c.empty? +module Rdb + module Helpers + def justify_numbers(cells) + parts = cells.map { |c| c.empty? ? ['', nil] : c.split('.', 2) } + int_w = parts.map { |i, _| i.size }.max + frac_w = parts.map { |_, f| f ? f.size : 0 }.max + cells.zip(parts).map do |c, (i, f)| + next ' ' * (int_w + (frac_w.zero? ? 0 : frac_w + 1)) if c.empty? - s = i.rjust(int_w) - s += f ? ".#{f.ljust(frac_w)}" : ' ' * (frac_w.zero? ? 0 : frac_w + 1) - s + s = i.rjust(int_w) + s += f ? ".#{f.ljust(frac_w)}" : ' ' * (frac_w.zero? ? 0 : frac_w + 1) + s + end + end end end @@ -112,7 +116,7 @@ Rdb.register('pad', 'Add extra spaces at end of last column', 'pad [-number] < t if t.list t.with(rows: t.rows.map { |r| r[0...-1] + [r.last + ' ' * n] }).write else - $stdout.write t.head.join("\t"), "\n", Rdb::Table.dash_row(t.head), "\n" + $stdout.write Rdb::Table.head_text(t.head) t.rows.each { |r| puts (r[0...-1] + [r.last + ' ' * n]).join("\t") } end end diff --git a/lib/rdb/env.rb b/lib/rdb/env.rb index bfce2ff..701c6d7 100644 --- a/lib/rdb/env.rb +++ b/lib/rdb/env.rb @@ -1,8 +1,13 @@ # frozen_string_literal: true module Rdb + # Helpers of single command files (lib/rdb/cmd/*.rb) go in here, not on + # Object; every Env includes them. + module Helpers; end + # Command blocks run inside an Env, which holds the helpers they share. class Env + include Helpers attr_reader :name def initialize(name) @@ -30,14 +35,12 @@ module Rdb str.strip[/\.(\d+)\z/, 1].to_s.size end - # Decimal arithmetic, so that 0.1 + 0.2 is 0.3 and money adds up to the cent. def dec_num(str) - BigDecimal(str.strip.sub(/\.\z/, '')) + Rdb.decimal(str) end def fmt(num, dec) - num = BigDecimal(num.to_s) unless num.is_a?(BigDecimal) - dec.zero? ? num.round(0, :half_up).to_i.to_s : num.round(dec, :half_up).to_s('F').then { |t| t.sub(/\.(\d*)\z/) { ".#{Regexp.last_match(1).ljust(dec, '0')}" } } + Rdb.fixed(num, dec) end def blank_row?(row) @@ -102,18 +105,14 @@ module Rdb # Run an awk program over rows (tab separated) and return the output rows. def awk(prog, rows) - input = rows.map { |r| r.join("\t") }.join("\n") - input += "\n" unless input.empty? - out, err, st = Open3.capture3('awk', '-F', "\t", prog, stdin_data: input) - $stderr.print err unless err.empty? - exit 1 unless st.success? + out, ok = awk_text(prog, rows) + exit 1 unless ok out.each_line.map { |l| l.chomp.split("\t", -1) } end # Raw output of awk, for programs that print their own text. def awk_text(prog, rows) - input = rows.map { |r| r.join("\t") }.join("\n") - input += "\n" unless input.empty? + input = rows.map { |r| "#{r.join("\t")}\n" }.join out, err, st = Open3.capture3('awk', '-F', "\t", prog, stdin_data: input) $stderr.print err unless err.empty? [out, st.success?] diff --git a/lib/rdb/fast.rb b/lib/rdb/fast.rb index 15eba93..0c0dc56 100644 --- a/lib/rdb/fast.rb +++ b/lib/rdb/fast.rb @@ -129,7 +129,7 @@ module Rdb when 'b' k = data.column(keycol) body = live_rows(data).sort_by { |r| r.cells[k].to_s.strip }.map { |r| r.line + "\n" } - Rdb.write_file(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join) + Rdb.write_file(path, Table.head_text(data.head) + body.join) end end @@ -393,7 +393,7 @@ module Rdb return [] if text.empty? lines = text.lines.map { |l| l.chomp("\n") } - if lines.size >= 2 && Table.dash_line?(lines[1].split("\t", -1)) + if Table.headed?(lines) lines.drop(2).reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) } else lines.reject(&:empty?).map { |l| l.split("\t", -1).first(ncols) } diff --git a/lib/rdb/table.rb b/lib/rdb/table.rb index 744dbce..65693cd 100644 --- a/lib/rdb/table.rb +++ b/lib/rdb/table.rb @@ -28,6 +28,11 @@ module Rdb end end + # Do these lines (chomped) start with a head line and a dash line? + def self.headed?(lines) + lines.size >= 2 && dash_line?(lines[1].split("\t", -1)) + end + def self.parse(text) text = utf8(text) return parse_list(text) if text.start_with?("\n") @@ -78,15 +83,22 @@ module Rdb def to_s return list_text if @list - out = +'' - out << @head.join("\t") << "\n" - out << Table.dash_row(@head) << "\n" + out = Table.head_text(@head) @rows.each { |r| out << r.join("\t") << "\n" } out end + def self.dash_cells(head) + head.map { |h| '-' * [h.size, 1].max } + end + def self.dash_row(head) - head.map { |h| '-' * [h.size, 1].max }.join("\t") + dash_cells(head).join("\t") + end + + # Head line and dash line, each with its newline. + def self.head_text(head) + +"#{head.join("\t")}\n#{dash_row(head)}\n" end def list_text diff --git a/test/test_helper.rb b/test/test_helper.rb index 73fb758..6a4ca25 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -12,6 +12,10 @@ DEMO = File.join(ROOT, 'demo') # Each test runs in a scratch copy of demo/, so commands that change files # (append, delete, replace ...) never touch the originals. class RdbCase < Minitest::Test + # Tests spawn their own processes in their own directory, so they can run + # side by side (N=1 in MT_CPU runs them one at a time). + parallelize_me! + def setup @dir = Dir.mktmpdir('rdb-test') FileUtils.cp_r(Dir[File.join(DEMO, '*')], @dir)