Rdb: Ruby-Nachbau der MWC /rdb-Werkzeuge

This commit is contained in:
wkh
2026-10-11 20:33:17 +02:00
commit a9990169e9
308 changed files with 16738 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
# A Ruby re-creation of /rdb, the relational database tools Mark Williams
# Company sold for COHERENT (~1991).
require 'open3'
require 'date'
require 'tmpdir'
require 'fileutils'
module Rdb
ROOT = File.expand_path('..', __dir__)
NUM = /\A\s*[+-]?(\d+\.?\d*|\.\d+)\s*\z/
Command = Struct.new(:name, :desc, :syntax, :block)
COMMANDS = {}
class << self
def die(msg)
abort "#{File.basename($PROGRAM_NAME)}: #{msg}"
end
# register 'name', 'description', 'syntax' do |args| ... end
def register(name, desc, syntax = nil, &block)
COMMANDS[name] = Command.new(name, desc, syntax || name, block)
end
def alias_for(name, target)
cmd = COMMANDS.fetch(target)
COMMANDS[name] = Command.new(name, cmd.desc, cmd.syntax.sub(target, name), cmd.block)
end
def run(name, args)
cmd = COMMANDS[name] or die("unknown command '#{name}'")
Env.new(name).instance_exec(args.dup, &cmd.block)
end
end
end
require_relative 'rdb/table'
require_relative 'rdb/env'
Dir[File.join(__dir__, 'rdb/cmd/*.rb')].sort.each { |f| require f }
+479
View File
@@ -0,0 +1,479 @@
# frozen_string_literal: true
# Finding, adding, changing and locking records. Many of these take the name
# of the table instead of reading it on stdin, because they change the file.
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
end
args.shift
end
o
end
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
def each_key(ncols)
if $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
Rdb.register('index', 'Set up table for search', 'index [-m[bhirs]] [-x] [-l] tableorlist [keycol ...]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: index [-m[bhirs]] table [keycolumn]'
data = Rdb::Fast::Data.new(table)
key = args.first || data.head[0]
data.column(key)
Rdb::Fast.build(table, o[:m], key, fold: o[:x])
if o[:l]
Rdb::Fast::Data.new(table).rows.each { |r| warn "#{r.from} #{r.to} 0 0" }
end
end
Rdb.register('search', 'Search a table',
'search [-m[bhirs]] [-hsnx +x] [-l] tableorlist [keycolumn ...] [< keytable]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: search [-m[bhirs]] table [keycolumn ...] < keys'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycols = args.empty? ? [data.head[0]] : args
puts data.head.join("\t"), Rdb::Table.dash_row(data.head) unless o[:h]
each_key(keycols.size) 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|
puts h.row.line
warn "#{h.row.from} #{h.row.to} #{h.xfrom} #{h.xto}" if o[:l]
end
$stdout.flush
end
end
Rdb.register('seek', 'Return the beginning and ending offset of a row', 'seek [-m[bihrs]] [-o outfile] tableorlist [keycol...] < file') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: seek [-m] [-o outfile] table [keycolumn ...]'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycols = args.empty? ? [data.head[0]] : args
each_key(keycols.size) do |key|
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")
end
end
end
end
Rdb.register('append', 'Add a row to a table and update index tables', 'append [-h -m[bhirs]] table [keycolumn ...] < tableorrow') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: append [-h] [-m] table [keycolumn] < tableorrow'
data = Rdb::Fast::Data.new(table)
need_index(table, o[:m])
keycol = args.first || data.head[0]
text = $stdin.read
rows =
if o[:h] then text.lines.map { |l| l.chomp("\n").split("\t", -1) }
else Rdb::Table.parse(text).rows
end
rows.each { |cells| Rdb::Fast.append_row(table, cells, o[:m], keycol) }
end
Rdb.register('delete', 'Blank record and update index file', 'delete [-m[bhirs]] table keycolumn ... < keytable') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: delete [-m] table keycolumn ... < keytable'
need_index(table, o[:m])
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
each_key(keycols.size) do |key|
data = Rdb::Fast::Data.new(table)
Rdb::Fast.search(data, o[:m], keycols, key).each { |h| Rdb::Fast.delete_hit(table, h, o[:m]) }
end
end
Rdb.register('replace', 'Insert a record into a file at specified location',
'replace [-a] [-m[bhirs]] tableorlist from to [xfrom xto] < intableorlist') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: replace [-a] [-m] table from to [xfrom xto] < row'
from, to, xfrom, xto = args.map(&:to_i)
die 'usage: replace [-a] [-m] table from to [xfrom xto] < row' unless to
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))
die 'replace: no input row' if lines.empty?
keycol = Rdb::Fast::Data.new(table).head[0]
newfrom = Rdb::Fast.replace_row(table, from, to, lines.first.split("\t", -1), o[:m], xfrom.to_i, xto.to_i, keycol,
allow_append: !o[:a])
die 'replace: record too big for its slot (-a)' unless newfrom
end
Rdb.register('enter', 'Add rows to a table or list file without an editor', 'enter [-limit] tableorlist') do |args|
limit = args.first&.match?(/\A-\d+\z/) ? args.shift[1..].to_i : nil
file = args.first or die 'usage: enter [-limit] tableorlist'
t = Rdb::Table.open(file)
count = 0
loop do
break if limit && count >= limit
vals = []
i = 0
eof = false
while i < t.head.size
$stdout.print "#{t.head[i]}: "
$stdout.flush
line = $stdin.gets
if line.nil?
eof = true
break
end
line = line.chomp
if line == '^'
i = [i - 1, 0].max
next
end
vals[i] = line
i += 1
end
break if eof || vals.first.to_s.empty?
if t.list
File.open(file, 'a') { |f| t.head.each_with_index { |h, j| f.puts "#{h}\t#{vals[j]}" }; f.puts }
else
Rdb::Fast.append_row(file, vals, 's', t.head[0])
end
count += 1
end
end
# ---- locks -------------------------------------------------------------------
def lock_file(name)
File.join(Dir.tmpdir, "L#{File.basename(name)}")
end
Rdb.register('lock', 'Lock a record or field of a file', 'lock filename processid from to indexfrom indexto') do |args|
die 'usage: lock filename processid from to indexfrom indexto' if args.size < 4
name, pid, from, to = args
lf = lock_file(name)
held = File.exist?(lf) ? File.readlines(lf, chomp: true).map { |l| l.split.map(&:to_i) } : []
clash = held.find { |p, f, t| p != pid.to_i && f < to.to_i && from.to_i < t }
if clash
warn "lock: #{name}: bytes #{from}-#{to} are locked by process #{clash[0]}"
exit 1
end
File.open(lf, 'a') { |f| f.puts args.drop(1).join(' ') }
end
Rdb.register('unlock', 'Unlock a record or field of a file', 'unlock tableorlist processid from to indexfrom indexto') do |args|
die 'usage: unlock filename processid from to indexfrom indexto' if args.size < 4
name = args.shift
lf = lock_file(name)
next unless File.exist?(lf)
keep = File.readlines(lf, chomp: true).reject { |l| l.split.first(3) == args.first(3) }
keep.empty? ? File.delete(lf) : File.write(lf, keep.join("\n") + "\n")
end
Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock tableorlist') do |args|
file = args.first or die 'usage: vilock tableorlist'
locked = File.join(File.dirname(file), "LOCK#{File.basename(file)}")
die "#{file} is locked or missing" if File.exist?(locked) || !File.exist?(file)
File.rename(file, locked)
begin
system(ENV['EDITOR'] || 'vi', locked)
ensure
File.rename(locked, file)
end
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
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(ENV['EDITOR'] || 'vi', f.path)
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] : '' }
end
end
Rdb.register('update.rdb', 'Display and edit records in any sized file',
'update.rdb [-l -m[bhirs] -v] tableorlist [keycolumn ... ]') do |args|
o = fast_opts(args)
table = args.shift or die 'usage: update.rdb [-l -m -v] table [keycolumn]'
need_index(table, o[:m])
keycols = args.empty? ? [Rdb::Fast::Data.new(table).head[0]] : args
cur = nil
help = lambda do
puts ' <Return> or n next record p or - previous record',
' number go to that record /pattern next record matching pattern',
' e edit this record d delete this record',
' anything else look up by key q or CTRL-d quit'
end
loop do
data = Rdb::Fast::Data.new(table)
rows = Rdb::Fast.live_rows(data)
print '> '
$stdout.flush
line = $stdin.gets or break
cmd = line.strip
case cmd
when '', /\An/i then cur = cur ? cur + 1 : 0
when /\A-/, /\Ap/i then cur = [(cur || 1) - 1, 0].max
when /\Aq/i then break
when /\Ah/i then help.call; next
when /\A\d+\z/ then cur = cmd.to_i - 1
when %r{\A/(.*)}
re = Regexp.new(Regexp.last_match(1), Regexp::IGNORECASE)
found = rows.each_index.find { |i| i > (cur || -1) && rows[i].line.match?(re) }
found ? cur = found : (puts 'not found'; next)
when /\Ae/i
next puts('no current record') unless cur && rows[cur]
r = rows[cur]
cells = r.cells
Rdb::Fast.splice(table, r.from, r.to, Rdb::Fast.blanked(r.line) + "\n") unless o[:l]
new = edit_cells(data.head, cells.map(&:strip))
if new
hit = Rdb::Fast::Hit.new(r, 0, 0)
xf, xt = 0, 0
if Rdb::Fast::SUFFIX[o[:m]]
h = Rdb::Fast.search(Rdb::Fast::Data.new(table), o[:m], keycols, cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first
xf, xt = h.xfrom, h.xto if h
end
Rdb::Fast.replace_row(table, hit.row.from, hit.row.to, new, o[:m], xf, xt, keycols[0])
puts 'record replaced'
else
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n")
puts 'unchanged'
end
next
when /\Ad/i
next puts('no current record') unless cur && rows[cur]
h = Rdb::Fast::Hit.new(rows[cur], 0, 0)
if Rdb::Fast::SUFFIX[o[:m]]
h = Rdb::Fast.search(data, o[:m], keycols, rows[cur].cells.values_at(*keycols.map { |c| data.column(c) }), first: true).first || h
end
Rdb::Fast.delete_hit(table, h, o[:m])
puts 'record deleted'
next
else
key = cmd.split("\t", -1)
hit = Rdb::Fast.search(data, o[:m], keycols, key, first: true).first
next puts('not found') unless hit
cur = rows.index { |r| r.from == hit.row.from }
end
if cur && rows[cur]
puts "-- record #{cur + 1} of #{rows.size}"
show_record(data.head, rows[cur].cells)
else
puts 'no such record'
cur = nil
end
end
end
Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [file] -h [file] -a [file] -v [file]]] [-b]') do |args|
file = args.shift or die 'usage: ve table (a plain prompt-driven editor; see update.rdb for fast access)'
auto = args.find { |a| a.match?(/\A-n\d*\z/) }
unless File.exist?(file)
head = Rdb::Table.new(%w[Item], []).head
print 'Column names for the new table, tab separated: '
head = ($stdin.gets || '').chomp.split("\t")
File.write(file, Rdb::Table.new(head, []).to_s)
end
cur = 0
loop do
t = Rdb::Table.open(file)
rows = t.rows
puts "-- #{file}: record #{rows.empty? ? 0 : cur + 1} of #{rows.size}"
show_record(t.head, rows[cur]) if rows[cur]
print '[n]ext [p]rev [g]o N [e]dit [a]dd [d]elete [q]uit > '
$stdout.flush
line = $stdin.gets or break
case line.strip
when 'n', '' then cur = [cur + 1, rows.size - 1].min
when 'p' then cur = [cur - 1, 0].max
when /\Ag\s*(\d+)/ then cur = [[Regexp.last_match(1).to_i - 1, 0].max, rows.size - 1].min
when 'e'
next unless rows[cur]
puts 'field number, then the new value:'
print 'field> '
f = ($stdin.gets || '').to_i - 1
next puts('no such field') unless (0...t.head.size).cover?(f)
print "#{t.head[f]}> "
rows[cur][f] = ($stdin.gets || '').chomp
File.write(file, t.to_s)
when 'a'
row = t.head.each_with_index.map do |h, i|
if i.zero? && auto
start = auto[2..].to_i
next ([rows.map { |r| r[0].to_i }.max.to_i + 1, start].max).to_s
end
print "#{h}> "
($stdin.gets || '').chomp
end
rows << row
File.write(file, t.to_s)
cur = rows.size - 1
when 'd'
rows.delete_at(cur) if rows[cur]
File.write(file, t.to_s)
cur = [cur, rows.size - 1].min
cur = 0 if cur.negative?
when 'q' then break
end
end
end
# ---- checking data ------------------------------------------------------------
Rdb.register('domain', 'Display invalid values in a column', 'domain domaintable [string ...] [< one-column-table]') do |args|
dom = args.shift or die 'usage: domain domaintable [string ...]'
valid = open_table(dom).rows.map { |r| r[0].strip }.to_h { |v| [v, true] }
if args.any?
args.reject { |s| valid[s.strip] }.each { |s| puts s }
else
t = table_in
t.with(rows: t.rows.reject { |r| valid[r[0].strip] }).write
end
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)
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
out, ok = awk_text(prog, t.rows)
$stdout.write out
exit 1 unless ok
end
Rdb.register('fd', 'Test for functional dependency of columns', 'fd determinecolumn dependcolumn < table') do |args|
die 'usage: fd determinecolumn dependcolumn < table' unless args.size == 2
t = table_in
a = t.index_of(args[0])
b = t.index_of(args[1])
seen = {}
ok = t.rows.all? { |r| (seen[r[a]] ||= r[b]) == r[b] }
puts ok ? 'true' : 'false'
exit(ok ? 0 : 1)
end
Rdb.register('searchtree', 'Seek a string node in tree table', 'searchtree [-m[bhirs]] table root goal column1 column2') do |args|
fast_opts(args)
die 'usage: searchtree table root goal column1 column2' unless args.size == 5
file, root, goal, c1, c2 = args
t = Rdb::Table.open(file)
i1 = t.index_of(c1)
i2 = t.index_of(c2)
seen = { root => true }
queue = [root]
found = root == goal
until found || queue.empty?
node = queue.shift
t.rows.each do |r|
next unless r[i1].strip == node
child = r[i2].strip
next if seen[child]
seen[child] = true
found ||= child == goal
queue << child
end
end
puts found ? 'TRUE' : 'FALSE'
exit(found ? 0 : 1)
end
Rdb.register('vindex', 'Create and display ve look-up tables', 'vindex table [ key-column [: decode-column ] ... ]') do |args|
file = args.shift or die 'usage: vindex table [key-column [: decode-column] ...]'
t = Rdb::Table.open(file)
if args.empty?
Dir["#{File.dirname(file)}/*-[AB]"].sort.each { |f| puts "== #{File.basename(f)}"; $stdout.write File.read(f) }
next
end
until args.empty?
key = args.shift
decode = nil
if args.first == ':'
args.shift
decode = args.shift
end
k = t.index_of(key)
prefix = key.tr(' ', '_')[0, 14]
vals = t.rows.map { |r| [r[k].strip, decode ? r[t.index_of(decode)].strip : nil] }.reject { |v, _| v.empty? }.uniq.sort
File.write(File.join(File.dirname(file), "#{prefix}-A"), Rdb::Table.new([key], vals.map { |v, _| [v] }.uniq).to_s)
File.write(File.join(File.dirname(file), "#{prefix}-B"), Rdb::Table.new([key, decode], vals.map { |v, d| [v, d] }).to_s) if decode
end
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
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
end
Rdb.register('timesearch', 'Time the fast-access methods', 'timesearch [ > timesearch.new ]') do |_args|
puts 'method index(s) 200 searches(s) rows found'
time_methods { |m, ti, ts, hits| puts format('-m%-5s %8.4f %15.4f %10d', m, ti, ts, hits) }
end
Rdb.register('testsearch', 'Test the fast-access methods', 'testsearch') do |_args|
time_methods(500) { |m, _ti, _ts, hits| puts "-m#{m}: #{hits.positive? ? 'ok' : 'FAILED'} (#{hits} rows found)" }
end
+409
View File
@@ -0,0 +1,409 @@
# frozen_string_literal: true
# The /act accounting package: general ledger, payroll, inventory, sales.
# Like the original, it is an example of data-base programming with /rdb, not
# 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
fillform foot getjournal invoice sale tax update.inv w2].freeze
def num(str)
str.to_s.strip.empty? ? 0.0 : str.to_s.strip.to_f
end
def money(val)
val == val.to_i ? val.to_i.to_s : format('%.2f', val)
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 account_key(acct)
[acct.to_f, acct]
end
Rdb.register('act', 'List all /act commands', 'act') do |_args|
names = ACT_COMMANDS.sort
w = names.map(&:size).max + 2
names.each_slice(5) { |row| puts row.map { |n| n.ljust(w) }.join.rstrip }
end
Rdb.register('chartdup', 'Check for duplicate names and accounts in chart', 'chartdup') do |_args|
t = need('chart')
{ 'Account' => 'Account numbers', 'Short' => 'Short names' }.each do |col, label|
next unless (i = t.head.index(col))
dups = t.rows.group_by { |r| r[i] }.select { |_, v| v.size > 1 }
next if dups.empty?
puts "Duplicate #{label} in chart. See chart.#{col}"
dups.each_value { |rows| rows.each { |r| puts r.join("\t") } }
end
end
Rdb.register('foot', 'Foot or subtotal Debits and Credit of ledger', 'foot') do |_args|
t = need('ledger')
d = t.index_of('Debit')
c = t.index_of('Credit')
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 }
sub = [''] * t.head.size
sub[0] = rows.first[0]
sub[d] = money(rows.sum { |r| num(r[d]) })
sub[c] = money(rows.sum { |r| num(r[c]) })
out << sub
end
t.with(rows: out).write
end
Rdb.register('adjust', 'Create adjusted trial balance table', 'adjust') do |_args|
tb = need('trialbalance')
ja = need('journaladjust')
meta = {}
(tb.rows.map { |r| tb.head.zip(r).to_h } + ja.rows.map { |r| ja.head.zip(r).to_h }).each do |h|
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(0.0)
[tb, ja].each do |t|
d = t.index_of('Debit')
c = t.index_of('Credit')
t.rows.each { |r| net[r[0]] += num(r[d]) - num(r[c]) }
end
head = tb.head
rows = net.keys.sort_by { |a| account_key(a) }.map do |acct|
n = net[acct]
head.map do |h|
case h
when 'Debit' then n >= 0 ? money(n) : ''
when 'Credit' then n.negative? ? money(-n) : ''
else meta[acct][h].to_s
end
end
end
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
end
Rdb.register('balance', 'Create balance sheet from adjusted trial balance', 'balance') do |_args|
rows = trial_parts(need('adjustedtrial'))
bs = rows.select { |r| r[:fmt].start_with?('B-') }
inc = rows.select { |r| r[:fmt].start_with?('I-') }
assets = bs.select { |r| r[:fmt] == 'B-A' }
others = bs - assets
profit = inc.sum { |r| r[:credit] - r[:debit] }
company = File.exist?('company') ? File.read('company').strip : (ENV['COMPANY'] || 'Company')
out = []
out << company.center(30).rstrip << Date.today.strftime('%-d %B %Y').center(30).rstrip << 'Balancesheet'.center(30).rstrip
assets.each { |r| out << format('%5s %s', money(r[:debit] - r[:credit]), r[:name]) }
total = assets.sum { |r| r[:debit] - r[:credit] }
out << "#{'-' * 5} #{'-' * 24}" << format('%5s Total Assets', money(total))
others.each { |r| out << format('%6s %s', money(r[:credit] - r[:debit]), r[:name]) }
out << format('%6s Retained Earnings (Profit)', money(profit))
out << "#{'-' * 6} #{'-' * 26}"
out << format('%6s Total Liabilities and Equity', money(others.sum { |r| r[:credit] - r[:debit] } + profit))
File.write('balancesheet', out.join("\n") + "\n")
end
Rdb.register('close', 'Close accounting period creating journal for next', 'close') do |_args|
rows = trial_parts(need('adjustedtrial')).select { |r| r[:fmt].start_with?('B-') }
date = next_date
out = rows.map { |r| [r[:acct], date, r[:debit].zero? ? '' : money(r[:debit]), r[:credit].zero? ? '' : money(r[:credit]), 'jl', 'brought forward'] }
net = rows.sum { |r| r[:debit] - r[:credit] }
out << ['4997', date, net.negative? ? money(-net) : '', net.negative? ? '' : money(net), 'jl', 'brought forward']
File.write('journalnext', Rdb::Table.new(%w[Account Date Debit Credit Ref Description], out.map { |r| r }).to_s)
end
Rdb.register('getjournal', 'Copy journals for sales, purchasing, pay systems', 'getjournal journalname') do |args|
die 'usage: getjournal journalname' unless args.size == 1
t = need(args[0])
a = t.index_of('Amount')
total = t.rows.sum { |r| num(r[a]) }
Rdb::Table.new(%w[Date Amount Account Ref Description],
[[next_date, format('%.2f', total), '', t.rows.first&.fetch(t.head.index('Ref') || 0, ''), "#{args[0].delete_prefix('journal')} from #{args[0]}"]]).write
end
Rdb.register('consolidate', 'Combine all subsidiary journals to general journal', 'consolidate nextdate') do |args|
die 'usage: consolidate nextdate' unless args.size == 1
skip = %w[journal journaladjust journalnext journallast journalpay]
files = Dir['journal*'].reject { |f| skip.include?(f) || f.include?('.') }.sort
head = %w[Date Account Debit Credit Ref Description]
rows = []
files.each do |f|
t = Rdb::Table.open(f)
debit_journal = f.match?(/expense|pur|pay/)
t.rows.each do |r|
h = t.head.zip(r).to_h
if t.head.include?('Amount')
if h['Account'].to_s.strip.empty?
warn "consolidate: #{f}: row without an Account skipped"
next
end
amount = money(num(h['Amount']).abs)
h['Debit'], h['Credit'] = debit_journal ? [amount, ''] : ['', amount]
end
rows << head.map { |c| h[c].to_s }
end
end
d = rows.sum { |r| num(r[2]) }
c = rows.sum { |r| num(r[3]) }
rows << [args[0], '1010', '', money(d), '', 'double-entry'] unless d.zero?
rows << [args[0], '1010', money(c), '', '', 'double-entry'] unless c.zero?
File.write('journal', Rdb::Table.new(head, rows).to_s)
end
Rdb.register('fillform', 'Fill a tax from with adjusted trial balance data', 'fillform form') do |args|
die 'usage: fillform form' unless args.size == 1
lines = need("taxform.#{args[0]}")
trial = need('adjustedtrial')
ti = trial.index_of('Taxline')
sums = Hash.new(0.0)
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)
end
Rdb.register('calculate', 'Compute each tax form listed', 'calculate taxform ...') do |args|
die 'usage: calculate taxform ...' if args.empty?
args.each do |form|
t = need(form)
script = "#{form}.calc"
File.exist?(script) or die("#{script}: missing (one 'Column = expression' per line)")
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(File.readlines(script, chomp: true).join('; '), t.head)}; print }"
File.write(form, t.with(rows: awk(prog, t.rows)).to_s)
end
end
Rdb.register('tax', 'Compute tax from income and tax table', 'tax income < taxtable') do |args|
die 'usage: tax income < taxtable' unless args.size == 1
income = args[0].to_f
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)
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 = 0.0
rows = t.rows.map do |r|
r = r.dup
if r[a].strip.empty? && !r[b].strip.empty?
bal = num(r[b])
r[a] = money(bal)
else
bal += num(r[a])
end
r[b] = money(bal)
r
end
t.with(rows: rows).write
end
# ---- payroll -------------------------------------------------------------------------
Rdb.register('calcpay', 'Post payroll to ledgerpay', 'calcpay') do |_args|
t = need('journalpay')
n = t.index_of('Number')
File.write('ledgerpay', t.with(rows: t.rows.each_with_index.sort_by { |r, i| [r[n].to_f, i] }.map(&:first)).to_s)
end
Rdb.register('w2', 'Produce W-2 totals per employee from ledgerpay', 'w2') do |_args|
t = need('ledgerpay')
n = t.index_of('Number')
cols = %w[Gross Federal State Net].select { |c| t.head.include?(c) }
rows = t.rows.group_by { |r| r[n] }.map do |emp, rs|
[emp] + cols.map { |c| money(rs.sum { |r| num(r[t.index_of(c)]) }) }
end
Rdb::Table.new(['Number'] + cols, rows).write
end
# ---- operations -----------------------------------------------------------------------
Rdb.register('bom', 'Produce bill-of-materials from parts list', 'bom') do |_args|
part = need('part')
items = need('saleitem')
qty = Hash.new(0.0)
items.rows.each { |r| qty[r[items.index_of('Code')]] += num(r[items.index_of('Qty')]) }
need_total = Hash.new(0.0)
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)
end
File.write('bom', Rdb::Table.new(%w[Code Qty], need_total.sort.map { |k, v| [k, money(v)] }).to_s)
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(0.0)
queue = [[args[0], args[1].to_f]]
until queue.empty?
part, amount = queue.shift
totals[part] += amount
t.rows.each { |r| queue << [r[1], amount * num(r[2])] if r[0] == part }
end
Rdb::Table.new(%w[Part Amount], totals.sort_by { |k, _| [k.to_f, k] }.map { |k, v| [k, money(v)] }).write
end
# ---- sales ------------------------------------------------------------------------------
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
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
Rdb.register('invoice', 'Print invoice for a sale order', 'invoice') do |_args|
orders = need('salesorder')
items = need('saleitem')
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
loop do
n = ask('Please enter order number (or Return to exit): ')
break if n.empty?
o = orders.rows.find { |r| r[0] == n }
next puts('no such order') unless o
oh = orders.head.zip(o).to_h
puts ' Invoice', ' -------'
puts ENV['COMPANY'] || 'Company'
puts "Order Number #{n}", "Date Ordered #{oh['Date']}", "Date Shipped #{oh['Shipped']}"
puts address_block(customers, oh['Cust'])
mine = items.rows.select { |r| r[0] == n }
items.with(rows: mine).write
puts format('%-40s Gross %.2f', '', num(oh['Gross']))
puts format('%-40s Tax %.2f', '', num(oh['Tax']))
puts format('%-40s Total %.2f', '', num(oh['Total']))
end
end
Rdb.register('cstate', 'Produce customer statement', 'cstate') do |_args|
orders = need('salesorder')
deposits = File.exist?('deposit') ? Rdb::Table.open('deposit') : Rdb::Table.new(%w[Cust Date Amount Ref], [])
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number], [])
loop do
c = ask('Please enter customer number (or all or Return to exit): ')
break if c.empty?
chosen = c == 'all' ? orders.rows.map { |r| r[1] }.uniq : [c]
chosen.each do |cust|
lines = orders.rows.select { |r| r[1] == cust }.map { |r| [cust, r[2], format('%.2f', -num(r[orders.index_of('Total')])), r[0]] } +
deposits.rows.select { |r| r[0] == cust }.map { |r| [cust, r[1], format('%.2f', num(r[2])), r[3]] }
balance = lines.sum { |l| num(l[2]) }
puts ' Customer Statement', ' ------------------'
puts address_block(customers, cust)
puts "Now Due and Payable is your balance of: #{format('%.2f', -balance)}"
puts 'Details of Your Orders and Payments'
Rdb::Table.new(%w[Cust Date Amount Ref], lines.sort_by { |l| l[1] }).write
puts format('%-20s %s', '', format('%.2f', balance))
end
end
end
Rdb.register('sale', 'Enter sale order, and item, update customer', 'sale') do |_args|
products = need('product')
customers = File.exist?('customer') ? Rdb::Table.open('customer') : Rdb::Table.new(%w[Number Company], [])
File.write('salesorder', Rdb::Table.new(%w[Number Cust Date Shipped Gross Tax Total], []).to_s) unless File.exist?('salesorder')
File.write('saleitem', Rdb::Table.new(%w[Order Number Code Backord Qty Price Total Name], []).to_s) unless File.exist?('saleitem')
loop do
cust = ask('Enter Customer Number (? for new, /string search, Return to exit) ')
break if cust.empty?
if cust == '?'
row = customers.head.map { |h| ask("#{h}: ") }
cust = row[0]
customers.rows << row
File.write('customer', customers.to_s)
elsif cust.start_with?('/')
re = Regexp.new(cust[1..], Regexp::IGNORECASE)
customers.rows.select { |r| r.join(' ').match?(re) }.each { |r| puts r.join("\t") }
next
end
puts address_block(customers, cust)
n = Rdb::Table.open('salesorder').rows.map { |r| r[0].to_i }.max.to_i + 1
gross = 0.0
items = []
loop do
code = ask('Code (Return when done): ')
break if code.empty?
p = products.rows.find { |r| r[0] == code }
next puts('no such product') unless p
q = ask('Qty: ').to_i
price = num(p[products.index_of('Price')])
items << [n.to_s, (items.size + 1).to_s, code, '10', q.to_s, money(price), format('%.2f', price * q), p[products.index_of('Name')]]
gross += price * q
end
next if items.empty?
tax = gross * 0.05
order = [n.to_s, cust, Date.today.strftime('%y%m%d'), '0', format('%.2f', gross), format('%.2f', tax), format('%.2f', 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')
File.write('salesorder', Rdb::Table.open('salesorder').tap { |t| t.rows << order }.to_s)
File.write('saleitem', Rdb::Table.open('saleitem').tap { |t| t.rows.concat(items) }.to_s)
end
end
end
Rdb.register('update.inv', 'Multi-user update with screen form and record locking', 'update.inv') do |_args|
file = ENV['INV'] || 'inv'
File.exist?(file) or die("#{file}: no such table (set INV to your inventory table)")
Rdb::Fast.build(file, 'i', 'Item') unless File.exist?("#{file}.i")
puts "Inventory Update #{Date.today.strftime('%a %b %-d, %Y')}"
loop do
item = ask('Item Number: (or hit Return key to exit.) ')
break if item.empty?
data = Rdb::Fast::Data.new(file)
hit = Rdb::Fast.search(data, 'i', ['Item'], item, first: true).first
next puts('no such item') unless hit
pid = Process.pid.to_s
unless system(File.join(Rdb::ROOT, 'bin', 'lock'), file, pid, hit.row.from.to_s, hit.row.to.to_s, hit.xfrom.to_s, hit.xto.to_s)
puts 'record is being updated by someone else'
next
end
begin
cells = hit.row.cells
h = data.head
puts h.zip(cells).map { |a, b| "#{a}=#{b.strip}" }.join(' ')
amount = ask("Amount #{h.include?('Onhand') ? 'Onhand' : 'Amount'}: ")
unless amount.empty?
cells[h.index('Onhand') || h.index('Amount')] = amount
cells[h.index('Value')] = money(num(amount) * num(cells[h.index('Cost')])) if h.include?('Value') && h.include?('Cost')
Rdb::Fast.replace_row(file, hit.row.from, hit.row.to, cells, 'i', hit.xfrom, hit.xto, 'Item')
end
ensure
system(File.join(Rdb::ROOT, 'bin', 'unlock'), file, pid, hit.row.from.to_s, hit.row.to.to_s)
end
end
end
+142
View File
@@ -0,0 +1,142 @@
# frozen_string_literal: true
# Converting tables to other formats, dates, and name matching.
Rdb.register('tabletolist', 'Convert a table to list format', 'tabletolist [ -l ] < table') do |args|
t = table_in
text = t.with(rows: t.rows).tap { |x| x.list = true }.to_s
$stdout.write args.include?('-l') ? text.sub(/\A\n/, '') : text
end
Rdb.register('listtotable', 'Convert from list to table format', 'listtotable [ -el ] < listfile') do |_args|
t = Rdb::Table.parse_list($stdin.read.sub(/\A(?!\n)/, "\n"))
t.list = false
t.write
end
Rdb.register('listtosh', 'Convert list format to shell variable', 'listtosh < list') do |_args|
t = Rdb::Table.parse_list($stdin.read.sub(/\A(?!\n)/, "\n"))
t.rows.each do |r|
t.head.each_with_index { |h, i| puts "#{h.gsub(/\W/, '_')}='#{r[i].gsub("'", %q('\\\\''))}'" }
end
end
Rdb.register('tabletofix', 'Convert /rdb table format to fixed-length format', 'tabletofix column=n[r] ... < tableorlist') do |args|
t = table_in
specs = args.map do |a|
name, w = a.split('=', 2)
die "bad column spec '#{a}'" unless w
[t.index_of(name), w.to_i, w.end_with?('r')]
end
t.rows.each do |r|
next if blank_row?(r)
puts specs.map { |i, w, right| right ? r[i].strip[0, w].rjust(w) : r[i].strip[0, w].ljust(w) }.join
end
end
Rdb.register('fixtotable', 'Converts fixed length format to /rdb table format', 'fixtotable column[=n1,n2] ... < fixtdb') do |args|
specs = args.map do |a|
name, pos = a.split('=', 2)
[name, pos ? pos.split(',').map(&:to_i) : nil]
end
rows = $stdin.each_line.map { |l| l.chomp("\n") }.reject { |l| l.strip.empty? }.map do |l|
specs.map { |_, pos| pos ? (l[pos[0] - 1, pos[1]] || '').strip : '' }
end
Rdb::Table.new(specs.map(&:first), rows).write
end
Rdb.register('tabletostruct', 'Convert table to C-language struct declaration', 'tabletostruct tag name < table > table.h') do |args|
die 'usage: tabletostruct tag name < table' unless args.size == 2
t = table_in
puts "struct #{args[0]}", '{'
t.head.each { |h| puts "char *#{h};" }
puts "} #{args[1]} [] =", '{ ' + t.rows.map { |r| r.map { |c| %("#{c.strip}") }.join(',') }.join(" },\n{ ") + ' }', ';'
end
Rdb.register('tabletotbl', 'Convert /rdb table format to UNIX tbl/nroff format', 'tabletotbl < tablefile > tblfile') do |_args|
t = table_in
fmt = t.head.each_index.map { |i| t.rows.all? { |r| r[i].empty? || numeric?(r[i]) } ? 'n' : 'l' }.join(' ')
puts '.TS', "#{fmt}.", t.head.join("\t"), '_'
t.rows.each { |r| puts r.join("\t") }
puts '.TE'
end
Rdb.register('tabletofact', 'Converts a table to PROLOG fact-file format', 'tabletofact table ... > prologfactfile') do |args|
die 'usage: tabletofact table ...' if args.empty?
args.each do |f|
open_table(f).rows.each { |r| puts "#{File.basename(f)}(#{r.map(&:strip).join(',')})." }
end
end
Rdb.register('tabletorule', 'Convert a table to PROLOG rule-file format', 'tabletorule table ... > prologrulefile') do |args|
die 'usage: tabletorule table ...' if args.empty?
args.each do |f|
open_table(f).rows.each { |r| puts "#{r[0].strip} :- #{r[1].strip}." }
end
end
# ---- dates ------------------------------------------------------------------
{ 'julian' => 'Convert column of dates for arithmetic and format change',
'gregorian' => 'Convert column of dates for arithmetic and format change' }.each do |cmd, desc|
Rdb.register(cmd, desc, "#{cmd} [ -ceu ] [ Column ... ] < tableorlist") do |args|
fmt = 'c'
args.reject! { |a| a.match?(/\A-[ceu]\z/) && (fmt = a[1]) }
t = table_in
sel = args.empty? ? [0] : args.map { |a| t.index_of(a) }
rows = t.rows.map do |r|
r = r.dup
sel.each { |i| r[i] = cmd == 'julian' ? to_julian(r[i], fmt).to_s : from_julian(r[i], fmt) }
r
end
t.with(rows: rows).write
end
end
Rdb.register('computedate', 'Add given number of days to a date', 'computedate date days') do |args|
die 'usage: computedate date days' unless args.size == 2
puts from_julian(to_julian(args[0]) + args[1].to_i)
end
Rdb.register('todaysdate', "Print today's date in YYMMDD format", 'todaysdate') do |_args|
puts Date.today.strftime('%y%m%d')
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?
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
Rdb.register('soundex', 'Convert a name into soundex code', 'soundex < file > file.x ; index -mb file.x Soundex') do |_args|
t = table_in
Rdb::Table.new([t.head[0], 'Soundex'], t.rows.map { |r| [r[0], soundex_code(r[0])] }).write
end
Rdb.register('like', 'Find names that sound like another name', 'like similar-name file [ name-column ]') do |args|
die 'usage: like similar-name file [name-column]' if args.size < 2
t = open_table("#{args[1]}.x")
col = args[2] ? t.index_of(args[2]) : 0
want = soundex_code(args[0])
s = t.index_of('Soundex')
Rdb::Table.new([t.head[col]], t.rows.select { |r| r[s] == want }.map { |r| [r[col]] }).write
end
Rdb.register('hashkey', 'Return the hash offset for key strings', 'hashkey rows keystring ...') do |args|
die 'usage: hashkey rows keystring ...' if args.size < 2
puts args.drop(1).join.delete(" \t").bytes.sum % args[0].to_i
end
+341
View File
@@ -0,0 +1,341 @@
# frozen_string_literal: true
# Selecting, combining and summarising tables.
Rdb.register('row', 'Make a new table where rows match logical condition',
"row 'column condition column-or-value ..' < table") do |args|
die "usage: row 'condition' < table" unless args.size == 1
t = table_in
prog = "BEGIN{OFS=\"\\t\"} #{to_awk(args[0], t.head)} {print}"
t.with(rows: awk(prog, t.rows)).write
end
Rdb.alias_for('select', 'row')
Rdb.register('column', 'Display columns of a table in any order', 'column [ Column ... ] < tableorlist') do |args|
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
end
Rdb.alias_for('project', 'column')
Rdb.register('sorttable', 'Sort a table by one or more columns',
'sorttable [-nrf] [columnname ...] < tableorlist') do |args|
t = table_in
numeric = reverse = fold = false
cols = []
args.each do |a|
if a.match?(/\A-[nrf]+\z/)
numeric ||= a.include?('n')
reverse ||= a.include?('r')
fold ||= a.include?('f')
else
cols << t.index_of(a)
end
end
cols = (0...t.head.size).to_a if cols.empty?
key = ->(r) { cols.map { |i| numeric ? r[i].to_f : (fold ? r[i].downcase : r[i]) } }
sorted = t.rows.each_with_index.sort_by { |r, n| [key.(r), n] }.map(&:first)
sorted.reverse! if reverse
t.with(rows: sorted).write
end
Rdb.register('compute', 'Calculate columns of a table', "compute 'column = expression [ ; ... ]' < tableorlist") do |args|
die "usage: compute 'column = expression [; ...]' < table" unless args.size == 1
t = table_in
prog = "BEGIN{OFS=\"\\t\"} { #{to_awk(args[0], t.head)}; print }"
t.with(rows: awk(prog, t.rows)).write
end
Rdb.register('addcol', 'Add a column to a table', 'addcol newcolumn < table') do |args|
die 'usage: addcol newcolumn ... < table' if args.empty?
t = table_in
t.with(head: t.head + args, rows: t.rows.map { |r| r + [''] * args.size }).write
end
Rdb.register('rename', 'Rename a column', 'rename [ oldcolumn newcolumn ... ] < tableorlist') do |args|
die 'usage: rename oldcolumn newcolumn ... < table' if args.empty? || args.size.odd?
t = table_in
head = t.head.dup
args.each_slice(2) { |old, new| head[t.index_of(old)] = new }
t.with(head: head).write
end
{ 'number' => 'number', 'Number' => 'Number' }.each do |cmd, title|
Rdb.register(cmd, 'Insert a column-row number into a table or list', "#{cmd} < tableorlist") do |_args|
t = table_in
t.with(head: [title] + t.head, rows: t.rows.each_with_index.map { |r, i| [(i + 1).to_s] + r }).write
end
end
Rdb.register('record', 'Find and output a record from a table', 'record number < table') do |args|
die 'usage: record number < table' unless args.size == 1
t = table_in
row = t.rows[args[0].to_i - 1]
t.with(rows: row ? [row] : []).write
end
# ---- combining tables ------------------------------------------------------
Rdb.register('jointable', 'Join two tables into one where keys match',
'jointable [-a[1|2]] [-j[1|2] column]... [-n] [-] table1 [table2]') do |args|
all1 = all2 = false
k1 = k2 = nil
files = []
until args.empty?
a = args.shift
case a
when '-a' then all1 = all2 = true
when '-a1' then all1 = true
when '-a2' then all2 = true
when '-j' then k1 = k2 = args.shift
when '-j1' then k1 = args.shift
when '-j2' then k2 = args.shift
when '-n' then nil
else files << a
end
end
die 'usage: jointable [-a[1|2]] [-j[1|2] column] table1 [table2]' if files.empty? || files.size > 2
files << '-' if files.size == 1
t1, t2 = files.map { |f| open_table(f) }
i1 = k1 ? t1.index_of(k1) : 0
i2 = k2 ? t2.index_of(k2) : 0
keep2 = (0...t2.head.size).to_a - [i2]
by_key = Hash.new { |h, k| h[k] = [] }
t2.rows.each { |r| by_key[r[i2]] << r }
used = {}
out = []
t1.rows.each do |r|
if (m = by_key[r[i1]]).any?
used[r[i1]] = true
m.each { |r2| out << r + keep2.map { |i| r2[i] } }
elsif all1
out << r + [''] * keep2.size
end
end
if all2
t2.rows.each do |r2|
next if used[r2[i2]]
blank = [''] * t1.head.size
blank[i1] = r2[i2]
out << blank + keep2.map { |i| r2[i] }
end
end
Rdb::Table.new(t1.head + keep2.map { |i| t2.head[i] }, out).write
end
Rdb.register('union', 'Concatenate tables', 'union tableorlist ... [-] [< tableorlist]') do |args|
args = ['-'] if args.empty?
tabs = args.map { |a| open_table(a) }
tabs.first.with(rows: tabs.flat_map(&:rows)).write
end
Rdb.register('intersect', 'Write table of rows that are in both input tables', 'intersect table1 table2') do |args|
die 'usage: intersect table1 table2' unless args.size == 2
a, b = args.map { |x| open_table(x) }
seen = b.rows.to_h { |r| [r, true] }
a.with(rows: a.rows.select { |r| seen[r] }.uniq).write
end
Rdb.register('difference', 'Output table of rows that are in only one table', 'difference table1 table2') do |args|
die 'usage: difference table1 table2' unless args.size == 2
a, b = args.map { |x| open_table(x) }
sb = b.rows.to_h { |r| [r, true] }
a.with(rows: a.rows.reject { |r| sb[r] }.uniq).write
end
Rdb.register('paste.rdb', 'Paste together two or more tables', 'paste.rdb table1 table2 ...') do |args|
die 'usage: paste.rdb table1 table2 ...' if args.size < 2
tabs = args.map { |a| open_table(a) }
n = tabs.map { |t| t.rows.size }.max
head = tabs.flat_map(&:head)
rows = (0...n).map { |i| tabs.flat_map { |t| t.rows[i] || [''] * t.head.size } }
Rdb::Table.new(head, rows).write
end
Rdb.register('splittable', 'Divide a table horizontally', 'splittable [-n] table') do |args|
n = 50
n = args.shift[1..].to_i if args.first&.match?(/\A-\d+\z/)
die 'usage: splittable [-n] table' unless args.size == 1
t = open_table(args[0])
suffix = ('aa'..'zz').each
t.rows.each_slice(n) do |chunk|
File.write("#{args[0]}#{suffix.next}", t.with(rows: chunk).to_s)
end
end
# ---- aggregates ------------------------------------------------------------
# total, mean, maximum, minimum, width, precision and datatype are one program
# that decides what to compute from the name it was called by.
STATS = {
'total' => 'Sum a column',
'mean' => 'Display the mean of a column',
'maximum' => 'Display the maximum value in a column',
'minimum' => 'Display the minimum of each column selected',
'width' => 'Display the width of each column',
'precision' => 'Display the precision of a column',
'datatype' => 'Display the data type of each column selected'
}.freeze
STATS.each do |cmd, desc|
Rdb.register(cmd, desc, "#{cmd} [ -l ] [ column ... ] < table") do |args|
list = args.first == '-l'
args.shift if list
t = table_in
sel = args.empty? ? (0...t.head.size).to_a : args.map { |a| t.index_of(a) }
result = Array.new(t.head.size, '')
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.to_f } : nil
dec = filled.map { |v| decimals(v) }.max.to_i
result[i] =
case name
when 'width' then (vals.map(&:size) + [t.head[i].size]).max.to_s
when 'precision' then nums ? dec.to_s : ''
when 'datatype' then filled.empty? || !nums ? '-1' : dec.to_s
when 'total' then nums ? fmt(nums.sum, dec) : ''
when 'mean' then nums && !nums.empty? ? fmt(nums.sum / nums.size, [dec, 2].max) : ''
when 'maximum' then nums && !nums.empty? ? fmt(nums.max, dec) : ''
when 'minimum' then nums && !nums.empty? ? fmt(nums.min, dec) : ''
end
end
if list
$stdout.write t.to_s
puts Rdb::Table.dash_row(t.head)
puts result.join("\t")
else
Rdb::Table.new(t.head, [result]).write
end
end
end
Rdb.register('widest', 'Output the width of the widest entries in a table', 'widest < table') do |_args|
t = table_in
res = t.head.each_index.map { |i| (t.rows.map { |r| r[i].size } + [t.head[i].size]).max.to_s }
Rdb::Table.new(t.head, [res]).write
end
Rdb.register('subtotal', 'Output subtotals of columns in a table',
'subtotal [ -l ] [ break-column column ... ] < table') do |args|
list = args.first == '-l'
args.shift if list
t = table_in
brk = args.empty? ? 0 : t.index_of(args.shift)
sel = args.empty? ? (0...t.head.size).to_a - [brk] : args.map { |a| t.index_of(a) }
out = []
group = []
flush = lambda do
next if group.empty?
sub = Array.new(t.head.size, '')
sub[brk] = group.first[brk]
sel.each do |i|
vals = group.map { |r| r[i] }.reject { |v| v.strip.empty? }
next if vals.empty? || !vals.all? { |v| numeric?(v) }
sub[i] = fmt(vals.sum { |v| v.strip.to_f }, vals.map { |v| decimals(v) }.max)
end
if list
out.concat(group)
out << t.head.map { |h| '-' * [h.size, 1].max }
end
out << sub
group = []
end
t.rows.each do |r|
flush.() if group.any? && group.first[brk] != r[brk]
group << r
end
flush.()
t.with(rows: out).write
end
# ---- describing tables ------------------------------------------------------
Rdb.register('schema', "Print a table's schema", 'schema tableorlist ...') do |args|
die 'usage: schema tableorlist ...' if args.empty?
rows = args.flat_map do |f|
open_table(f).head.each_with_index.map { |h, i| [File.basename(f), (i + 1).to_s, h] }
end
Rdb::Table.new(%w[Table Field Name], rows).write
end
Rdb.register('dbdict', 'Print a data-base dictionary', 'dbdict < table') do |_args|
t = table_in
Rdb::Table.new(%w[field name], t.head.each_with_index.map { |h, i| [(i + 1).to_s, h] }).write
end
Rdb.register('tableorlist', 'Report whether a file has table or list format',
'tableorlist [tableorlist ...] [< tableorlist]') do |args|
texts = args.empty? ? [$stdin.read] : args.map { |a| File.read(a) }
status = 0
texts.each do |tx|
if tx.start_with?("\n")
puts 'list'
status = 1
else
puts 'table'
end
end
exit status
end
Rdb.register('check.rdb', 'Report any rows in which columns do not match head line', 'check.rdb < tableorlist') do |_args|
lines = $stdin.read.lines.map { |l| l.chomp("\n") }
expected = lines.first.to_s.count("\t") + 1
bad = 0
lines.each_with_index do |l, n|
next if n.zero? || l.count("\t") + 1 == expected
bad += 1
warn "check.rdb: Columns (#{l.count("\t") + 1}) do not equal headline columns (#{expected}) in row #{n - 1} (line #{n + 1})."
warn l
end
exit(bad.zero? ? 0 : 1)
end
# ---- head and dash lines -------------------------------------------------
Rdb.register('headon', 'Add an /rdb header to a table', 'headon [column ...] < table') do |args|
rows = $stdin.each_line.map { |l| l.chomp("\n").split("\t", -1) }
n = rows.map(&:size).max || 0
head = args.dup
head << (head.size + 1).to_s while head.size < n
Rdb::Table.new(head, rows.map { |r| r.fill('', r.size...head.size) }).write
end
Rdb.register('headoff', 'Remove an /rdb head from both table and list file', 'headoff < tableorlist') do |_args|
text = $stdin.read
lines = text.lines
$stdout.write lines.drop(text.start_with?("\n") ? 1 : 2).join
end
Rdb.register('insertdash', 'Insert dash line as second line in table', 'insertdash < table-without-dashline') do |_args|
lines = $stdin.read.lines
head = lines.shift.to_s.chomp("\n")
puts head
puts head.gsub(/[^\t]/, '-')
$stdout.write lines.join
end
Rdb.register('display', 'Write table or list file to standard output', 'display < tableorlist') do |_args|
$stdout.write $stdin.read
end
Rdb.register('see', 'Display nonprinting as well as printing characters', 'see < tableorlist') do |_args|
$stdin.read.each_char do |c|
o = c.ord
$stdout.write(
if c == "\n" then "$\n"
elsif o < 32 then "^#{(o + 64).chr}"
elsif o == 127 then '^?'
else c
end
)
end
end
+99
View File
@@ -0,0 +1,99 @@
# frozen_string_literal: true
# Reports, forms and mailing lists.
# Fill in a form for one row: <Column> is replaced by the value, <!command!>
# by the output of the shell command, and <!cmd <Column>!> passes data to it.
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]*)>/) { 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
Rdb.register('report', 'Write reports using a form and a table', 'report form < tableorlist') do |args|
die 'usage: report form < tableorlist' unless args.size == 1
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
form = File.read(args[0])
t = table_in
t.rows.each { |r| $stdout.write fill_form(form, t.head, r) }
end
Rdb.register('reportwriter', 'Sample program to write standard reports', 'reportwriter [-lines] [title] < table') do |args|
lines = args.first&.match?(/\A-\d+\z/) ? args.shift[1..].to_i : 20
title = args.join(' ')
t = table_in
date = Date.today.strftime('%-d %b %Y')
w = t.head.each_index.map { |i| (t.rows.map { |r| r[i].strip.size } + [t.head[i].size]).max }
line = ->(cells) { cells.each_with_index.map { |c, i| c.strip.ljust(w[i]) }.join(' ').rstrip }
pages = t.rows.each_slice(lines).to_a
pages = [[]] if pages.empty?
pages.each_with_index do |chunk, n|
puts title.center(w.sum + 3 * (w.size - 1)).rstrip unless title.empty?
puts "Report Date: #{date}".center(w.sum + 3 * (w.size - 1)).rstrip, ''
puts line.(t.head), line.(t.head.map { |h| '-' * h.size })
chunk.each { |r| puts line.(r) }
puts '', " Page #{n + 1}"
puts "\f" if n < pages.size - 1
end
end
Rdb.register('screen', 'Convert a form into a screen-input shell program', 'screen < form > shellprogram') do |_args|
names = []
reads = []
painted = $stdin.read.lines.each_with_index.map do |line, n|
line = line.chomp("\n")
out = line.dup
line.scan(/<!(.*?)!>|<([^<>!]*)>/) do
m = Regexp.last_match
col = m.begin(0) + 1
if m[1]
reads << format('cursor %2d %2d ; %s;', n + 1, col, m[1].strip)
else
names << m[2].strip
reads << format('cursor %2d %2d ; read %s;', n + 1, col, m[2].strip)
end
out[m.begin(0), m[0].size] = ' ' * m[0].size
end
out
end
puts ': paint crt screen', 'exec 1>/dev/tty', 'cat <<SCREEN', "${CLEAR}#{painted.join("\n")}", 'SCREEN'
puts ': read user input', *reads
puts ': output table head', 'exec 1>&2'
puts %(echo "#{names.join("\t")}"), %(echo "#{names.map { |x| '-' * x.size }.join("\t")}")
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
end
Rdb.register('label', 'Print mailing labels from a mailing list', 'label < list') do |_args|
t = table_in
t.rows.each { |r| puts label_lines(t.head, r) }
end
Rdb.register('letter', 'Print form letters from a mailing list', 'letter letter.1 ... < maillist') do |args|
die 'usage: letter letter.1 letter.2 < maillist' if args.empty?
parts = args.map { |f| File.readlines(f, chomp: true) }
t = table_in
wrap = lambda do |text|
text.split.each_with_object(['']) { |w, ls| (ls.last + ' ' + w).strip.size > 65 ? ls << w : ls[-1] = (ls.last + ' ' + w).strip }
end
t.rows.each do |r|
h = t.head.zip(r).to_h
puts label_lines(t.head, r), '', "Dear #{h['Name']}:", ''
first = parts.first.join(' ')
rest = parts.drop(1).flatten
close = rest.index { |l| l.end_with?(',') } || rest.size
para = [first, "#{h['Company']}.", rest.first(close).join(' ')].reject(&:empty?).join(' ')
puts wrap.(para), *rest.drop(close), "\n"
end
end
+238
View File
@@ -0,0 +1,238 @@
# frozen_string_literal: true
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"
}.freeze
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*/, '')
end
Rdb.register('rdb.list', 'List all /rdb programs', 'rdb') do |_args|
names = command_rows.map(&:name)
w = names.map(&:size).max + 2
cols = [80 / w, 1].max
names.each_slice((names.size / cols.to_f).ceil).to_a.then do |columns|
columns.first.each_index { |i| puts columns.map { |c| (c[i] || '').ljust(w) }.join.rstrip }
end
end
Rdb.alias_for('rdb', 'rdb.list')
Rdb.register('rdb.install', 'Symlink every command into a directory', 'rdb install [directory]') do |args|
me = File.realpath(File.join(Rdb::ROOT, 'bin', 'rdb'))
dir = File.expand_path(args.first || File.dirname(me))
FileUtils.mkdir_p(dir)
made = 0
command_rows.each do |c|
link = File.join(dir, c.name)
next if File.exist?(link) || File.symlink?(link)
File.symlink(me, link)
made += 1
end
puts "linked #{made} new commands into #{dir} (#{command_rows.size} total)"
end
Rdb.register('commands', 'Describe /rdb commands', 'commands') do |_args|
Rdb::Table.new(%w[Command Description Syntax], command_rows.map { |c| [c.name, c.desc, syntax_of(c)] }).write
end
Rdb.register('helpme', 'List the help commands available', 'helpme') do |_args|
rows = [['menu', 'a menu with some commands'], ['rdb', 'a list of the available commands'],
['commands', 'description and syntax of all the commands'],
['whatis command', 'description and syntax of command'],
['whatwill feature', 'info on commands with that feature'],
['rdb man command', 'the manual page for that command']]
Rdb::Table.new(%w[Command Description], rows.map { |a, b| [%("#{a}"), b] }).write
end
show_cmd = lambda do |c|
puts format(' %-9s - %s', c.name, c.desc.sub(/\A./, &:downcase))
puts format(' %-9s %s', c.name, c.syntax)
end
Rdb.register('whatis', 'Display the command description and syntax', 'whatis command') do |args|
die 'usage: whatis command' if args.empty?
args.each { |a| (c = Rdb::COMMANDS[a]) ? show_cmd.(c) : warn("whatis: #{a}: not an /rdb command") }
end
Rdb.register('whatwill', 'Display commands with functions in description', 'whatwill feature') do |args|
die 'usage: whatwill feature' if args.empty?
re = Regexp.new(Regexp.escape(args.join(' ')), Regexp::IGNORECASE)
command_rows.select { |c| [c.name, c.desc, c.syntax].any? { |s| s.match?(re) } }.each { |c| show_cmd.(c) }
end
Rdb.register('howmany', 'Display the number of commands', 'howmany') do |_args|
puts command_rows.size
end
# ---- manual pages ---------------------------------------------------------------
def troff_clean(str)
str.gsub(/\\f[BIRP]/, '').gsub('\\e', '\\').gsub('\\(em', ' -- ').gsub('\\(aa', "'")
.gsub('\\-', '-').gsub('\\&', '').gsub('\\*(UX', 'UNIX').gsub('``', '"').gsub("''", '"').gsub('\\ ', ' ')
.gsub(/\\\(..|\\\*\(..|\\[a-z]/, '').gsub('\\\\', '\\')
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
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
flush.()
out.join("\n").squeeze("\n")
end
Rdb.register('man', 'Display the manual page for a command', 'man [-g] command') do |args|
groff = args.first == '-g'
args.shift if groff
die 'usage: rdb man [-g] command (-g formats with groff)' if args.empty?
if groff
args.each do |a|
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
File.exist?(f) or die("no manual page for '#{a}'")
cmd = "groff -man -mrdb -M#{Rdb::ROOT}/man -Tutf8 #{f.shellescape}"
$stdout.tty? ? system("#{cmd} | #{ENV['PAGER'] || 'less -R'}") : system(cmd)
end
next
end
args.each do |a|
f = File.join(Rdb::ROOT, 'man', "#{a == 'Number' ? 'number.c' : a}.man")
File.exist?(f) or die("no manual page for '#{a}'")
desc = File.read(f)[/^\.PC\s+"?(.*?)"?\s*$/, 1]
puts "#{a}#{desc ? " -- #{troff_clean(desc)}" : ''}", '=' * 60
puts render_man(File.read(f)), ''
end
end
# ---- terminal ---------------------------------------------------------------------
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]}'" }
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}:"
end
Rdb.register('clear.rdb', "Clear the terminal's screen", 'clear.rdb') do |_args|
$stdout.print 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.flush
end
Rdb.register('menu', 'Root menu with some commands', 'menu') do |_args|
items = [['exit', 'leave menu or return to higher menu', nil],
['sh', 'get a shell', ENV['SHELL'] || 'sh'],
['vi', 'edit a file', ENV['EDITOR'] || 'vi'],
['cal', 'see your calendar', 'cal'],
['who', 'see who is on the system', 'who'],
['ls', 'list the files in this directory', 'ls'],
['cat', 'display a file on the screen', 'cat'],
['rdb', 'display rdb commands', 'rdb']]
loop do
Rdb::Table.new(%w[Number Name For], items.each_with_index.map { |(n, d, _), i| [i.to_s, n, d] }).write
print 'Enter a number or name for the action you wish or DEL to exit: '
$stdout.flush
choice = ($stdin.gets || '').strip
break if choice.empty? || %w[0 exit].include?(choice)
item = items[choice.to_i] if choice.match?(/\A\d+\z/)
item ||= items.find { |n, _, _| n == choice }
next puts('?') unless item
cmd = item[2]
if item[0] == 'cat'
print 'file: '
f = ($stdin.gets || '').strip
system('cat', f) unless f.empty?
elsif item[0] == 'vi'
print 'file: '
f = ($stdin.gets || '').strip
system(cmd, f) unless f.empty?
elsif item[0] == 'rdb'
Rdb.run('rdb.list', [])
else
system(cmd)
end
end
end
# ---- housekeeping --------------------------------------------------------------------
Rdb.register('rmcore', 'Remove all core files', 'rmcore') do |_args|
Dir.glob('**/core').select { |f| File.file?(f) }.each { |f| puts "removed #{f}"; File.delete(f) }
end
Rdb.register('cpdir.rdb', 'Copy one directory tree to another directory', 'cpdir.rdb fromdirectory todirectory') do |args|
die 'usage: cpdir.rdb fromdirectory todirectory' unless args.size == 2
FileUtils.mkdir_p(args[1])
FileUtils.cp_r(File.join(args[0], '.'), args[1])
end
Rdb.register('testall', 'Test all /rdb programs in directory $RDB/demo', 'testall [ > testall.new ]') do |_args|
ENV['PATH'] = "#{File.join(Rdb::ROOT, 'bin')}:#{ENV['PATH']}"
demo = File.join(ENV['RDB'] || Rdb::ROOT, 'demo')
Dir.chdir(demo)
script = File.join(demo, 'testall.cmds')
File.exist?(script) or die "#{script}: missing"
File.readlines(script, chomp: true).reject { |l| l.strip.empty? || l.start_with?('#') }.each do |cmd|
puts "$ #{cmd}"
$stdout.flush
puts `#{cmd} 2>&1`
end
end
+239
View File
@@ -0,0 +1,239 @@
# frozen_string_literal: true
# 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?
s = i.rjust(int_w)
s += f ? ".#{f.ljust(frac_w)}" : ' ' * (frac_w.zero? ? 0 : frac_w + 1)
s
end
end
Rdb.register('justify', 'Left or right justify the columns of a table', "justify [-elrct'c'] [ column ... ] < table") do |args|
expand = false
mode = nil
sep = "\t"
names = []
args.each do |a|
case a
when '-e' then expand = true
when '-l', '-r', '-c' then mode = a[1]
when /\A-t(.)\z/ then sep = Regexp.last_match(1)
else names << a
end
end
t = table_in
forced = {}
sel = names.empty? ? (0...t.head.size).to_a : names.map { |n| t.index_of(n) }
sel.each { |i| forced[i] = mode } if mode
cols = t.head.each_index.map { |i| t.rows.map { |r| r[i].strip } }
head = t.head.map(&:strip)
new_cols = []
widths = []
t.head.each_index do |i|
unless names.empty? || sel.include?(i)
new_cols << cols[i]
widths << nil
next
end
filled = cols[i].reject(&:empty?)
numeric = !filled.empty? && filled.all? { |c| c.match?(Rdb::NUM) }
style = forced[i] || (numeric ? 'r' : 'l')
cells = (numeric && style == 'r') ? justify_numbers(cols[i]) : cols[i]
w = (cells.map(&:size) + [head[i].size]).max
w = ((w + 1 + 7) / 8 * 8 - 1) if expand
new_cols << cells.map do |c|
case style
when 'r' then c.rjust(w)
when 'c' then c.center(w)
else c.ljust(w)
end
end
head[i] = case style
when 'r' then head[i].rjust(w)
when 'c' then head[i].center(w)
else head[i].ljust(w)
end
widths << w
end
dash = head.each_index.map { |i| '-' * (widths[i] || [head[i].size, 1].max) }
puts head.join(sep)
puts dash.join(sep)
t.rows.each_index { |r| puts new_cols.map { |c| c[r] }.join(sep) }
end
Rdb.register('trim', 'Trim excess white space from a table', "trim [-[l|n|ln] col...][-r[n] col...][-t'c']... < table") do |args|
sep = ' '
limits = {}
mode = nil
n = nil
t = table_in
args.each do |a|
case a
when /\A-t(.)\z/ then sep = Regexp.last_match(1)
when /\A-(l?)(\d+)\z/ then mode = :left; n = Regexp.last_match(2).to_i
when /\A-r(\d+)\z/ then mode = :right; n = Regexp.last_match(1).to_i
else limits[t.index_of(a)] = [mode, n] if mode
end
end
cols = t.head.each_index.map do |i|
cells = t.rows.map { |r| r[i].strip }
if (m, k = limits[i])
cells = cells.map { |c| m == :right ? c[-k..] || c : c[0, k] }
end
cells
end
heads = t.head.map(&:strip)
widths = t.head.each_index.map { |i| (cols[i].map(&:size) + [heads[i].size]).max }
puts heads.each_with_index.map { |h, i| h.ljust(widths[i]) }.join(sep)
puts widths.map { |w| '-' * w }.join(sep)
t.rows.each_index { |r| puts cols.each_with_index.map { |c, i| c[r].ljust(widths[i]) }.join(sep) }
end
Rdb.register('compresss', 'Squeeze out all leading and trailing blanks', 'compresss [-b] < tableorlist') do |args|
drop = args.include?('-b')
$stdin.each_line do |l|
l = l.chomp("\n")
next if drop && l.strip.empty?
puts l.split("\t", -1).map(&:strip).join("\t")
end
end
Rdb.register('pad', 'Add extra spaces at end of last column', 'pad [-number] < tableorlist') do |args|
n = args.first&.match?(/\A-\d+\z/) ? args.first[1..].to_i : 70
t = table_in
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"
t.rows.each { |r| puts (r[0...-1] + [r.last + ' ' * n]).join("\t") }
end
end
Rdb.register('rmblank', 'Remove blank rows from a table', 'rmblank < table') do |_args|
t = table_in
t.with(rows: t.rows.reject { |r| blank_row?(r) }).write
end
Rdb.register('blank', 'Replace all data in a record with spaces', 'blank < tableorlist') do |_args|
t = table_in
t.with(rows: t.rows.map { |r| r.map { |c| ' ' * c.size } }).write
end
Rdb.register('cap', 'Convert first letter of each word to upper case', 'cap < textfile') do |_args|
$stdout.write $stdin.read.gsub(/\b[a-z]/, &:upcase)
end
Rdb.register('uppercase', 'Convert input to all upper-case characters', 'uppercase [string ...] [< file]') do |args|
puts args.empty? ? $stdin.read.upcase : args.join(' ').upcase
end
Rdb.register('lowercase', 'Transform text to lower case', 'lowercase [string ...] [< file]') do |args|
puts args.empty? ? $stdin.read.downcase : args.join(' ').downcase
end
Rdb.register('substitute', 'Replace old string with new string', 'substitute oldstring newstring file ...') do |args|
die 'usage: substitute oldstring newstring file ...' if args.size < 3
old, new, *files = args
files.each { |f| File.write(f, File.read(f).gsub(old, new)) }
end
Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args|
puts (args.empty? ? $stdin.read : args.join(' ')).strip
end
Rdb.register('padstring', 'Return string with blanks to fill a field', 'padstring [-]length string') do |args|
die 'usage: padstring [-]length string' unless args.size == 2
w = args[0].to_i
puts w.negative? ? args[1].ljust(-w) : args[1].rjust(w)
end
Rdb.register('length', 'Return the length of its argument or input file', 'length [string] [< file]') do |args|
puts args.empty? ? $stdin.read.bytesize : args[0].size
end
Rdb.register('filesize', 'Return the number of characters in a file', 'filesize file ...') do |args|
die 'usage: filesize file ...' if args.empty?
if args.size == 1
puts File.size(args[0])
else
Rdb::Table.new(%w[Offset Filename], args.map { |f| [File.size(f).to_s, f] }).write
end
end
Rdb.register('ascii', 'Return the ASCII value of a character', 'ascii character...') do |args|
die 'usage: ascii character...' if args.empty?
puts args[0].bytes.join(' ') + ' '
end
Rdb.register('chr', 'Display the character corresponding to a number', 'chr number ...') do |args|
die 'usage: chr number ...' if args.empty?
puts args.map { |a| a.to_i.chr }.join
end
Rdb.register('word', 'Convert text file into list of unique words', 'word < text > table') do |_args|
puts $stdin.read.scan(/[A-Za-z0-9_']+/).uniq.sort
end
Rdb.register('prompt', 'Echo a string on the standard output', 'prompt string ...') do |args|
$stdout.print args.join(' ')
end
Rdb.register('not', 'Logical not, to reverse return status of command', 'not command ...') do |args|
die 'usage: not command ...' if args.empty?
system(*args)
exit($?.success? ? 255 : 0)
end
Rdb.register('path', 'Find the full path of a command', 'path [ command ... ]') do |args|
args.each do |c|
dir = ENV['PATH'].to_s.split(':').find { |d| File.executable?(File.join(d, c)) && !File.directory?(File.join(d, c)) }
puts dir if dir
end
end
# ---- translation tables -----------------------------------------------------
Rdb.register('tabletom4', 'Convert a table to m4 define-file format', 'tabletom4 < table > definefile') do |_args|
table_in.rows.each { |r| puts "define(#{r[0].strip},#{r[1].strip})" unless r[1].to_s.strip.empty? }
end
Rdb.register('tabletosed', 'Convert table format to sed file format', 'tabletosed < table > sedfile') do |_args|
table_in.rows.each { |r| puts "s/#{r[0].strip}/#{r[1].strip}/g" unless r[1].to_s.strip.empty? }
end
Rdb.register('translate', 'Word-for-word substitution using a translation file', 'translate language < text > translatedtext') do |args|
die 'usage: translate language < text' unless args.size == 1
lang = args[0]
text = $stdin.read
sed = lambda do |file, s|
File.readlines(file, chomp: true).reduce(s) do |acc, l|
m = l.match(%r{\As/(.*?)/(.*?)/g\z})
m ? acc.gsub(m[1], m[2]) : acc
end
end
text = sed.("#{lang}.sed.1", text) if File.exist?("#{lang}.sed.1")
if File.exist?(lang)
defs = File.readlines(lang, chomp: true).filter_map { |l| l.match(/\Adefine\((.*?),(.*)\)\z/)&.captures }.to_h
re = Regexp.union(defs.keys.sort_by { |k| -k.size }.map { |k| /\b#{Regexp.escape(k)}\b/ })
text = text.gsub(re) { |w| defs[w] } unless defs.empty?
end
text = sed.("#{lang}.sed.2", text) if File.exist?("#{lang}.sed.2")
$stdout.write text
end
Rdb.register('uniondict', 'Combine three tables into translation dictionary', 'uniondict langtolang > definefile') do |args|
die 'usage: uniondict langtolang' unless args.size == 1
l = args[0]
low = Rdb::Table.open("#{l}.low")
cap = Rdb::Table.open("#{l}.allcap")
initial = low.rows.map { |r| r.map(&:capitalize) }
(low.rows + initial + cap.rows).each { |r| puts "define(#{r[0].strip},#{r[1].strip})" unless r[1].to_s.strip.empty? }
end
+133
View File
@@ -0,0 +1,133 @@
# frozen_string_literal: true
module Rdb
# Command blocks run inside an Env, which holds the helpers they share.
class Env
attr_reader :name
def initialize(name)
@name = name
end
def die(msg)
Rdb.die(msg)
end
def table_in
Table.read($stdin)
end
def open_table(path)
File.exist?(path) || path == '-' or die("#{path}: no such table")
Table.open(path)
end
def numeric?(str)
str.match?(NUM)
end
def decimals(str)
str.strip[/\.(\d+)\z/, 1].to_s.size
end
def fmt(num, dec)
dec.zero? ? num.round.to_s : format("%.#{dec}f", num)
end
def blank_row?(row)
row.all? { |c| c.strip.empty? }
end
# Replace column names in an awk expression with $N, leaving strings,
# regexes and other identifiers alone.
def to_awk(expr, head)
names = head.each_with_index.sort_by { |n, _| -n.size }
out = +''
i = 0
while i < expr.size
c = expr[i]
prev = out.rstrip[-1].to_s
if c == '"'
j = i + 1
j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '"'
out << expr[i..j]
i = j + 1
elsif c == '/' && (prev.empty? || '~(!&|,={;'.include?(prev))
j = i + 1
j += expr[j] == '\\' ? 2 : 1 while j < expr.size && expr[j] != '/'
out << expr[i..j]
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/) }
if hit
out << "$#{hit[1] + 1}"
i += hit[0].size
else
m = expr[i..][/\A\w+/] || c
out << m
i += m.size
end
else
out << c
i += 1
end
end
out
end
# 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.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?
out, err, st = Open3.capture3('awk', '-F', "\t", prog, stdin_data: input)
$stderr.print err unless err.empty?
[out, st.success?]
end
def sh(cmd, stdin: nil)
out, _err, _st = Open3.capture3('sh', '-c', cmd, stdin_data: stdin.to_s)
out
end
# YYMMDD <-> day number. The day number counts from the same epoch as
# /rdb's julian command (1986-12-22 is 1752827).
JULIAN_OFFSET = 693_960
def to_julian(str, fmt = 'c')
s = str.strip
return '' if s.empty?
y, m, d =
case fmt
when 'e' then s.split('/').values_at(2, 1, 0).map(&:to_i)
when 'u' then s.split('/').values_at(2, 0, 1).map(&:to_i)
else
s.size == 8 ? [s[0, 4], s[4, 2], s[6, 2]].map(&:to_i) : [s[0, 2], s[2, 2], s[4, 2]].map(&:to_i)
end
y += (y >= 50 ? 1900 : 2000) if y < 100
(Date.new(y, m, 1) + (d - 1)).jd - JULIAN_OFFSET
end
def from_julian(num, fmt = 'c')
s = num.to_s.strip
return '' if s.empty?
dt = Date.jd(s.to_i + JULIAN_OFFSET)
case fmt
when 'e' then format('%02d/%02d/%02d', dt.day, dt.month, dt.year % 100)
when 'u' then format('%02d/%02d/%02d', dt.month, dt.day, dt.year % 100)
else format('%02d%02d%02d', dt.year % 100, dt.month, dt.day)
end
end
end
end
+331
View File
@@ -0,0 +1,331 @@
# frozen_string_literal: true
module Rdb
# Fast-access methods for tables. Rows are found by byte offset, so a record
# can be blanked or replaced in place without rewriting the file.
#
# -ms sequential (no index file) -mb binary search on a sorted table
# -mr record numbers table.r -mi inverted index table.i
# -mh hash table.h
#
# table.r and table.h hold fixed-width entries (a head line, a dash line,
# then "%8d\n" per entry), so one entry can be rewritten in place.
module Fast
ENTRY_WIDTH = 9
SUFFIX = { 'h' => '.h', 'i' => '.i', 'r' => '.r' }.freeze
FIXED_HEAD = "Offset\n------\n"
Row = Struct.new(:from, :to, :line) do
def cells
@cells ||= line.split("\t", -1)
end
def blank?
line.delete("\t ").empty?
end
end
Hit = Struct.new(:row, :xfrom, :xto)
# A table file read as bytes with the offset of every row.
class Data
attr_reader :path, :head, :rows
def initialize(path)
@path = path
File.exist?(path) or Rdb.die("#{path}: no such table")
bytes = File.binread(path)
lines = []
off = 0
bytes.each_line("\n") do |l|
lines << Row.new(off, off + l.bytesize, l.chomp("\n"))
off += l.bytesize
end
Rdb.die("#{path}: is a list; fast access needs a table") if bytes.start_with?("\n")
@head = lines.shift&.line.to_s.split("\t", -1)
lines.shift if lines.first && Table.dash_line?(lines.first.cells)
@rows = lines
@by_from = lines.to_h { |r| [r.from, r] }
end
def live
@live ||= @rows.reject(&:blank?)
end
def column(name)
@head.index(name) || Rdb.die("#{path}: no column named '#{name}'")
end
def row_at(offset)
@by_from[offset]
end
end
module_function
def index_path(path, method)
SUFFIX[method] ? path + SUFFIX[method] : nil
end
def key_of(cells, idx)
cells[idx].to_s
end
def norm(str, fold)
s = str.to_s.strip
fold ? s.downcase : s
end
def fmt_entry(num)
format("%8d\n", num)
end
def hash_slot(slots, key)
key.delete(" \t").bytes.sum % slots
end
def live_rows(data)
data.live
end
# ---- building indexes -------------------------------------------------
def build(path, method, keycol, fold: false)
data = Data.new(path)
rows = live_rows(data)
case method
when 'r'
File.binwrite(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join)
when 'i'
k = data.column(keycol)
ents = rows.map { |r| [r.cells[k].to_s, r.from] }
ents = ents.sort_by { |key, off| [fold ? key.downcase : key, off] }
File.binwrite(path + '.i', "#{keycol}\tOffset\n#{'-' * keycol.size}\t------\n" + ents.map { |key, off| "#{key}\t#{off}\n" }.join)
when 'h'
k = data.column(keycol)
slots = [rows.size * 2, 2].max
table = Array.new(slots, 0)
rows.each { |r| table[free_slot(table, hash_slot(slots, r.cells[k].to_s))] = r.from }
File.binwrite(path + '.h', FIXED_HEAD + table.map { |o| fmt_entry(o) }.join)
when 'b'
k = data.column(keycol)
body = live_rows(data).sort_by { |r| r.cells[k].to_s }.map { |r| r.line + "\n" }
File.binwrite(path, data.head.join("\t") + "\n" + Table.dash_row(data.head) + "\n" + body.join)
end
end
def free_slot(table, start)
i = start
i = (i + 1) % table.size while table[i] != 0 && table[i] != -1
i
end
# ---- searching ----------------------------------------------------------
def read_fixed(path)
File.binread(path).byteslice(FIXED_HEAD.bytesize..).to_s.lines.map { |l| l.to_i }
end
def read_inverted(path)
off = 0
ents = []
File.binread(path).each_line("\n").with_index do |l, n|
ents << { from: off, to: off + l.bytesize, key: l.chomp("\n").split("\t", 2)[0], offset: l.chomp("\n").split("\t", 2)[1].to_i } if n >= 2
off += l.bytesize
end
ents
end
def matches?(cell, key, partial, exact)
if partial
norm(cell, true).start_with?(norm(key, true))
elsif exact
cell == key
else
cell.strip == key.strip
end
end
# Find rows for one key. method is one of s b h i r; keycols are column names.
def search(data, method, keycols, key, first: false, partial: false, exact: false)
path = data.path
idxs = keycols.map { |c| data.column(c) }
keyvals = Array(key)
hits = []
case method
when 'b'
rows = live_rows(data)
k = idxs[0]
lo = rows.bsearch_index { |r| (partial ? norm(r.cells[k], true)[0, keyvals[0].strip.size] : r.cells[k].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
if lo
rows[lo..].each do |r|
break unless matches?(r.cells[k], keyvals[0], partial, exact)
hits << Hit.new(r, 0, 8)
break if first
end
end
when 'r'
ents = read_fixed(path + '.r')
n = keyvals[0].to_i
if n.positive? && n <= ents.size
r = data.row_at(ents[n - 1]) and hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * (n - 1), FIXED_HEAD.bytesize + ENTRY_WIDTH * n)
end
when 'i'
ents = read_inverted(path + '.i')
lo = ents.bsearch_index { |e| (partial ? norm(e[:key], true)[0, keyvals[0].strip.size] : e[:key].strip) >= (partial ? norm(keyvals[0], true) : keyvals[0].strip) }
if lo
ents[lo..].each do |e|
break unless matches?(e[:key], keyvals[0], partial, exact)
r = data.row_at(e[:offset])
hits << Hit.new(r, e[:from], e[:to]) if r
break if first
end
end
when 'h'
ents = read_fixed(path + '.h')
slots = ents.size
i = hash_slot(slots, keyvals[0])
slots.times do
o = ents[i]
break if o.zero?
if o.positive? && (r = data.row_at(o)) && matches?(r.cells[idxs[0]], keyvals[0], false, exact)
hits << Hit.new(r, FIXED_HEAD.bytesize + ENTRY_WIDTH * i, FIXED_HEAD.bytesize + ENTRY_WIDTH * (i + 1))
break if first
end
i = (i + 1) % slots
end
else
live_rows(data).each do |r|
c = r.cells
next unless idxs.each_with_index.all? { |ci, n| matches?(c[ci].to_s, keyvals[n].to_s, partial, exact) }
hits << Hit.new(r, 0, 8)
break if first
end
end
hits
end
# ---- changing rows --------------------------------------------------------
def blanked(line)
line.gsub(/[^\t]/, ' ')
end
def splice(path, from, to, text)
b = File.binread(path)
File.binwrite(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
end
# Remove a record's entry from the secondary index for method.
def index_remove(path, method, row_from)
case method
when 'r'
ip = path + '.r'
ents = read_fixed(ip)
ents.delete(row_from)
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'h'
ip = path + '.h'
ents = read_fixed(ip).map { |o| o == row_from ? -1 : o }
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
when 'i'
ip = path + '.i'
e = read_inverted(ip).find { |x| x[:offset] == row_from }
splice(ip, e[:from], e[:to], '') if e
end
end
def index_add(path, method, row_from, cells, data_head, keycol)
case method
when 'r'
File.binwrite(path + '.r', File.binread(path + '.r') + fmt_entry(row_from))
when 'i'
ip = path + '.i'
name = File.binread(ip).lines.first.split("\t")[0]
key = cells[data_head.index(name)].to_s
ents = read_inverted(ip)
pos = ents.find { |e| e[:key] > key }
line = "#{key}\t#{row_from}\n"
if pos then splice(ip, pos[:from], pos[:from], line)
else File.binwrite(ip, File.binread(ip) + line)
end
when 'h'
ip = path + '.h'
ents = read_fixed(ip)
if ents.any? { |o| o <= 0 }
key = cells[data_head.index(keycol)].to_s
ents[free_slot(ents, hash_slot(ents.size, key))] = row_from
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
else
build(path, 'h', keycol)
end
when 'b'
build(path, 'b', keycol)
end
end
def append_row(path, cells, method, keycol)
line = cells.join("\t")
b = File.binread(path)
b += "\n" unless b.empty? || b.end_with?("\n")
from = b.bytesize
File.binwrite(path, b + line + "\n")
index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b'
from
end
def delete_hit(path, hit, method)
r = hit.row
splice(path, r.from, r.to, blanked(r.line) + "\n")
index_remove(path, method, r.from)
end
# Put cells where the record at hit was. If the new record is too long
# even after trimming blanks, append it (unless append is false).
def replace_row(path, from, to, cells, method, xfrom, xto, keycol, allow_append: true)
room = to - from - 1
line = cells.join("\t")
line = cells.map(&:strip).join("\t") if line.bytesize > room
if line.bytesize <= room
splice(path, from, to, line + ' ' * (room - line.bytesize) + "\n")
return from
end
return nil unless allow_append
old = File.binread(path).byteslice(from, room)
splice(path, from, to, blanked(old) + "\n")
newfrom = append_row(path, cells.map(&:strip), 'x', keycol)
reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
newfrom
end
def reindex_entry(path, method, xfrom, xto, newfrom, cells, keycol)
case method
when 'r', 'h'
splice(path + SUFFIX[method], xfrom, xto, fmt_entry(newfrom))
when 'i'
ip = path + '.i'
name = File.binread(ip).lines.first.split("\t")[0]
head = Data.new(path).head
splice(ip, xfrom, xto, "#{cells[head.index(name)].to_s.strip}\t#{newfrom}\n")
end
end
# Read keys: a table of keys, or one key per line.
def read_keys(io, ncols)
text = io.read.to_s
return [] if text.empty?
lines = text.lines.map { |l| l.chomp("\n") }
if lines.size >= 2 && Table.dash_line?(lines[1].split("\t", -1))
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) }
end
end
end
end
+97
View File
@@ -0,0 +1,97 @@
# frozen_string_literal: true
module Rdb
# A table is a head line of column names, a dash line, then one
# tab-separated row per line. A list is the same data turned sideways:
# it starts with a newline and holds "Column<tab>value" lines.
class Table
attr_reader :head, :rows
attr_accessor :list
def initialize(head, rows, list: false)
@head = head
@rows = rows
@list = list
end
def self.dash_line?(cells)
cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') }
end
def self.parse(text)
return parse_list(text) if text.start_with?("\n")
lines = text.lines.map { |l| l.chomp("\n") }
head = (lines.shift || '').split("\t", -1)
lines.shift if lines.first && dash_line?(lines.first.split("\t", -1))
rows = lines.map { |l| l.split("\t", -1) }
new(head, rows.map { |r| r.fill('', r.size...head.size) })
end
def self.parse_list(text)
head = []
records = []
rec = nil
text.sub(/\A\n/, '').each_line do |line|
line = line.chomp("\n")
if line.strip.empty?
rec = nil
next
end
name, value = line.split("\t", 2)
value ||= ''
head << name unless head.include?(name)
rec = nil if rec && name == head.first && rec.key?(name)
unless rec
rec = {}
records << rec
end
rec[name] = value
end
new(head, records.map { |r| head.map { |h| r.fetch(h, '') } }, list: true)
end
def self.read(io)
parse(io.read)
end
def self.open(path)
path == '-' ? read($stdin) : parse(File.read(path))
end
def index_of(name)
@head.index(name) || Rdb.die("no column named '#{name}'")
end
def to_s
return list_text if @list
out = +''
out << @head.join("\t") << "\n"
out << Table.dash_row(@head) << "\n"
@rows.each { |r| out << r.join("\t") << "\n" }
out
end
def self.dash_row(head)
head.map { |h| '-' * [h.size, 1].max }.join("\t")
end
def list_text
out = +"\n"
@rows.each do |r|
@head.each_with_index { |h, i| out << h << "\t" << r[i].to_s << "\n" }
out << "\n"
end
out
end
def write(out = $stdout)
out.write to_s
end
def with(head: @head, rows: @rows)
Table.new(head, rows, list: @list)
end
end
end