diagrammatron 0.4.2 → 0.5.0

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.
@@ -1,14 +1,12 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Copyright © 2021 Ismo Kärkkäinen
4
+ # Copyright © 2021-2023 Ismo Kärkkäinen
5
5
  # Licensed under Universal Permissive License. See LICENSE.txt.
6
6
 
7
7
  require_relative '../lib/common'
8
8
  require 'optparse'
9
- require 'yaml'
10
9
  require 'set'
11
- require 'pathname'
12
10
 
13
11
 
14
12
  def prune_array(original, matching, keep)
@@ -26,7 +24,12 @@ def prune(doc, patterns, keep)
26
24
  nodes = doc.fetch('nodes', [])
27
25
  matching = Array.new(nodes.size, false)
28
26
  nodes.each_index do |k|
29
- label = nodes[k].fetch('label', '')
27
+ label = nodes[k].fetch('label', nil)
28
+ if label.nil?
29
+ # Schemas require 'label' to be present.
30
+ matching[k] = !keep
31
+ next
32
+ end
30
33
  patterns.each do |p|
31
34
  next unless p.match(label)
32
35
  matching[k] = true
@@ -40,10 +43,12 @@ def prune(doc, patterns, keep)
40
43
  edges.each_index do |k|
41
44
  between = edges[k].fetch('between', [])
42
45
  if between.size != 2
43
- matching[k] = keep # Removing these suppresses later warnings.
46
+ matching[k] = !keep # Schemas require between size to be 2.
44
47
  elsif keep
48
+ # Both ends matched reversed remove rule, keep the edge.
45
49
  matching[k] = (labels.member?(between[0]) && labels.member?(between[1]))
46
50
  else
51
+ # If either end matched remove rule, remove the edge.
47
52
  matching[k] = (labels.member?(between[0]) || labels.member?(between[1]))
48
53
  end
49
54
  end
@@ -53,6 +58,7 @@ end
53
58
  def main
54
59
  input = nil
55
60
  output = nil
61
+ input_output_schema = 'nodes'
56
62
  keep = false
57
63
  ENV['POSIXLY_CORRECT'] = '1' # Leaves patterns as they are.
58
64
  parser = OptionParser.new do |opts|
@@ -75,6 +81,8 @@ def main
75
81
  Patterns are strings used to create Ruby Regexps.
76
82
 
77
83
  Input YAML file is expected to be dot_json2diagrammatron output.
84
+ Input and output YAML file schema is returned by:
85
+ diagrammatron-schema #{input_output_schema}
78
86
 
79
87
  Output is the input file with nodes that have labels that match patterns
80
88
  removed or kept depending on options. Edges to removed nodes are removed.
@@ -89,7 +97,7 @@ removed or kept depending on options. Edges to removed nodes are removed.
89
97
  rescue StandardError => e
90
98
  return aargh("Error creating Regexp: #{e}", 1)
91
99
  end
92
- doc = load_source(input)
100
+ doc = load_verified(input, input_output_schema)
93
101
  return 2 if doc.nil?
94
102
 
95
103
  begin
@@ -98,7 +106,7 @@ removed or kept depending on options. Edges to removed nodes are removed.
98
106
  return aargh('Error processing input.', 3)
99
107
  end
100
108
 
101
- dump_result(output, YAML.dump(doc, line_width: 1_000_000), 4)
109
+ save_verified(output, doc, 4, input_output_schema)
102
110
  end
103
111
 
104
112
  exit(main) if (defined? $unit_test).nil?
@@ -61,8 +61,7 @@ def separate_coordinates(doc)
61
61
  push_coords(xcoords, ycoords, ckd2count, node.clone, 1, 1, true)
62
62
  end
63
63
  doc['edges'].each do |edge|
64
- path = edge.fetch('path', nil)
65
- next if path.nil?
64
+ path = edge['path']
66
65
  xdirection, ydirection = end_directions(path[0], path[1])
67
66
  push_coords(xcoords, ycoords, ckd2count, path[0], xdirection, ydirection)
68
67
  (1...(path.size - 1)).each do |k|
