c0data 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +143 -0
- data/c0-c/c0.h +3926 -0
- data/ext/c0/c0_ext.c +1061 -0
- data/ext/c0/extconf.rb +7 -0
- data/lib/c0/body.rb +314 -0
- data/lib/c0/builder.rb +64 -0
- data/lib/c0/document.rb +243 -0
- data/lib/c0/pretty.rb +28 -0
- data/lib/c0/stream.rb +76 -0
- data/lib/c0.rb +123 -0
- metadata +53 -0
data/ext/c0/extconf.rb
ADDED
data/lib/c0/body.rb
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module C0
|
|
4
|
+
# Zero-copy navigator for a document body: the bytes between a heading and
|
|
5
|
+
# EOT, or the inside of a nested level. Everything it returns is a shared
|
|
6
|
+
# byteslice of the original buffer.
|
|
7
|
+
#
|
|
8
|
+
# Records before the first GS of a file form an unnamed group; records and
|
|
9
|
+
# groups before the first FS form an unnamed file. Enumerates the groups of
|
|
10
|
+
# the first file.
|
|
11
|
+
class Body
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
attr_reader :buf, :start, :stop
|
|
15
|
+
|
|
16
|
+
def initialize(buf, start = 0, stop = nil)
|
|
17
|
+
@buf = C0.to_bin(buf)
|
|
18
|
+
@start = start
|
|
19
|
+
@stop = stop || @buf.bytesize
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The files, in order (at least one: the unnamed leading file).
|
|
23
|
+
def files
|
|
24
|
+
@files ||= Ext.files(@buf, @start, @stop).map { |ns, ne, s, e| FileView.new(@buf, ns, ne, s, e) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# All groups of all files, in order.
|
|
28
|
+
def groups
|
|
29
|
+
@groups ||= Ext.groups(@buf, @start, @stop).map { |d, ns, ne, s, e| Group.new(@buf, d, ns, ne, s, e) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Groups of the first file.
|
|
33
|
+
def group_count = first_file.groups.size
|
|
34
|
+
|
|
35
|
+
# Group by index or by name (first file). Raises C0::KeyError for a name
|
|
36
|
+
# with no group.
|
|
37
|
+
def group(index_or_name)
|
|
38
|
+
index_or_name.is_a?(Integer) ? first_file.groups[index_or_name] : first_file.group(index_or_name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
alias [] group
|
|
42
|
+
|
|
43
|
+
def each_group(&) = first_file.groups.each(&)
|
|
44
|
+
|
|
45
|
+
alias each each_group
|
|
46
|
+
|
|
47
|
+
def group_names = first_file.groups.map(&:name)
|
|
48
|
+
|
|
49
|
+
# Name of the first file (empty when the body has no FS).
|
|
50
|
+
def name = first_file.name
|
|
51
|
+
|
|
52
|
+
# Records of the body when it is a bare record stream (no FS, no GS): the
|
|
53
|
+
# unnamed group's records.
|
|
54
|
+
def records = first_file.groups.first&.records || []
|
|
55
|
+
|
|
56
|
+
def record_count = records.size
|
|
57
|
+
|
|
58
|
+
def record(index) = records[index]
|
|
59
|
+
|
|
60
|
+
def each_record(&) = records.each(&)
|
|
61
|
+
|
|
62
|
+
# Whether the body is just records: one unnamed file, at most the unnamed group.
|
|
63
|
+
def bare? = Ext.body_bare(@buf, @start, @stop)
|
|
64
|
+
|
|
65
|
+
# The body's bytes.
|
|
66
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def first_file = files[0]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A file within a body: its name and groups. Enumerates the groups.
|
|
74
|
+
class FileView
|
|
75
|
+
include Enumerable
|
|
76
|
+
|
|
77
|
+
attr_reader :start, :stop
|
|
78
|
+
|
|
79
|
+
def initialize(buf, name_start, name_end, start, stop)
|
|
80
|
+
@buf = buf
|
|
81
|
+
@name_start = name_start
|
|
82
|
+
@name_end = name_end
|
|
83
|
+
@start = start
|
|
84
|
+
@stop = stop
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def name = @buf.byteslice(@name_start, @name_end - @name_start)
|
|
88
|
+
|
|
89
|
+
def groups
|
|
90
|
+
@groups ||= Ext.groups(@buf, @start, @stop).map { |d, ns, ne, s, e| Group.new(@buf, d, ns, ne, s, e) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The first group with this name. Raises C0::KeyError when there is none.
|
|
94
|
+
def group(name)
|
|
95
|
+
needle = C0.to_bin(name)
|
|
96
|
+
groups.find { |g| g.name == needle } or raise KeyError, "No group named '#{name}'"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
alias [] group
|
|
100
|
+
|
|
101
|
+
def each(&) = groups.each(&)
|
|
102
|
+
|
|
103
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A group: its depth (GS count; 0 for the unnamed leading group), name,
|
|
107
|
+
# optional header row and records. Enumerates the records.
|
|
108
|
+
class Group
|
|
109
|
+
include Enumerable
|
|
110
|
+
|
|
111
|
+
attr_reader :depth, :start, :stop
|
|
112
|
+
|
|
113
|
+
def initialize(buf, depth, name_start, name_end, start, stop)
|
|
114
|
+
@buf = buf
|
|
115
|
+
@depth = depth
|
|
116
|
+
@name_start = name_start
|
|
117
|
+
@name_end = name_end
|
|
118
|
+
@start = start
|
|
119
|
+
@stop = stop
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def name = @buf.byteslice(@name_start, @name_end - @name_start)
|
|
123
|
+
|
|
124
|
+
# The header row as a Record, or nil.
|
|
125
|
+
def header_row
|
|
126
|
+
return @header_row if defined?(@header_row)
|
|
127
|
+
|
|
128
|
+
range = Ext.group_header(@buf, @start, @stop)
|
|
129
|
+
@header_row = range && Record.new(@buf, range[0], range[1])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def header? = !header_row.nil?
|
|
133
|
+
|
|
134
|
+
alias has_header? header?
|
|
135
|
+
|
|
136
|
+
# Header names, or an empty array.
|
|
137
|
+
def headers = header_row ? header_row.values : []
|
|
138
|
+
|
|
139
|
+
def header_count = header_row ? header_row.field_count : 0
|
|
140
|
+
|
|
141
|
+
def header(index) = headers[index]
|
|
142
|
+
|
|
143
|
+
# Index of a header name, or nil.
|
|
144
|
+
def column(name)
|
|
145
|
+
needle = C0.to_bin(name)
|
|
146
|
+
headers.index(needle)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The live records (header row, notes, commits and dead elements skipped).
|
|
150
|
+
def records
|
|
151
|
+
@records ||= Ext.group_records(@buf, @start, @stop).map { |s, e| Record.new(@buf, s, e) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def record_count = records.size
|
|
155
|
+
|
|
156
|
+
alias size record_count
|
|
157
|
+
|
|
158
|
+
def record(index) = records[index]
|
|
159
|
+
|
|
160
|
+
def each_record(&) = records.each(&)
|
|
161
|
+
|
|
162
|
+
alias each each_record
|
|
163
|
+
|
|
164
|
+
# The group as a table is itself.
|
|
165
|
+
def table = self
|
|
166
|
+
|
|
167
|
+
# The group's contents (after its name).
|
|
168
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Table = Group
|
|
172
|
+
|
|
173
|
+
# A record: positional or keyed, never mixed. Zero-copy accessors.
|
|
174
|
+
# Enumerates the logical values of a positional record.
|
|
175
|
+
class Record
|
|
176
|
+
include Enumerable
|
|
177
|
+
|
|
178
|
+
attr_reader :start, :stop
|
|
179
|
+
|
|
180
|
+
def initialize(buf, start, stop)
|
|
181
|
+
@buf = buf
|
|
182
|
+
@start = start
|
|
183
|
+
@stop = stop
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The record's bytes (after its RS, trailing layout excluded).
|
|
187
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
188
|
+
|
|
189
|
+
# Whether the record is keyed (its first live unit is an SOH).
|
|
190
|
+
def keyed? = Ext.record_keyed(@buf, @start, @stop)
|
|
191
|
+
|
|
192
|
+
# ---- positional ---------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
# Raw bytes of unit n (its layout included), or of the unit under a key
|
|
195
|
+
# (last wins); nil when there is none.
|
|
196
|
+
def field(index_or_key)
|
|
197
|
+
s, e = index_or_key.is_a?(Integer) ? units[index_or_key] : keyed_range(index_or_key)
|
|
198
|
+
s && @buf.byteslice(s, e - s)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Number of positional units (0 for a keyed record).
|
|
202
|
+
def field_count = units.size
|
|
203
|
+
|
|
204
|
+
alias size field_count
|
|
205
|
+
|
|
206
|
+
def fields = units.map { |s, e| @buf.byteslice(s, e - s) }
|
|
207
|
+
|
|
208
|
+
# Logical value of unit n, or nil when there is none.
|
|
209
|
+
def value(index)
|
|
210
|
+
s, e = units[index]
|
|
211
|
+
s && Ext.value(@buf, s, e)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def values = units.map { |s, e| Ext.value(@buf, s, e) }
|
|
215
|
+
|
|
216
|
+
def each(&) = values.each(&)
|
|
217
|
+
|
|
218
|
+
# Unit n (or the unit under a key) as a nested level. Raises C0::Error when
|
|
219
|
+
# it is not one; nil for a key with no unit.
|
|
220
|
+
def level(index_or_key)
|
|
221
|
+
s, e = index_or_key.is_a?(Integer) ? units[index_or_key] : keyed_range(index_or_key)
|
|
222
|
+
return nil if s.nil? && !index_or_key.is_a?(Integer)
|
|
223
|
+
raise KeyError, "no unit #{index_or_key}" if s.nil?
|
|
224
|
+
|
|
225
|
+
ls, le = Ext.unit_level(@buf, s, e)
|
|
226
|
+
Body.new(@buf, ls, le)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Unit n as a flat list: a level holding at most one positional record.
|
|
230
|
+
# Raises C0::Error for any other level.
|
|
231
|
+
def list(index)
|
|
232
|
+
s, e = units[index]
|
|
233
|
+
raise KeyError, "no unit #{index}" if s.nil?
|
|
234
|
+
|
|
235
|
+
Ext.unit_list(@buf, s, e).map { |is, ie| Ext.value(@buf, is, ie) }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Unit n as a Reference.
|
|
239
|
+
def ref(index)
|
|
240
|
+
s, e = units[index]
|
|
241
|
+
raise KeyError, "no unit #{index}" if s.nil?
|
|
242
|
+
|
|
243
|
+
Reference.new(Ext.unit_ref(@buf, s, e).map { |rs, re| @buf.byteslice(rs, re - rs) })
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# ---- keyed --------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
# Keys in written order (duplicates included).
|
|
249
|
+
def keys = pair_ranges.map { |ks, ke, _, _| @buf.byteslice(ks, ke - ks) }
|
|
250
|
+
|
|
251
|
+
# [key, logical value] pairs in written order.
|
|
252
|
+
def pairs = pair_ranges.map { |ks, ke, vs, ve| [@buf.byteslice(ks, ke - ks), Ext.value(@buf, vs, ve)] }
|
|
253
|
+
|
|
254
|
+
# Logical value under a key (last wins), or nil.
|
|
255
|
+
def [](key)
|
|
256
|
+
s, e = keyed_range(key)
|
|
257
|
+
s && Ext.value(@buf, s, e)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def key?(key) = !keyed_range(key).nil?
|
|
261
|
+
|
|
262
|
+
alias has_key? key?
|
|
263
|
+
|
|
264
|
+
# The keyed record as a Hash (last wins).
|
|
265
|
+
def to_h = pairs.to_h
|
|
266
|
+
|
|
267
|
+
private
|
|
268
|
+
|
|
269
|
+
def units = @units ||= Ext.record_units(@buf, @start, @stop)
|
|
270
|
+
|
|
271
|
+
def pair_ranges = @pair_ranges ||= Ext.record_pairs(@buf, @start, @stop)
|
|
272
|
+
|
|
273
|
+
def keyed_range(key)
|
|
274
|
+
needle = C0.to_bin(key)
|
|
275
|
+
found = nil
|
|
276
|
+
pair_ranges.each do |ks, ke, vs, ve|
|
|
277
|
+
found = [vs, ve] if @buf.byteslice(ks, ke - ks) == needle
|
|
278
|
+
end
|
|
279
|
+
found
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A reference: a group name, or a path of two or three segments
|
|
284
|
+
# (group, record id, field name).
|
|
285
|
+
class Reference
|
|
286
|
+
attr_reader :path
|
|
287
|
+
|
|
288
|
+
def initialize(path)
|
|
289
|
+
@path = path
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Parse a raw unit (ENQ …). Raises C0::Error when it is not a reference.
|
|
293
|
+
def self.parse(raw)
|
|
294
|
+
b = C0.to_bin(raw)
|
|
295
|
+
new(Ext.unit_ref(b, 0, b.bytesize).map { |s, e| b.byteslice(s, e - s) })
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def group = @path[0]
|
|
299
|
+
|
|
300
|
+
def record = @path[1]
|
|
301
|
+
|
|
302
|
+
def field = @path[2]
|
|
303
|
+
|
|
304
|
+
def group? = @path.size == 1
|
|
305
|
+
|
|
306
|
+
def ==(other) = other.is_a?(Reference) && other.path == @path
|
|
307
|
+
|
|
308
|
+
alias eql? ==
|
|
309
|
+
|
|
310
|
+
def hash = @path.hash
|
|
311
|
+
|
|
312
|
+
def to_s = @path.join("/")
|
|
313
|
+
end
|
|
314
|
+
end
|
data/lib/c0/builder.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module C0
|
|
4
|
+
# Writes C0DATA in canonical compact form. The class and its methods are
|
|
5
|
+
# defined in the C extension (a TypedData object owning a c0_builder); this
|
|
6
|
+
# file adds the Ruby conveniences.
|
|
7
|
+
#
|
|
8
|
+
# buf = C0::Builder.build do |b|
|
|
9
|
+
# b.syn(shape: "tabular")
|
|
10
|
+
# b.group("users", %w[name amount]) do
|
|
11
|
+
# b.record("Alice", "1502.30")
|
|
12
|
+
# b.record("Bob", "340.00")
|
|
13
|
+
# end
|
|
14
|
+
# b.eot
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Two layers. The primitives are one method per code (`fs`, `gs`, `rs`,
|
|
18
|
+
# `us`, `sub`, `soh`, `dle`, `can`, `bel`, `enq`, `syn`, `eot`, `etb`, `em`);
|
|
19
|
+
# each writes its code and, when given, what grammatically follows it, so a
|
|
20
|
+
# chain reads like the bytes (`sub` is C0-DIFF only: `rs("old").sub("new")`
|
|
21
|
+
# writes a replace-all, ␞old␚new; in a data body SUB is an error):
|
|
22
|
+
#
|
|
23
|
+
# b.rs("Alice").us("1502.30") # ␞Alice␟1502.30
|
|
24
|
+
# b.rs.soh("host").us("localhost").soh("port").us("5432") # ␞␁host␟localhost␁port␟5432
|
|
25
|
+
# b.gs("users").soh.rs("name").us("amount") # ␝users␁␞name␟amount
|
|
26
|
+
# b.bel.gs("old") # ␇␝old, a dead group
|
|
27
|
+
#
|
|
28
|
+
# The composed methods (`file`, `group`, `header`, `record`, `field`, `map`,
|
|
29
|
+
# `key`, `level`, `list`, `ref`, `note`) are built on the primitives and
|
|
30
|
+
# cannot write ill-formed output. The primitives write what you spell;
|
|
31
|
+
# well-formedness is then yours to keep. Both layers share the record state,
|
|
32
|
+
# so they mix: `b.rs("a").field("b")` writes ␞a␟b.
|
|
33
|
+
#
|
|
34
|
+
# Values are byte-transparent and written in their one spelling: plain when
|
|
35
|
+
# they can be, text (STX … ETX) otherwise, with only the seven codes escaped;
|
|
36
|
+
# there is no STX/ETX primitive, so everything written stays canonical.
|
|
37
|
+
# Names (file, group, header, key, reference segment, ETB payload) must be
|
|
38
|
+
# plain, or a C0::BuildError is raised before anything is written. Every
|
|
39
|
+
# method returns the builder, so calls chain.
|
|
40
|
+
#
|
|
41
|
+
# A record is opened by `record` or `rs` (or implicitly by the first `field`,
|
|
42
|
+
# `key`, `level`, `list` or `ref`) and ended by the next structural call.
|
|
43
|
+
# Keyed units written right after `syn` belong to the heading. Inside
|
|
44
|
+
# `level { }` (or between `dle` and `can`) the builder is at the top of a
|
|
45
|
+
# fresh body.
|
|
46
|
+
class Builder
|
|
47
|
+
# Drive a fresh builder and return its bytes.
|
|
48
|
+
def self.build
|
|
49
|
+
b = new
|
|
50
|
+
yield b
|
|
51
|
+
b.bytes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Alias of `bytes`: the compact form as a binary String.
|
|
55
|
+
def to_s = bytes
|
|
56
|
+
|
|
57
|
+
alias to_slice bytes
|
|
58
|
+
|
|
59
|
+
# Compatibility spelling of `list`.
|
|
60
|
+
def list_field(items) = list(items)
|
|
61
|
+
|
|
62
|
+
def inspect = "#<C0::Builder #{bytesize} bytes, depth #{depth}>"
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/c0/document.rb
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module C0
|
|
4
|
+
# The heading of a document: the keyed units after SYN. Last wins for a
|
|
5
|
+
# duplicate key. Enumerates [key, value] pairs.
|
|
6
|
+
class Heading
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
KNOWN_KEYS = %w[shape version blob].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(buf, start, stop)
|
|
12
|
+
@buf = buf
|
|
13
|
+
@start = start
|
|
14
|
+
@stop = stop
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# [key, value] in written order: the logical value, or for a nested level
|
|
18
|
+
# (the blob table) its raw bytes.
|
|
19
|
+
def pairs
|
|
20
|
+
@pairs ||= raw_pairs.map do |k, s, e|
|
|
21
|
+
[k, Ext.unit_type(@buf, s, e) == UnitType::LEVEL ? @buf.byteslice(s, e - s) : Ext.value(@buf, s, e)]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&) = pairs.each(&)
|
|
26
|
+
|
|
27
|
+
def keys = raw_pairs.map(&:first)
|
|
28
|
+
|
|
29
|
+
# Value for a key (last wins), or nil.
|
|
30
|
+
def [](key)
|
|
31
|
+
needle = C0.to_bin(key)
|
|
32
|
+
found = nil
|
|
33
|
+
pairs.each { |k, v| found = v if k == needle }
|
|
34
|
+
found
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def key?(key) = !self[key].nil?
|
|
38
|
+
|
|
39
|
+
def empty? = raw_pairs.empty?
|
|
40
|
+
|
|
41
|
+
# The `shape` value as a String, or nil.
|
|
42
|
+
def shape = self["shape"]&.dup&.force_encoding(Encoding::UTF_8)
|
|
43
|
+
|
|
44
|
+
# The `version` value as an Integer; 1 when absent or not a number.
|
|
45
|
+
def version
|
|
46
|
+
v = self["version"]
|
|
47
|
+
v && v.match?(/\A\d+\z/) ? v.to_i : 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The value under a key as a nested level, or nil when absent or not one.
|
|
51
|
+
def level(key)
|
|
52
|
+
range = raw_range(key)
|
|
53
|
+
return nil unless range && Ext.unit_type(@buf, *range) == UnitType::LEVEL
|
|
54
|
+
|
|
55
|
+
ls, le = Ext.unit_level(@buf, *range)
|
|
56
|
+
Body.new(@buf, ls, le)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The blob table as a Body, when declared as a level.
|
|
60
|
+
def blob = level("blob")
|
|
61
|
+
|
|
62
|
+
# The heading's bytes.
|
|
63
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def raw_pairs
|
|
68
|
+
@raw_pairs ||= Ext.record_pairs(@buf, @start, @stop).map { |ks, ke, vs, ve| [@buf.byteslice(ks, ke - ks), vs, ve] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def raw_range(key)
|
|
72
|
+
needle = C0.to_bin(key)
|
|
73
|
+
found = nil
|
|
74
|
+
raw_pairs.each { |k, s, e| found = [s, e] if k == needle }
|
|
75
|
+
found
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One C0DATA document: an optional SYN heading, a body, an optional EOT.
|
|
80
|
+
# `Document.new(buf)` reads the first document in the buffer. Use
|
|
81
|
+
# `Container` for a buffer holding several documents or an EM tail.
|
|
82
|
+
# Enumerates the groups of the body's first file.
|
|
83
|
+
class Document
|
|
84
|
+
include Enumerable
|
|
85
|
+
|
|
86
|
+
SHAPES = %w[diff stream tabular document key-value].freeze
|
|
87
|
+
|
|
88
|
+
attr_reader :buf, :heading, :body, :start, :stop
|
|
89
|
+
|
|
90
|
+
def initialize(buf, start = 0)
|
|
91
|
+
@buf = C0.to_bin(buf)
|
|
92
|
+
load(Ext.document(@buf, start))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# A document from the C reader's description (see Container).
|
|
96
|
+
def self.from_ext(buf, desc)
|
|
97
|
+
doc = allocate
|
|
98
|
+
doc.instance_variable_set(:@buf, buf)
|
|
99
|
+
doc.send(:load, desc)
|
|
100
|
+
doc
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def has_syn? = @has_syn
|
|
104
|
+
|
|
105
|
+
alias syn? has_syn?
|
|
106
|
+
|
|
107
|
+
def has_eot? = @has_eot
|
|
108
|
+
|
|
109
|
+
alias eot? has_eot?
|
|
110
|
+
|
|
111
|
+
# Loud warnings (DESIGN.md "Warnings") as strings; empty for a careful writer's file.
|
|
112
|
+
def warnings
|
|
113
|
+
@warnings ||= begin
|
|
114
|
+
w = []
|
|
115
|
+
w << "no SYN at the start of the document" unless @has_syn
|
|
116
|
+
w << "no EOT at the end of the document" if !@has_eot && heading.shape != "stream"
|
|
117
|
+
heading.keys.each do |k|
|
|
118
|
+
w << "unknown heading key '#{k}'" unless Heading::KNOWN_KEYS.include?(k)
|
|
119
|
+
end
|
|
120
|
+
seen = {}
|
|
121
|
+
heading.keys.each { |k| w << "duplicate heading key '#{k}'" if seen.key?(k) && !w.include?("duplicate heading key '#{k}'"); seen[k] = true }
|
|
122
|
+
if (sh = heading.shape) && !SHAPES.include?(sh)
|
|
123
|
+
w << "unknown shape '#{sh}'"
|
|
124
|
+
end
|
|
125
|
+
if (v = heading["version"]) && v != "1".b
|
|
126
|
+
w << "unknown version '#{v}'"
|
|
127
|
+
end
|
|
128
|
+
w
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- convenience accessors over the body --------------------------------
|
|
133
|
+
|
|
134
|
+
def name = @body.name
|
|
135
|
+
|
|
136
|
+
def files = @body.files
|
|
137
|
+
|
|
138
|
+
def group_count = @body.group_count
|
|
139
|
+
|
|
140
|
+
def group(index_or_name) = @body.group(index_or_name)
|
|
141
|
+
|
|
142
|
+
alias [] group
|
|
143
|
+
|
|
144
|
+
def each_group(&) = @body.each_group(&)
|
|
145
|
+
|
|
146
|
+
alias each each_group
|
|
147
|
+
|
|
148
|
+
def groups = @body.groups
|
|
149
|
+
|
|
150
|
+
def group_names = @body.group_names
|
|
151
|
+
|
|
152
|
+
def records = @body.records
|
|
153
|
+
|
|
154
|
+
def shape = heading.shape
|
|
155
|
+
|
|
156
|
+
# The document's bytes (SYN through EOT).
|
|
157
|
+
def raw = @buf.byteslice(@start, @stop - @start)
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
def load(desc)
|
|
162
|
+
@start, @stop, @has_syn, @has_eot, hs, he, bs, be, _bits = desc
|
|
163
|
+
@heading = Heading.new(@buf, hs, he)
|
|
164
|
+
@body = Body.new(@buf, bs, be)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A file or message: one or more documents and an optional raw tail after
|
|
169
|
+
# EM. Enumerates the documents.
|
|
170
|
+
class Container
|
|
171
|
+
include Enumerable
|
|
172
|
+
|
|
173
|
+
attr_reader :buf, :documents, :tail
|
|
174
|
+
|
|
175
|
+
def initialize(buf)
|
|
176
|
+
@buf = C0.to_bin(buf)
|
|
177
|
+
descs, @has_em, em_pos = Ext.documents(@buf)
|
|
178
|
+
@documents = descs.map { |d| Document.from_ext(@buf, d) }
|
|
179
|
+
@tail = @has_em ? @buf.byteslice(em_pos + 1, @buf.bytesize - em_pos - 1) : "".b
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def has_em? = @has_em
|
|
183
|
+
|
|
184
|
+
alias em? has_em?
|
|
185
|
+
|
|
186
|
+
def each(&) = @documents.each(&)
|
|
187
|
+
|
|
188
|
+
def first = @documents[0]
|
|
189
|
+
|
|
190
|
+
def size = @documents.size
|
|
191
|
+
|
|
192
|
+
# Loud warnings about the container as a whole; each document carries its own.
|
|
193
|
+
def warnings
|
|
194
|
+
@warnings ||= begin
|
|
195
|
+
w = []
|
|
196
|
+
@documents.each_with_index do |doc, i|
|
|
197
|
+
next unless doc.heading.key?("blob")
|
|
198
|
+
|
|
199
|
+
if i.positive?
|
|
200
|
+
w << "blob table in a later heading is ignored"
|
|
201
|
+
elsif doc.heading.blob.nil?
|
|
202
|
+
w << "blob is not a level: declares nothing"
|
|
203
|
+
elsif !doc.heading.blob.groups.first&.header?
|
|
204
|
+
w << "blob table has no header row: declares nothing"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
w << "EM with no blob table: the region is unaddressable" if @has_em && blob_table.nil?
|
|
208
|
+
w
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The blob table from the first document's heading, as a Body, or nil.
|
|
213
|
+
def blob_table = first&.heading&.blob
|
|
214
|
+
|
|
215
|
+
# Bytes of a named blob from the tail (columns matched by header name,
|
|
216
|
+
# last row wins), or nil. With no name, the blob table itself.
|
|
217
|
+
def blob(name = nil)
|
|
218
|
+
return blob_table if name.nil?
|
|
219
|
+
|
|
220
|
+
table = blob_table or return nil
|
|
221
|
+
g = table.groups.first or return nil
|
|
222
|
+
ni = g.column("name") || 0
|
|
223
|
+
oi = g.column("offset") || 1
|
|
224
|
+
li = g.column("length") || 2
|
|
225
|
+
needle = C0.to_bin(name)
|
|
226
|
+
found = nil
|
|
227
|
+
g.records.each do |r|
|
|
228
|
+
next unless r.value(ni) == needle
|
|
229
|
+
|
|
230
|
+
off = r.value(oi)
|
|
231
|
+
len = r.value(li)
|
|
232
|
+
next unless off&.match?(/\A\d+\z/) && len&.match?(/\A\d+\z/)
|
|
233
|
+
|
|
234
|
+
off = off.to_i
|
|
235
|
+
len = len.to_i
|
|
236
|
+
next if off > @tail.bytesize || len > @tail.bytesize - off
|
|
237
|
+
|
|
238
|
+
found = @tail.byteslice(off, len)
|
|
239
|
+
end
|
|
240
|
+
found
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/c0/pretty.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module C0
|
|
4
|
+
# Pretty form: Unicode Control Pictures (U+2400 block) for the codes, line
|
|
5
|
+
# breaks and indentation as layout. `format` and `parse` are inverses on
|
|
6
|
+
# well-formed input. GlyphInDataError (a C0::Error) is raised when a value
|
|
7
|
+
# holds a Control Picture character and cannot be shown unambiguously.
|
|
8
|
+
module Pretty
|
|
9
|
+
MODES = { compact: 0, aligned: 1, spaced: 2 }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# The glyph for a code byte.
|
|
14
|
+
def glyph(byte) = (0x2400 + byte).chr(Encoding::UTF_8)
|
|
15
|
+
|
|
16
|
+
# Compact bytes → pretty form (a UTF-8 String). `mode` is :compact (one
|
|
17
|
+
# element per line), :aligned (table columns aligned) or :spaced (aligned,
|
|
18
|
+
# plus a space after each prefix and around the US glyph).
|
|
19
|
+
def format(buf, indent: " ", mode: :compact)
|
|
20
|
+
m = MODES.fetch(mode) { raise ArgumentError, "unknown mode #{mode.inspect}; use #{MODES.keys.inspect}" }
|
|
21
|
+
Ext.pretty_format(C0.to_bin(buf), indent, m)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Pretty form → compact bytes. Glyphs become codes; outside text,
|
|
25
|
+
# whitespace touching a glyph or a line edge is dropped.
|
|
26
|
+
def parse(text) = Ext.pretty_parse(text.to_s.encode(Encoding::UTF_8))
|
|
27
|
+
end
|
|
28
|
+
end
|