# 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' require_relative 'rdb/version' 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 # RDB_ICASE=1 makes column names match without regard to case. An exact # match always wins; several case-insensitive matches are an error. def icase? !%w[0 no false].include?(ENV['RDB_ICASE'].to_s.downcase) && !ENV['RDB_ICASE'].to_s.empty? end def column_index(head, name) exact = head.index(name) return exact if exact || !icase? hits = head.each_index.select { |i| head[i].casecmp?(name) } die "column name '#{name}' is ambiguous: #{hits.map { |i| head[i] }.join(', ')}" if hits.size > 1 hits.first end # 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 # 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 # Write a file all at once: a temporary file next to it, then rename, so # an interrupted run leaves the old file rather than half a new one. def write_file(path, data) path = File.realpath(path) if File.symlink?(path) tmp = "#{path}.tmp#{Process.pid}" File.binwrite(tmp, data) File.chmod(File.stat(path).mode & 0o7777, tmp) if File.exist?(path) File.rename(tmp, path) ensure File.delete(tmp) if tmp && File.exist?(tmp) end # A value is one cell of a tab-separated line: no tab, no line break. def check_cells!(cells) bad = cells.find { |c| c.to_s.match?(/[\t\r\n]/) } die "value #{bad.to_s.inspect} contains a tab or line break" if bad end # Errors reach the user as one line; RDB_DEBUG=1 keeps the backtrace. def run(name, args) cmd = COMMANDS[name] or die("unknown command '#{name}'") Env.new(name).instance_exec(args.dup, &cmd.block) rescue Errno::EPIPE exit 0 rescue Interrupt exit 130 rescue SystemExit raise rescue StandardError => e raise if ENV['RDB_DEBUG'].to_s.match?(/\A(1|yes|true)\z/i) die("#{e.message} (#{e.class}; RDB_DEBUG=1 shows where)") end end end require_relative 'rdb/table' require_relative 'rdb/env' Dir[File.join(__dir__, 'rdb/cmd/*.rb')].sort.each { |f| require f }