@@ -80,9 +79,10 @@ class Styles
80
79
  def base_styles(m, styles, group)
81
80
  d = styles.dig(group, 'default')
82
81
  if d.nil?
83
- d = m.fetch('default', {}) if d.nil?
82
+ d = m.fetch('default', {}) # No default in styles.
84
83
  else
85
84
  m['default'] = m.fetch('default', {}).merge(d)
85
+ d = m['default']
86
86
  end
87
87
  styles.fetch(group, {}).each_pair do |name, values|
88
88
  s = d.clone
@@ -98,7 +98,7 @@ class Styles
98
98
  @d = base_styles(base_styles({}, template_styles, 'diagram'), diagram_styles, 'diagram')
99
99
  end
100
100
 
101
- def fill(mapping, type_name, item)
101
+ def fill(mapping, _type_name, item)
102
102
  styles = item.fetch('style', [ 'default' ])
103
103
  styles = [ styles ] unless styles.is_a?(Array)
104
104
  s = {}
@@ -111,7 +111,7 @@ class Styles
111
111
  end
112
112
  s.merge!(mapping['default']) unless found # Merge default at least.
113
113
  # Keep values specified explicitly.
114
- item.merge!(s) { |key, existing, from_template| existing || from_template }
114
+ item.merge!(s) { |_key, existing, from_template| existing || from_template }
115
115
  end
116
116
 
117
117
  def apply_node_styles(node)
@@ -168,7 +168,7 @@ def estimate_sizes(doc, ckd2count)
168
168
  $render = SizeEstimation.new(ckd2count, doc)
169
169
  doc['nodes'].each do |node|
170
170
  $render.node = node
171
- label = node.fetch('label', 'unnamed')
171
+ label = node['label']
172
172
  style = node.fetch('style', 'default')
173
173
  code = node.fetch('size_estimator',
174
174
  %(raise NotImplementedError, "No size estimator for style: #{style}"))
@@ -186,7 +186,7 @@ end
186
186
  def maxima(doc)
187
187
  xmax = Hash.new(0)
188
188
  ymax = Hash.new(0)
189
- doc.fetch('nodes', []).each do |node|
189
+ doc['nodes'].each do |node|
190
190
  xmax[node['xo']] = [ node['w'], xmax[node['xo']] ].max
191
191
  ymax[node['yo']] = [ node['h'], ymax[node['yo']] ].max
192
192
  end
@@ -194,7 +194,7 @@ def maxima(doc)
194
194
  end
195
195
 
196
196
  def apply_maxima(doc, xmax, ymax)
197
- doc.fetch('nodes', []).each do |node|
197
+ doc['nodes'].each do |node|
198
198
  node['w'] = xmax[node['xo']]
199
199
  node['h'] = ymax[node['yo']]
200
200
  end
@@ -257,13 +257,12 @@ class Render
257
257
  def dimensions
258
258
  w = 0
259
259
  h = 0
260
- @doc.fetch('nodes', []).each do |node|
260
+ @doc['nodes'].each do |node|
261
261
  w = [ w, node['xo'] + node['w'] ].max
262
262
  h = [ h, node['yo'] + node['h'] ].max
263
263
  end
264
- @doc.fetch('edges', []).each do |edge|
265
- path = edge.fetch('path', nil)
266
- next if path.nil?
264
+ @doc['edges'].each do |edge|
265
+ path = edge['path']
267
266
  path.each do |p|
268
267
  w = [ w, p['xo'] ].max
269
268
  h = [ h, p['yo'] ].max
@@ -280,9 +279,31 @@ def apply(doc, template)
280
279
  out
281
280
  end
282
281
 
282
+ def reverse_depth_order(a, b)
283
+ d = b['depth'] <=> a['depth']
284
+ return d unless d.zero?
285
+ d = a['index'] <=> b['index']
286
+ return d unless d.zero?
287
+ a['kind'] <=> b['kind']
288
+ end
289
+
290
+ def reverse_depth_sort(items)
291
+ arr = []
292
+ items.each_index do |k|
293
+ arr.push({
294
+ 'depth' => items[k].fetch('depth', 0),
295
+ 'index' => k,
296
+ 'kind' => items[k].fetch('kind', 'unknown')
297
+ })
298
+ end
299
+ arr.sort! { |a, b| reverse_depth_order(a, b) }
300
+ arr.map { |x| items[x['index']] }
301
+ end
302
+
283
303
  def main
