Fehler behoben: Umlaute, Latin-1, Fehlermeldungen, atomares Schreiben

Spaltennamen mit Umlauten in Ausdrücken, Latin-1-Dateien als UTF-8,
Fehler als eine Zeile (RDB_DEBUG=1 für Backtrace), Werte mit Tab oder
Zeilenumbruch abgelehnt, Tabellen und Indexe über Temp-Datei und rename,
update.rdb gibt den Satz bei Editorfehler zurück, sorttable -r stabil.
This commit is contained in:
wkh
2026-10-30 09:48:42 +01:00
parent 9f6c450492
commit 75c2424307
11 changed files with 164 additions and 42 deletions
+29
View File
@@ -46,9 +46,38 @@ module Rdb
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
+25 -15
View File
@@ -259,7 +259,7 @@ Rdb.register('vilock', 'Lock a table before editing, unlock afterward', 'vilock
end
File.unlink(file)
begin
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked)
system(*Shellwords.split(ENV['EDITOR'] || 'vi'), locked) or warn "vilock: editor #{ENV['EDITOR'] || 'vi'} failed"
ensure
File.rename(locked, file)
end
@@ -276,7 +276,7 @@ def edit_cells(head, cells)
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)
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?
@@ -319,20 +319,27 @@ Rdb.register('update.rdb', 'Display and edit records in any sized file',
r = rows[cur]
cells = r.cells
# Unless -l, the record is blanked while it is edited (that is the lock).
# Whatever happens, it must not stay blank: put it back unless replaced.
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
replaced = false
begin
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])
replaced = true
puts 'record replaced'
else
puts 'unchanged'
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'
ensure
Rdb::Fast.splice(table, r.from, r.to, r.line + "\n") unless o[:l] || replaced
end
next
when /\Ad/i
@@ -393,7 +400,9 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil
next puts('no such field') unless (0...t.head.size).cover?(f)
print "#{t.head[f]}> "
rows[cur][f] = ($stdin.gets || '').chomp
value = ($stdin.gets || '').chomp
Rdb.check_cells!([value])
rows[cur][f] = value
File.write(file, t.to_s)
when 'a'
row = t.head.each_with_index.map do |h, i|
@@ -404,6 +413,7 @@ Rdb.register('ve', 'Visually edit a table', 've [table [-n[n] -fc -mc -i -s [fil
print "#{h}> "
($stdin.gets || '').chomp
end
Rdb.check_cells!(row)
rows << row
File.write(file, t.to_s)
cur = rows.size - 1
+2 -2
View File
@@ -48,8 +48,8 @@ Rdb.register('sorttable', 'Sort a table by one or more columns',
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
keyed = t.rows.each_with_index.map { |r, n| [key.(r), n, r] }
sorted = keyed.sort { |a, b| (reverse ? b[0] <=> a[0] : a[0] <=> b[0]).nonzero? || a[1] <=> b[1] }.map(&:last)
t.with(rows: sorted).write
end
+1 -1
View File
@@ -142,7 +142,7 @@ 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)) }
files.each { |f| Rdb.write_file(f, File.read(f).gsub(old, new)) }
end
Rdb.register('trimblank', 'Remove leading and trailing blanks from a string', 'trimblank [string] [< file]') do |args|
+4 -4
View File
@@ -50,7 +50,7 @@ module Rdb
def icase_column(names, expr, i)
return unless Rdb.icase?
word = expr[i..][/\A\w+/].to_s
word = expr[i..][/\A[[:alnum:]_]+/].to_s
return if word.empty? || AWK_WORDS.include?(word) || expr[i + word.size..].to_s.match?(/\A\s*\(/)
hits = names.select { |n, _| n.casecmp?(word) }
@@ -75,14 +75,14 @@ module Rdb
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/) }
elsif c.match?(/[[:alpha:]_#]/) && (i.zero? || !expr[i - 1].match?(/[[:alnum:]_$]/))
hit = names.find { |n, _| !n.empty? && expr[i, n.size] == n && !expr[i + n.size].to_s.match?(/[[:alnum:]_]/) }
hit ||= icase_column(names, expr, i)
if hit
out << "$#{hit[1] + 1}"
i += hit[0].size
else
m = expr[i..][/\A\w+/] || c
m = expr[i..][/\A[[:alnum:]_]+/] || c
out << m
i += m.size
end
+17 -15
View File
@@ -42,7 +42,7 @@ module Rdb
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).map(&:strip)
@head = lines.shift&.line.to_s.split("\t", -1).map { |h| h.dup.force_encoding(Encoding::UTF_8).strip }
lines.shift if lines.first && Table.dash_line?(lines.first.cells)
@rows = lines
@by_from = lines.to_h { |r| [r.from, r] }
@@ -95,22 +95,22 @@ module Rdb
rows = live_rows(data)
case method
when 'r'
File.binwrite(path + '.r', FIXED_HEAD + rows.map { |r| fmt_entry(r.from) }.join)
Rdb.write_file(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)
ents = ents.sort_by { |key, off| [fold ? key.strip.downcase : key.strip, off] }
Rdb.write_file(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)
Rdb.write_file(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)
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)
end
end
@@ -217,7 +217,7 @@ module Rdb
def splice(path, from, to, text)
b = File.binread(path)
File.binwrite(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
Rdb.write_file(path, b.byteslice(0, from) + text + b.byteslice(to..).to_s)
end
# Remove a record's entry from the secondary index for method.
@@ -227,11 +227,11 @@ module Rdb
ip = path + '.r'
ents = read_fixed(ip)
ents.delete(row_from)
File.binwrite(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
Rdb.write_file(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)
Rdb.write_file(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 }
@@ -242,16 +242,16 @@ module Rdb
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))
Rdb.write_file(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 }
pos = ents.find { |e| e[:key].strip > key.strip }
line = "#{key}\t#{row_from}\n"
if pos then splice(ip, pos[:from], pos[:from], line)
else File.binwrite(ip, File.binread(ip) + line)
else Rdb.write_file(ip, File.binread(ip) + line)
end
when 'h'
ip = path + '.h'
@@ -259,7 +259,7 @@ module Rdb
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)
Rdb.write_file(ip, FIXED_HEAD + ents.map { |o| fmt_entry(o) }.join)
else
build(path, 'h', keycol)
end
@@ -269,11 +269,12 @@ module Rdb
end
def append_row(path, cells, method, keycol)
Rdb.check_cells!(cells)
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")
Rdb.write_file(path, b + line + "\n")
index_add(path, method, from, cells, Data.new(path).head, keycol) if SUFFIX[method] || method == 'b'
from
end
@@ -287,6 +288,7 @@ module Rdb
# 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)
Rdb.check_cells!(cells)
room = to - from - 1
line = cells.join("\t")
line = cells.map(&:strip).join("\t") if line.bytesize > room
+11
View File
@@ -18,7 +18,18 @@ module Rdb
cells.all? { |c| c.match?(/\A\s*-*\s*\z/) } && cells.any? { |c| c.include?('-') }
end
# COHERENT-era files are often Latin-1. Text that is not valid UTF-8 is
# read as Latin-1 and comes out as UTF-8.
def self.utf8(text)
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
text.dup.force_encoding(Encoding::UTF_8).then do |t|
t.valid_encoding? ? t : text.dup.force_encoding(Encoding::ISO_8859_1).encode(Encoding::UTF_8)
end
end
def self.parse(text)
text = utf8(text)
return parse_list(text) if text.start_with?("\n")
lines = text.lines.map { |l| l.chomp("\n") }