284
304
  template = nil
285
305
  input = nil
306
+ input_schema = 'render'
286
307
  output = nil
287
308
  styles = nil
288
309
  parser = OptionParser.new do |opts|
@@ -302,7 +323,10 @@ def main
302
323
  end
303
324
  opts.on('-h', '--help', 'Print this help and exit.') do
304
325
  $stdout.puts %(#{opts}
305
- Input YAML file is expected to be the output of diagrammatron-place.
326
+ Input YAML file schema is returned by:
327
+ diagrammatron-schema #{input_schema}
328
+
329
+ There can be other fields present as needed by the template.
306
330
 
307
331
  Output is the file produced by the erb-template.
308
332
  )
@@ -325,15 +349,15 @@ Output is the file produced by the erb-template.
325
349
  end
326
350
  end
327
351
 
328
- doc = load_source(input)
352
+ doc = load_verified(input, input_schema)
329
353
  return 2 if doc.nil?
330
354
 
331
355
  styles = Styles.new(template.fetch('styles', {}), doc.fetch('styles', {}))
332
- doc.fetch('nodes', []).each do |node|
356
+ doc['nodes'].each do |node|
333
357
  styles.apply_node_styles(node)
334
- node['text'] = node.fetch('text', node.fetch('label', '')).split("\n")
358
+ node['text'] = node.fetch('text', node['label']).split("\n")
335
359
  end
336
- doc.fetch('edges', []).each { |edge| styles.apply_edge_styles(edge) }
360
+ doc['edges'].each { |edge| styles.apply_edge_styles(edge) }
337
361
  doc['diagram'] = {} unless doc.key? 'diagram'
338
362
  styles.apply_diagram_styles(doc['diagram'])
339
363
 
@@ -354,6 +378,30 @@ Output is the file produced by the erb-template.
354
378
  remap_coordinates(xcoords, xmax, x2min, doc.dig('diagram', 'edge_gap'))
355
379
  remap_coordinates(ycoords, ymax, y2min, doc.dig('diagram', 'edge_gap'))
356
380
 
381
+ doc['nodes'] = reverse_depth_sort(doc['nodes'])
382
+ doc['edges'] = reverse_depth_sort(doc['edges'])
383
+ all = doc['nodes'].map do |a|
384
+ {
385
+ 'kind' => 'node',
386
+ 'depth' => a.fetch('depth', 0),
387
+ 'item' => a
388
+ }
389
+ end
390
+ all.concat(doc['edges'].map do |a|
391
+ {
392
+ 'kind' => 'edge',
393
+ 'depth' => a.fetch('depth', 0),
394
+ 'item' => a
395
+ }
396
+ end)
397
+ all = reverse_depth_sort(all)
398
+ doc['all'] = all.map do |x|
399
+ {
400
+ 'kind' => x['kind'],
401
+ 'item' => x['item']
402
+ }
403
+ end
404
+
357
405
  dump_result(output, apply(doc, template), 5)
358
406
  end
359
407
 
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright © 2023 Ismo Kärkkäinen
5
+ # Licensed under Universal Permissive License. See LICENSE.txt.
6
+
7
+ require_relative '../lib/common'
8
+ require 'optparse'
9
+ require 'yaml'
10
+ require 'json'
11
+
12
+ def convert(result, format)
13
+ case format
14
+ when :json then JSON.generate(result)
15
+ when :yaml then YAML.dump(result)
16
+ when :prettyjson then JSON.pretty_generate(result)
17
+ end
18
+ end
19
+
20
+ def main
21
+ output = nil
22
+ format = :json
23
+ package = :bare
24
+ ENV['POSIXLY_CORRECT'] = '1' # Leaves patterns as they are.
25
+ parser = OptionParser.new do |opts|
26
+ opts.summary_indent = ' '
27
+ opts.summary_width = 20
28
+ opts.banner = 'Usage: diagrammatron-schema [options] schema-name ...'
29
+ opts.separator ''
30
+ opts.separator 'Options:'
31
+ opts.on('-f', '--format FMT', 'Output schema format (json, yaml, pretty).') do |fmt|
32
+ case fmt.upcase
33
+ when 'JSON' then format = :json
34
+ when 'YAML' then format = :yaml
35
+ when 'PRETTY' then format = :prettyjson
36
+ else
37
+ format = fmt
38
+ end
39
+ end
40
+ opts.on('-p', '--package PKG', 'Wrap schema(s) in given way.') do |pkg|
41
+ case pkg.upcase
42
+ when 'OBJECT' then package = :object
43
+ when 'ARRAY' then package = :array
44
+ when 'BARE' then package = :bare
45
+ else
46
+ package = pkg
47
+ end
48
+ end
49
+ opts.on('-o', '--output FILE', 'Output file name. Write to stdout if not given.') do |filename|
50
+ output = filename
51
+ end
52
+ opts.on('-l', '--list', 'List available schemas and exit.') do
53
+ $stdout.puts list_schemas.join("\n")
54
+ exit 0
55
+ end
56
+ opts.on('-h', '--help', 'Print this help and exit.') do
57
+ $stdout.puts %(#{opts}
58
+ Outputs the given schema files.
59
+ Formats are: JSON, YAML, pretty for prettified JSON.
60
+ Packages are:
61
+ object: Output schemas as an object with name to schema mapping.
62
+ array: Output schemas as an array in the order given on the command-line
63
+ bare: Output schemas in the specified order as they are. The default.
64
+ )
65
+ exit 0
66
+ end
67
+ end
68
+ parser.parse! ARGV
69
+
70
+ aargh("Unknown format: #{format}", 1) unless format.is_a? Symbol
71
+ case package
72
+ when :bare, :array then out = []
73
+ when :object then out = {}
74
+ else
75
+ return aargh("Unknown package: #{package}", 1) unless package.is_a? Symbol
76
+ end
77
+
78
+ ARGV.each do |name|
79
+ s = load_schema(name)
80
+ if out.is_a? Array
81
+ out.push(s)
82
+ else
83
+ out[name] = s
84
+ end
85
+ rescue Errno::ENOENT
86
+ aargh "No such schema: #{name}", 2
87
+ rescue StandardError => e
88
+ aargh "#{e}\nInternal error: #{name}", 3
89
+ end
90
+
91
+ if output.nil?
92
+ fp = $stdout
93
+ else
94
+ fp = File.new(output, 'w')
95
+ end
96
+ if package == :bare
97
+ out.each do |item|
98
+ fp.puts(convert(item, format))
99
+ end
100
+ else
101
+ fp.puts(convert(out, format))
102
+ end
103
+ fp.close
104
+ 0
105
+ end
106
+
107
+ exit(main) if (defined? $unit_test).nil?
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright © 2023 Ismo Kärkkäinen
5
+ # Licensed under Universal Permissive License. See LICENSE.txt.
6
+
7
+ require_relative '../lib/common'
8
+ require_relative '../lib/subsets'
9
+ require 'optparse'
10
+ require 'set'
11
+
12
+
13
+ class Item
14
+ @@counter = 0
15
+ attr_reader :item, :ids
16
+
17
+ def initialize(item, label2id = {})
18
+ @item = item
19
+ @ids = [ @@counter ]
20
+ @@counter += 1
21
+ # This silently removes edges that have no valid end-point.
22
+ item.fetch('between', []).each { |x| @ids.push(label2id.fetch(x, -1)) }
23
+ end
24
+
25
+ def self.reset
26
+ @@counter = 0
27
+ end
28
+ end
29
+
30
+ def work_copy(src)
31
+ nodes = src.fetch('nodes', []).map { |n| Item.new(n) }
32
+ label2id = {}
33
+ nodes.each { |x| label2id[x.item['label']] = x.ids.first }
34
+ edges = src.fetch('edges', []).map { |e| Item.new(e, label2id) }
35
+ {
36
+ edges: edges,
37
+ nodes: nodes
38
+ }
39
+ end
40
+
41
+ def prepare_output(doc, work, chosen)
42
+ %i[edges nodes].each do |s|
43
+ work[s].select! do |x|
44
+ (x.ids.index { |id| !chosen.member?(id) }).nil?
45
+ end
46
+ doc[s.to_s] = work[s].map(&:item)
47
+ end
48
+ end
49
+
50
+ def gather_set(work, rules, current)
51
+ r = rules['sets'].fetch(current, nil)
52
+ return aargh("Undefined set: #{current}", 9) if r.nil?
53
+ result = Set.new
54
+ %i[nodes edges].each do |category|
55
+ work[category].each do |item|
56
+ if match_item(item.item, r.fetch(category, {}))
57
+ result.add(item.ids.first)
58
+ end
59
+ end
60
+ end
61
+ result
62
+ end
63
+
64
+ def gather(work, rules, current, above, set_cache)
65
+ e = rules['sets'].fetch(current, nil)
66
+ unless e.nil?
67
+ set = set_cache.fetch(current, nil)
68
+ return set unless set.nil?
69
+ # Evaluate needed set, store, and return it.
70
+ set = gather_set(work, rules, current)
71
+ set_cache[current] = set # Error may end up in cache, not a problem.
72
+ return set
73
+ end
74
+ return aargh("Loop in expressions for #{current}: #{above.reverse.join("\n")}", 8) if above.include?(current)
75
+ e = rules['expressions'].fetch(current, nil)
76
+ return aargh("Undefined expression: #{current}", 7) if e.nil?
77
+ above.push(current)
78
+ result = gather(work, rules, e.first, above, set_cache)
79
+ return result unless result.is_a?(Set)
80
+ (1...e.size).step(2) do |k|
81
+ op = e[k]
82
+ id = e[k + 1]
83
+ s = gather(work, rules, id, above, set_cache)
84
+ return s unless s.is_a?(Set)
85
+ case op
86
+ when :plus then result += s
87
+ when :minus then result -= s
88
+ else
89
+ return aargh("Unsupported operator: #{op}", 100)
90
+ end
91
+ end
92
+ above.pop
93
+ result
94
+ end
95
+
96
+ def main
97
+ input_output_schema = 'nodes'
98
+ input = nil
99
+ output = nil
100
+ selected = nil
101
+ ENV['POSIXLY_CORRECT'] = '1'
102
+ parser = OptionParser.new do |opts|
103
+ opts.summary_indent = ' '
104
+ opts.summary_width = 20
105
+ opts.banner = 'Usage: diagrammatron-subset [options] rule-file-names...'
106
+ opts.separator ''
107
+ opts.separator 'Options:'
108
+ opts.on('-i', '--input FILE', 'Input file name. Read from stdin if not given.') do |filename|
109
+ input = filename
110
+ end
111
+ opts.on('-o', '--output FILE', 'Output file name. Written to stdout if not given.') do |filename|
112
+ output = filename
113
+ end
114
+ opts.on('-s', '--select NAME', 'Select expression or subset NAME result.') do |name|
115
+ selected = name
116
+ end
117
+ opts.on('-h', '--help', 'Print this help and exit.') do
118
+ $stdout.puts %(#{opts}
119
+ Rule files can specify named subset selection rules and named expressions on
120
+ how to combine the subsets when determining which nodes and edges will form the
121
+ diagram.
122
+
123
+ Subset selection rules can apply to nodes only, edges only, or both. The rules
124
+ give for a field name an array of strings or patterns. If a node or edge
125
+ matches any of the rules, it belongs to the subset.
126
+
127
+ Expressions can refer to the subset or expression names and use operations to
128
+ form more complex subsets. In the simplest form, they just are a subset name.
129
+ Operations are a + b to add elements of b and a - b to drop elements of b. If
130
+ expression and subset have the same name, identifier refers to subset.
131
+
132
+ The select option gives the name of the expression or subset to use.
133
+
134
+ Edges to removed nodes are removed. Nodes with all edges removed are retained.
135
+ Output is the input file with remaining nodes and edges.
136
+
137
+ Input and output YAML file schema is returned by:
138
+ diagrammatron-schema #{input_output_schema}
139
+
140
+ Rule YAML file schema is returned by:
141
+ diagrammatron-schema subset
142
+
143
+ Note that input schema is minimal and does not restrict running this program
144
+ at a later stage in the pipeline after other tools have added their fields.
145
+ You can refer to fields outside the schema.
146
+ )
147
+ exit 0
148
+ end
149
+ end
150
+ parser.parse! ARGV
151
+
152
+ if selected.nil?
153
+ return aargh('Must select an expression or subset to use for output.', 2)
154
+ end
155
+
156
+ rules = {}
157
+ ARGV.each do |filename|
158
+ r = load_verified(filename, 'subset')
159
+ return 5 if r.nil?
160
+ return 6 unless check_rules(r, filename)
161
+ merge_any(r) # The 'any' field exists to avoid duplicating rules by hand.
162
+ merge_rules(rules, r)
163
+ end
164
+
165
+ if rules.fetch('sets', {}).key? selected
166
+ # Refers directly to subset so other expressions do not matter.
167
+ rules['expressions'] = { selected => selected }
168
+ elsif !rules.fetch('expressions', {}).key? selected
169
+ return aargh("Selected #{selected} is not expression or subset name.", 2)
170
+ end
171
+
172
+ doc = load_verified(input, input_output_schema)
173
+ return 2 if doc.nil?
174
+
175
+ begin
176
+ work = work_copy(doc)
177
+ return 3 if work.nil?
178
+ rescue StandardError
179
+ return aargh('Error processing input.', 3)
180
+ end
181
+
182
+ chosen = gather(work, rules, selected, [], {})
183
+ return chosen unless chosen.is_a?(Set)
184
+ prepare_output(doc, work, chosen)
185
+ save_verified(output, doc, 4, input_output_schema)
186
+ end
187
+
188
+ exit(main) if (defined? $unit_test).nil?
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Copyright © 2021, 2022 Ismo Kärkkäinen
4
+ # Copyright © 2021-2023 Ismo Kärkkäinen
5
5
  # Licensed under Universal Permissive License. See LICENSE.txt.
6
6
 
7
7
  require_relative '../lib/common'
8
8
  require 'optparse'
9
- require 'yaml'
10
- require 'json'
11
- require 'pathname'
12
9
 
13
10
 
14
11
  def convert(src)
@@ -63,10 +60,10 @@ def convert(src)
63
60
  { 'edges' => edges, 'nodes' => nodes }
64
61
  end
65
62
 
66
- $INPUT = nil
67
- $OUTPUT = nil
68
-
69
63
  def main
64
+ input = nil
65
+ output = nil
66
+ output_schema = 'nodes'
70
67
  parser = OptionParser.new do |opts|
71
68
  opts.summary_indent = ' '
72
69
  opts.summary_width = 20
@@ -74,24 +71,28 @@ def main
74
71
  opts.separator ''
75
72
  opts.separator 'Options:'
76
73
  opts.on('-i', '--input FILE', 'Input file name. Read from stdin if not given.') do |filename|
77
- $INPUT = filename
74
+ input = filename
78
75
  end
79
76
  opts.on('-o', '--output FILE', 'Output file name. Write to stdout if not given.') do |filename|
80
- $OUTPUT = filename
77
+ output = filename
81
78
  end
82
79
  opts.on('-h', '--help', 'Print this help and exit.') do
83
80
  $stdout.puts opts
84
81
  $stdout.puts %(
85
82
  Output is a YAML file with "name" used as "label" and "shape" used as is
86
- for nodes. For edges "tail" and "head" are used as the first and second element
87
- of "between" with the "name" as the identifier. Compound objects are ignored.
83
+ for nodes. For edges "tail" and "head" are used as the first and second
84
+ element of "between" with the "name" as the identifier. Compound objects
85
+ are ignored.
86
+
87
+ Output YAML file schema is returned by:
88
+ diagrammatron-schema #{output_schema}
88
89
  )
89
90
  exit 0
90
91
  end
91
92
  end
92
93
  parser.parse! ARGV
93
94
 
94
- doc = load_source
95
+ doc = load_source(input)
95
96
  exit(2) if doc.nil?
96
97
 
97
98
  begin
@@ -101,19 +102,7 @@ of "between" with the "name" as the identifier. Compound objects are ignored.
101
102
  end
102
103
  exit(3) if out.nil?
103
104
 
104
- begin
105
- if $OUTPUT.nil?
106
- $stdout.puts YAML.dump(out)
107
- else
108
- fp = Pathname.new $OUTPUT
109
- fp.open('w') do |f|
110
- f.puts YAML.dump(out)
111
- end
112
- end
113
- rescue StandardError => e
114
- aargh("#{e}\nFailed to write output: #{$OUTPUT}")
115
- exit 4
116
- end
105
+ save_verified(output, pout, 4, output_schema)
117
106
  end
118
107
 
119
108
  main if (defined? $unit_test).nil?
data/lib/common.rb CHANGED
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Copyright © 2021, 2022 Ismo Kärkkäinen
3
+ # Copyright © 2021-2023 Ismo Kärkkäinen
4
4
  # Licensed under Universal Permissive License. See LICENSE.txt.
5
5
 
6
+ require 'json_schemer'
7
+ require 'yaml'
8
+ require 'set'
9
+
10
+
6
11
  def aargh(message, return_value = nil)
7
12
  message = (message.map &(:to_s)).join("\n") if message.is_a? Array
8
13
  $stderr.puts message
@@ -20,9 +25,41 @@ end
20
25
  def load_source_hash(input)
21
26
  src = load_source(input)
22
27
  unless src.nil?
23
- return aargh('Input is not a mapping.') unless src.is_a? Hash
28
+ return aargh("#{input} is not a simple mapping.") unless src.is_a? Hash
29
+ end
30
+ src
31
+ end
32
+
33
+ def list_schemas
34
+ (Dir.entries(File.dirname(__FILE__)).select { |name| name.upcase.end_with?('.YAML') }).map { |name| name[0, name.size - 5] }
35
+ end
36
+
37
+ def load_schema(schema_name)
38
+ YAML.safe_load_file(File.join(File.dirname(__FILE__), "#{schema_name}.yaml"))
39
+ end
40
+
41
+ def make_schemer(schema, reading)
42
+ JSONSchemer.schema(JSON.generate(schema),
43
+ meta_schema: 'https://json-schema.org/draft/2020-12/schema',
44
+ insert_property_defaults: reading)
45
+ end
46
+
47
+ def load_verified(input, schema_name)
48
+ src = load_source(input)
49
+ unless src.nil?
50
+ s = load_schema(schema_name)
51
+ schemer = make_schemer(s, true)
52
+ errs = schemer.validate(src).to_a
53
+ unless errs.empty?
54
+ aargh (errs.map { |e| e['error'] }).join("\n")
55
+ src = nil
56
+ end
24
57
  end
25
58
  src
59
+ rescue Errno::ENOENT
60
+ aargh "Could not load #{schema_name}"
61
+ rescue StandardError => e
62
+ aargh "Internal error: #{e}\n#{e.backtrace.join("\n")}\nFailed to read #{schema_name}"
26
63
  end
27
64
 
28
65
  def dump_result(output, doc, error_return)
@@ -38,3 +75,14 @@ def dump_result(output, doc, error_return)
38
75
  rescue StandardError => e
39
76
  aargh([ e, "Failed to write output: #{output || 'stdout'}" ], error_return)
40
77
  end
78
+
79
+ def save_verified(output, doc, error_return, schema_name)
80
+ s = load_schema(schema_name)
81
+ schemer = make_schemer(s, false)
82
+ errs = schemer.validate(doc).to_a
83
+ unless errs.empty?
84
+ aargh (errs.map { |e| e['error'] }).join("\n")
85
+ return error_return
86
+ end
87
+ dump_result(output, YAML.dump(doc, line_width: 1_000_000), error_return)
88
+ end