expressir 2.4.3 → 2.4.4
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 +4 -4
- data/.cargo/config.toml +3 -0
- data/.github/workflows/rust-ext.yml +41 -0
- data/.github/workflows/verify_remarks.yml +33 -0
- data/.gitignore +6 -0
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +1 -0
- data/Cargo.toml +7 -0
- data/README.adoc +65 -0
- data/Rakefile +12 -0
- data/TODO.parity-ee/00-overview.md +58 -0
- data/TODO.parity-ee/01-annex-g-rule-extraction.md +41 -0
- data/TODO.parity-ee/02-eeng-algorithm-inventory.md +8 -0
- data/TODO.parity-ee/03-eeng-oracle-harness.md +33 -0
- data/TODO.parity-ee/04-shtolo-concatenate.md +44 -0
- data/TODO.parity-ee/05-shtolo-longform-flatten.md +53 -0
- data/TODO.parity-ee/06-interface-scheduling-parity.md +47 -0
- data/TODO.parity-ee/07-semantic-checks-port.md +35 -0
- data/TODO.parity-ee/08-pretty-roundtrip-gate.md +27 -0
- data/TODO.parity-ee/09-smrl-index-and-listing.md +22 -0
- data/TODO.parity-ee/10-interface-dot-graph.md +21 -0
- data/TODO.parity-ee/11-import-eeng-tests.md +79 -0
- data/TODO.parity-ee/parity-matrix.md +108 -0
- data/exe/expressir +6 -0
- data/ext/expressir_core/Cargo.toml +24 -0
- data/ext/expressir_core/extconf.rb +30 -0
- data/ext/expressir_core/src/lib.rs +446 -0
- data/lib/expressir/cli.rb +49 -4
- data/lib/expressir/commands/check.rb +56 -0
- data/lib/expressir/commands/expand.rb +26 -0
- data/lib/expressir/commands/fix.rb +33 -0
- data/lib/expressir/commands/flatten.rb +29 -0
- data/lib/expressir/commands/parity_inputs.rb +85 -0
- data/lib/expressir/commands/validate.rb +22 -0
- data/lib/expressir/commands.rb +5 -0
- data/lib/expressir/express/builder_registry.rb +11 -2
- data/lib/expressir/express/builders/entity_decl_builder.rb +17 -7
- data/lib/expressir/express/builders/subtype_constraint_builder.rb +3 -1
- data/lib/expressir/express/builders/type_builder.rb +8 -0
- data/lib/expressir/express/builders/unique_rule_builder.rb +8 -1
- data/lib/expressir/express/checker.rb +458 -0
- data/lib/expressir/express/concatenator.rb +79 -0
- data/lib/expressir/express/core.rb +66 -0
- data/lib/expressir/express/formatter.rb +25 -3
- data/lib/expressir/express/formatters/data_types_formatter.rb +2 -2
- data/lib/expressir/express/formatters/declarations_formatter.rb +1 -0
- data/lib/expressir/express/formatters/remark_formatter.rb +30 -6
- data/lib/expressir/express/formatters/statements_formatter.rb +7 -1
- data/lib/expressir/express/interface_dot.rb +105 -0
- data/lib/expressir/express/listing.rb +174 -0
- data/lib/expressir/express/model_traversal.rb +42 -0
- data/lib/expressir/express/node_position_index.rb +18 -4
- data/lib/expressir/express/parser.rb +242 -4
- data/lib/expressir/express/pretty_gate.rb +61 -0
- data/lib/expressir/express/refs_overlay.rb +47 -0
- data/lib/expressir/express/remark_attacher.rb +122 -10
- data/lib/expressir/express/remark_overlay.rb +86 -0
- data/lib/expressir/express/schema_plain_source_formatter.rb +11 -0
- data/lib/expressir/express/schema_source_formatter.rb +5 -5
- data/lib/expressir/express/self_schema_reference.rb +126 -0
- data/lib/expressir/express/shtolo.rb +522 -0
- data/lib/expressir/express.rb +13 -0
- data/lib/expressir/model/concerns.rb +15 -1
- data/lib/expressir/model/declarations/entity.rb +6 -1
- data/lib/expressir/model/declarations/interface.rb +13 -0
- data/lib/expressir/model/declarations/schema.rb +74 -23
- data/lib/expressir/model/indexes/item_graph.rb +135 -0
- data/lib/expressir/model/indexes.rb +1 -0
- data/lib/expressir/model/model_element.rb +30 -1
- data/lib/expressir/model/remark_info.rb +20 -1
- data/lib/expressir/model/remark_placement.rb +15 -2
- data/lib/expressir/model/repository.rb +8 -2
- data/lib/expressir/model.rb +2 -1
- data/lib/expressir/version.rb +1 -1
- data/lib/tasks/verify_remarks.rake +9 -0
- data/rakelib/verify_remarks.rake +16 -0
- metadata +42 -2
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Express
|
|
5
|
+
# Semantic checks modeled on eeng kernel/check.lisp + check-notes.lisp
|
|
6
|
+
# (expressir parity-ee stage 07). Each note preserves the eeng note id
|
|
7
|
+
# so oracle differentials stay cross-referenceable.
|
|
8
|
+
#
|
|
9
|
+
# Coverage is a deliberate subset of eeng's check-p11 catalog:
|
|
10
|
+
# check-schema-interface-redundant
|
|
11
|
+
# check-schema-iface-resource-duplicate
|
|
12
|
+
# check-subtype-ref
|
|
13
|
+
# check-subtype-cycle
|
|
14
|
+
# check-duplicate-declaration
|
|
15
|
+
# check-where-name-pattern (entity, type, rule)
|
|
16
|
+
# check-unique-name-pattern
|
|
17
|
+
# check-unresolved-ref (aggregate of eeng type/entity unparsed notes)
|
|
18
|
+
# check-select-extended-type
|
|
19
|
+
# check-enumeration-extended-type
|
|
20
|
+
#
|
|
21
|
+
# Severity: :error stops a clean SHTOLO run; :warning is advisory.
|
|
22
|
+
class Checker
|
|
23
|
+
Note = Struct.new(:id, :severity, :schema, :message, :node, keyword_init: true)
|
|
24
|
+
|
|
25
|
+
WHERE_LABEL = /\AWR\d+\z/i
|
|
26
|
+
UNIQUE_LABEL = /\AUR\d+\z/i
|
|
27
|
+
DECL_COLLECTIONS = %i[types entities functions procedures constants rules
|
|
28
|
+
subtype_constraints].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :notes
|
|
31
|
+
|
|
32
|
+
def initialize(repository)
|
|
33
|
+
@repository = repository
|
|
34
|
+
@notes = []
|
|
35
|
+
# Case-folded index to LISTS of schemas: EXPRESS schema ids are
|
|
36
|
+
# case-insensitive, and two schemas differing only in case are
|
|
37
|
+
# distinct declarations that must not silently overwrite each other.
|
|
38
|
+
@by_name = {}
|
|
39
|
+
repository.schemas.compact.each do |schema|
|
|
40
|
+
next unless schema.id
|
|
41
|
+
|
|
42
|
+
(@by_name[schema.id.safe_downcase] ||= []) << schema
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check
|
|
47
|
+
@notes = []
|
|
48
|
+
@repository.schemas.compact.each { |schema| check_schema(schema) }
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def errors
|
|
53
|
+
notes.select { |n| n.severity == :error }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def warnings
|
|
57
|
+
notes.select { |n| n.severity == :warning }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def valid?
|
|
61
|
+
errors.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def report(io = $stdout)
|
|
65
|
+
notes.each do |n|
|
|
66
|
+
io.puts "[#{n.severity}] #{n.id}: #{n.message}"
|
|
67
|
+
end
|
|
68
|
+
io.puts "#{errors.size} error(s), #{warnings.size} warning(s)"
|
|
69
|
+
io
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
BUILTINS = %w[
|
|
73
|
+
integer real number string binary boolean logical generic
|
|
74
|
+
generic_entity aggregate array bag list set
|
|
75
|
+
true false unknown self const_e pi
|
|
76
|
+
abs acos asin atan cos exp format hibound hiindex length
|
|
77
|
+
log log2 log10 lobound loindex nvl odd rolesof sin sizeof
|
|
78
|
+
sqrt tan typeof usedin value value_in value_unique exists
|
|
79
|
+
blength insert remove
|
|
80
|
+
].freeze
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def note!(id, severity, schema, message, node = nil)
|
|
85
|
+
@notes << Note.new(id: id, severity: severity, schema: schema&.id,
|
|
86
|
+
message: message, node: node)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def check_schema(schema)
|
|
90
|
+
check_duplicates(schema)
|
|
91
|
+
check_interfaces(schema)
|
|
92
|
+
check_self_schema_reference(schema)
|
|
93
|
+
schema.entities.each { |e| check_entity(schema, e) }
|
|
94
|
+
schema.types.each do |t|
|
|
95
|
+
check_type(schema, t)
|
|
96
|
+
check_where_rules(schema, t, t.where_rules)
|
|
97
|
+
end
|
|
98
|
+
schema.rules.each { |r| check_where_rules(schema, r, r.where_rules) }
|
|
99
|
+
check_unresolved_refs(schema)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Self-qualified literals naming the CURRENT schema (#125): local
|
|
103
|
+
# items must not be prefixed, foreign items must name their true
|
|
104
|
+
# defining schema (ISO 10303-11 scope rules).
|
|
105
|
+
def check_self_schema_reference(schema)
|
|
106
|
+
SelfSchemaReference.matches(schema).each do |match|
|
|
107
|
+
case match.status
|
|
108
|
+
when :local
|
|
109
|
+
note!(:check_self_schema_reference, :warning, schema,
|
|
110
|
+
"self-qualified '#{schema.id}.#{match.item}' — " \
|
|
111
|
+
"'#{match.item}' is local; drop the prefix", match.literal)
|
|
112
|
+
when :foreign
|
|
113
|
+
note!(:check_self_schema_reference, :warning, schema,
|
|
114
|
+
"self-qualified '#{schema.id}.#{match.item}' — item is " \
|
|
115
|
+
"defined in '#{match.source_schema.id}'", match.literal)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Two declarations in one schema sharing a (case-insensitive) name is
|
|
121
|
+
# invalid EXPRESS — the second can never be referenced.
|
|
122
|
+
def check_duplicates(schema)
|
|
123
|
+
seen = {}
|
|
124
|
+
DECL_COLLECTIONS.each do |coll|
|
|
125
|
+
Array(schema.public_send(coll)).each do |decl|
|
|
126
|
+
next unless decl.respond_to?(:id) && decl.id
|
|
127
|
+
|
|
128
|
+
key = decl.id.safe_downcase
|
|
129
|
+
if seen.key?(key)
|
|
130
|
+
note!(:check_duplicate_declaration, :error, schema,
|
|
131
|
+
"duplicate declaration '#{decl.id}' (also declared in " \
|
|
132
|
+
"#{seen[key]})", decl)
|
|
133
|
+
else
|
|
134
|
+
seen[key] = coll.to_s
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# eeng: check-schema-interface-redundant +
|
|
141
|
+
# check-schema-iface-resource-duplicate.
|
|
142
|
+
# USE FROM and REFERENCE FROM are different clauses with different
|
|
143
|
+
# semantics; only interfaces of the SAME kind are compared.
|
|
144
|
+
def check_interfaces(schema)
|
|
145
|
+
ifaces = Array(schema.interfaces)
|
|
146
|
+
ifaces.each_with_index do |iface, i|
|
|
147
|
+
target = iface_schema_name(iface)
|
|
148
|
+
next unless target
|
|
149
|
+
|
|
150
|
+
later = ifaces[(i + 1)..] || []
|
|
151
|
+
later.each do |other|
|
|
152
|
+
next unless iface_schema_name(other)&.safe_downcase == target.safe_downcase
|
|
153
|
+
next unless other.kind == iface.kind
|
|
154
|
+
|
|
155
|
+
a = resource_names(iface)
|
|
156
|
+
b = resource_names(other)
|
|
157
|
+
if a.empty? || b.empty? || (b - a).empty?
|
|
158
|
+
note!(:check_schema_interface_redundant, :warning, schema,
|
|
159
|
+
"redundant #{iface.kind} interface to #{target}", other)
|
|
160
|
+
else
|
|
161
|
+
dup = a & b
|
|
162
|
+
next if dup.empty?
|
|
163
|
+
|
|
164
|
+
note!(:check_schema_iface_resource_duplicate, :warning, schema,
|
|
165
|
+
"duplicate resources #{dup.join(', ')} on #{iface.kind} " \
|
|
166
|
+
"interface to #{target}", other)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
foreign = resolve_schema(target)
|
|
171
|
+
unless foreign
|
|
172
|
+
note!(:check_unresolved_ref, :error, schema,
|
|
173
|
+
"interface schema '#{target}' not found", iface)
|
|
174
|
+
next
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
resource_names(iface).each do |name|
|
|
178
|
+
next if foreign_decl?(foreign, name)
|
|
179
|
+
|
|
180
|
+
note!(:check_unresolved_ref, :error, schema,
|
|
181
|
+
"interface resource '#{name}' not in schema '#{target}'", iface)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def iface_schema_name(iface)
|
|
187
|
+
iface.schema.is_a?(String) ? iface.schema : iface.schema&.id
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resource_names(iface)
|
|
191
|
+
Array(iface.items).filter_map do |item|
|
|
192
|
+
ref = item.ref.is_a?(String) ? item.ref : item.ref&.id
|
|
193
|
+
ref || item.id
|
|
194
|
+
end.map(&:safe_downcase)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def foreign_decl?(schema, name)
|
|
198
|
+
key = name.safe_downcase
|
|
199
|
+
DECL_COLLECTIONS.any? do |coll|
|
|
200
|
+
Array(schema.public_send(coll)).any? { |d| d.id&.safe_downcase == key }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def check_entity(schema, entity)
|
|
205
|
+
Array(entity.subtype_of).each do |ref|
|
|
206
|
+
id = ref.is_a?(String) ? ref : ref.id
|
|
207
|
+
next if id.nil? || find_entity(schema, id)
|
|
208
|
+
|
|
209
|
+
note!(:check_subtype_ref, :error, schema,
|
|
210
|
+
"ENTITY #{entity.id}: subtype '#{id}' not found", entity)
|
|
211
|
+
end
|
|
212
|
+
check_subtype_cycles(schema, entity)
|
|
213
|
+
|
|
214
|
+
check_where_rules(schema, entity, entity.where_rules)
|
|
215
|
+
Array(entity.unique_rules).each do |ur|
|
|
216
|
+
next if ur.id.nil? || ur.id.match?(UNIQUE_LABEL)
|
|
217
|
+
|
|
218
|
+
note!(:check_unique_name_pattern, :warning, schema,
|
|
219
|
+
"ENTITY #{entity.id}: UNIQUE label '#{ur.id}' is not UR<n>", ur)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Cycle detection over SUBTYPE OF edges: per-path tracking with a
|
|
224
|
+
# proven-acyclic memo, so convergent (diamond) branches are not
|
|
225
|
+
# misread as cycles (#411).
|
|
226
|
+
def check_subtype_cycles(schema, entity)
|
|
227
|
+
walk_subtype_edges(schema, entity, entity,
|
|
228
|
+
[entity.id.safe_downcase], subtype_clean_memo(schema))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def subtype_clean_memo(schema)
|
|
232
|
+
(@subtype_clean ||= {})[schema] ||= {}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Returns true when a cycle is reachable from +node+; +path_keys+ is
|
|
236
|
+
# the current DFS chain and +clean+ memoizes nodes proven acyclic.
|
|
237
|
+
def walk_subtype_edges(schema, start, node, path_keys, clean)
|
|
238
|
+
cyclic = false
|
|
239
|
+
Array(node.subtype_of).each do |ref|
|
|
240
|
+
id = ref.is_a?(String) ? ref : ref.id
|
|
241
|
+
next unless id
|
|
242
|
+
|
|
243
|
+
nxt = find_entity(schema, id)
|
|
244
|
+
next unless nxt
|
|
245
|
+
|
|
246
|
+
key = nxt.id.safe_downcase
|
|
247
|
+
if path_keys.include?(key)
|
|
248
|
+
note!(:check_subtype_cycle, :error, schema,
|
|
249
|
+
"ENTITY #{start.id}: subtype inheritance cycle through " \
|
|
250
|
+
"'#{nxt.id}'", start)
|
|
251
|
+
cyclic = true
|
|
252
|
+
elsif clean[key]
|
|
253
|
+
next
|
|
254
|
+
else
|
|
255
|
+
cyclic ||= walk_subtype_edges(
|
|
256
|
+
schema, start, nxt, path_keys + [key], clean
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
clean[node.id.safe_downcase] = true unless cyclic
|
|
261
|
+
cyclic
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def check_where_rules(schema, owner, rules)
|
|
265
|
+
Array(rules).each do |wr|
|
|
266
|
+
next if wr.id.nil? || wr.id.match?(WHERE_LABEL)
|
|
267
|
+
|
|
268
|
+
note!(:check_where_name_pattern, :warning, schema,
|
|
269
|
+
"#{owner.class.name.split('::').last} #{owner.id}: " \
|
|
270
|
+
"WHERE label '#{wr.id}' is not WR<n>", wr)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def check_type(schema, type)
|
|
275
|
+
ut = type.underlying_type
|
|
276
|
+
case ut
|
|
277
|
+
when Model::DataTypes::Select
|
|
278
|
+
if ut.based_on && !resolve_type(schema, ut.based_on)
|
|
279
|
+
note!(:check_select_extended_type, :error, schema,
|
|
280
|
+
"TYPE #{type.id}: BASED_ON '#{ref_id(ut.based_on)}' not found", type)
|
|
281
|
+
end
|
|
282
|
+
when Model::DataTypes::Enumeration
|
|
283
|
+
if ut.based_on && !resolve_type(schema, ut.based_on)
|
|
284
|
+
note!(:check_enumeration_extended_type, :error, schema,
|
|
285
|
+
"TYPE #{type.id}: BASED_ON '#{ref_id(ut.based_on)}' not found", type)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Walk SimpleReferences whose base_path was never filled in by the
|
|
291
|
+
# resolver — the expressir equivalent of eeng's unparsed-type notes.
|
|
292
|
+
# Locally bound identifiers are not unresolved: QUERY variables,
|
|
293
|
+
# REPEAT control variables, attribute names visible in the schema,
|
|
294
|
+
# and the `?` of aggregate bounds (#396).
|
|
295
|
+
def check_unresolved_refs(schema)
|
|
296
|
+
aliases = alias_map(schema)
|
|
297
|
+
bound = bound_identifiers(schema)
|
|
298
|
+
each_node(schema) do |node|
|
|
299
|
+
next unless node.is_a?(Model::References::SimpleReference)
|
|
300
|
+
next if node.base_path
|
|
301
|
+
next if node.parent.is_a?(Model::Declarations::InterfaceItem)
|
|
302
|
+
next if node.parent.is_a?(Model::References::AttributeReference)
|
|
303
|
+
next if node.id.nil? || builtin?(node.id)
|
|
304
|
+
next if aliases.key?(node.id.safe_downcase)
|
|
305
|
+
next if bound.include?(node.id.safe_downcase)
|
|
306
|
+
|
|
307
|
+
# Skip self-ids of declarations (entity/type names as the decl itself)
|
|
308
|
+
next if declaration_id?(schema, node)
|
|
309
|
+
|
|
310
|
+
note!(:check_unresolved_ref, :error, schema,
|
|
311
|
+
"unresolved reference '#{node.id}'", node)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Names the schema's own constructs bind: QUERY variables, REPEAT
|
|
316
|
+
# control variables, attribute names of every entity (including the
|
|
317
|
+
# inherited ones visible through SUBTYPE OF — an over-approximation on
|
|
318
|
+
# purpose, so the error-severity walk never flags a legal identifier).
|
|
319
|
+
def bound_identifiers(schema)
|
|
320
|
+
@bound_identifiers ||= {}.compare_by_identity
|
|
321
|
+
@bound_identifiers[schema] ||= begin
|
|
322
|
+
bound = Set.new(["?"])
|
|
323
|
+
each_node(schema) do |node|
|
|
324
|
+
case node
|
|
325
|
+
when Model::Expressions::QueryExpression, Model::Statements::Repeat
|
|
326
|
+
bound << node.id.safe_downcase if node.respond_to?(:id) && node.id
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
schema.entities.each { |e| collect_attribute_names(e, bound) }
|
|
330
|
+
visible_entities(schema).each { |e| collect_attribute_names(e, bound) }
|
|
331
|
+
bound
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def collect_attribute_names(entity, bound)
|
|
336
|
+
Array(entity.attributes).each do |attr|
|
|
337
|
+
bound << attr.id.safe_downcase if attr.respond_to?(:id) && attr.id
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# `USE FROM s (orig AS alias)` makes `alias` the locally visible name;
|
|
342
|
+
# references written with the alias are resolved, not unresolved.
|
|
343
|
+
# Maps alias downcase => [foreign schema, original downcase].
|
|
344
|
+
def alias_map(schema)
|
|
345
|
+
@alias_maps ||= {}.compare_by_identity
|
|
346
|
+
@alias_maps[schema] ||= begin
|
|
347
|
+
map = {}
|
|
348
|
+
Array(schema.interfaces).each do |iface|
|
|
349
|
+
foreign = resolve_schema(iface_schema_name(iface))
|
|
350
|
+
next unless foreign
|
|
351
|
+
|
|
352
|
+
Array(iface.items).each do |item|
|
|
353
|
+
original = item.ref.is_a?(String) ? item.ref : item.ref&.id
|
|
354
|
+
next unless original && item.id
|
|
355
|
+
|
|
356
|
+
map[item.id.safe_downcase] = [foreign, original.safe_downcase]
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
map
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def declaration_id?(_schema, node)
|
|
364
|
+
parent = node.parent
|
|
365
|
+
parent.respond_to?(:id) && parent.id&.safe_downcase == node.id.safe_downcase
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def builtin?(id)
|
|
369
|
+
BUILTINS.include?(id.safe_downcase)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Resolve a schema id case-insensitively, preferring the exact-case
|
|
373
|
+
# match when several folded ids collide (they are distinct schemas).
|
|
374
|
+
def resolve_schema(name)
|
|
375
|
+
return nil unless name
|
|
376
|
+
|
|
377
|
+
group = @by_name[name.safe_downcase]
|
|
378
|
+
return nil unless group
|
|
379
|
+
|
|
380
|
+
group.find { |s| s.id == name } || group.first
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def find_entity(schema, id)
|
|
384
|
+
key = id.safe_downcase
|
|
385
|
+
own = schema.entities.find { |e| e.id.safe_downcase == key }
|
|
386
|
+
return own if own
|
|
387
|
+
|
|
388
|
+
# interface AS-renames: the alias names a foreign declaration.
|
|
389
|
+
if (aliased = alias_map(schema)[key])
|
|
390
|
+
foreign, original = aliased
|
|
391
|
+
return foreign.entities.find { |e| e.id.safe_downcase == original }
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
visible_entities(schema).find { |e| e.id.safe_downcase == key }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def resolve_type(schema, ref)
|
|
398
|
+
id = ref_id(ref)
|
|
399
|
+
return false unless id
|
|
400
|
+
|
|
401
|
+
key = id.safe_downcase
|
|
402
|
+
own = schema.types.find { |t| t.id.safe_downcase == key }
|
|
403
|
+
return true if own
|
|
404
|
+
|
|
405
|
+
if (aliased = alias_map(schema)[key])
|
|
406
|
+
foreign, original = aliased
|
|
407
|
+
return foreign.types.any? { |t| t.id.safe_downcase == original }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
visible_types(schema).any? { |t| t.id.safe_downcase == key }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def ref_id(ref)
|
|
414
|
+
ref.is_a?(String) ? ref : ref&.id
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Foreign declarations visible through +schema+'s interfaces. With an
|
|
418
|
+
# item list, the LISTED names are AS-imported: match the originals.
|
|
419
|
+
def visible_from(schema, collection)
|
|
420
|
+
Array(schema.interfaces).flat_map do |iface|
|
|
421
|
+
foreign = resolve_schema(iface_schema_name(iface))
|
|
422
|
+
next [] unless foreign
|
|
423
|
+
|
|
424
|
+
names = resource_names(iface)
|
|
425
|
+
decls = Array(foreign.public_send(collection))
|
|
426
|
+
names.empty? ? decls : decls.select { |d| names.include?(d.id.safe_downcase) }
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def visible_entities(schema)
|
|
431
|
+
visible_from(schema, :entities)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def visible_types(schema)
|
|
435
|
+
visible_from(schema, :types)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def each_node(root)
|
|
439
|
+
stack = [root]
|
|
440
|
+
until stack.empty?
|
|
441
|
+
node = stack.pop
|
|
442
|
+
yield node
|
|
443
|
+
next unless node.is_a?(Model::ModelElement)
|
|
444
|
+
|
|
445
|
+
node.class.attributes.each_key do |attr|
|
|
446
|
+
next if Model::ModelElement::SKIP_ATTRIBUTES.include?(attr)
|
|
447
|
+
|
|
448
|
+
value = node.public_send(attr)
|
|
449
|
+
case value
|
|
450
|
+
when Array then value.each { |item| stack << item if item.is_a?(Model::ModelElement) }
|
|
451
|
+
when Model::ModelElement then stack << value
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Expressir
|
|
4
|
+
module Express
|
|
5
|
+
# Builds the concatenated EXPRESS artifact (expressir#247): the
|
|
6
|
+
# root schema plus every schema reachable through USE FROM /
|
|
7
|
+
# REFERENCE FROM, one section per source schema, ordered
|
|
8
|
+
# alphabetically by schema name — the Express Engine
|
|
9
|
+
# `--concat_schema` semantics.
|
|
10
|
+
module Concatenator
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Schemas of the interface closure of `root_schema`, deduplicated
|
|
14
|
+
# by name and sorted alphabetically — the exact set and order the
|
|
15
|
+
# concatenated artifact contains. `repository` must hold every
|
|
16
|
+
# schema the closure can reach.
|
|
17
|
+
def closure(root_schema, repository)
|
|
18
|
+
by_name = repository.schemas.to_h { |s| [s.id.safe_downcase, s] }
|
|
19
|
+
visited = {}
|
|
20
|
+
queue = [root_schema]
|
|
21
|
+
until queue.empty?
|
|
22
|
+
schema = queue.shift
|
|
23
|
+
key = schema.id.safe_downcase
|
|
24
|
+
next if visited.key?(key)
|
|
25
|
+
|
|
26
|
+
visited[key] = schema
|
|
27
|
+
Array(schema.interfaces).each do |iface|
|
|
28
|
+
target = interface_target(iface, by_name)
|
|
29
|
+
queue << target if target && !visited.key?(target.id.safe_downcase)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
visited.values.sort_by { |s| s.id.safe_downcase }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def interface_target(iface, by_name)
|
|
36
|
+
name = iface.schema.is_a?(String) ? iface.schema : iface.schema&.id
|
|
37
|
+
return nil unless name
|
|
38
|
+
|
|
39
|
+
by_name[name.safe_downcase]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Writes the artifact: a provenance banner, the schema index
|
|
43
|
+
# (name, file, version), then each source file verbatim behind a
|
|
44
|
+
# `-- NAME (file)` separator.
|
|
45
|
+
def write(io, schemas)
|
|
46
|
+
io.puts "(* Concatenated File produced by Expressir #{Expressir::Version::VERSION}"
|
|
47
|
+
io.puts
|
|
48
|
+
io.puts "#{schemas.size} Schemata for Concatenated File"
|
|
49
|
+
io.puts
|
|
50
|
+
schemas.each do |schema|
|
|
51
|
+
io.puts schema.id.safe_downcase
|
|
52
|
+
io.puts " Filename: #{schema.file}"
|
|
53
|
+
io.puts " Version: #{schema.version&.value}" if schema.version
|
|
54
|
+
io.puts
|
|
55
|
+
end
|
|
56
|
+
io.puts " *)"
|
|
57
|
+
io.puts
|
|
58
|
+
schemas.each do |schema|
|
|
59
|
+
io.puts "--"
|
|
60
|
+
io.puts "-- #{schema.id.upcase} (#{schema.file})"
|
|
61
|
+
io.puts "--"
|
|
62
|
+
io.write(File.read(schema.file))
|
|
63
|
+
io.puts
|
|
64
|
+
io.puts
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One-call convenience: closure of the root schema found in
|
|
69
|
+
# `repository`, written to `path`.
|
|
70
|
+
def call(root_schema, repository, path)
|
|
71
|
+
schemas = closure(root_schema, repository)
|
|
72
|
+
File.open(path, "w") do |file|
|
|
73
|
+
write(file, schemas)
|
|
74
|
+
end
|
|
75
|
+
schemas
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Expressir
|
|
6
|
+
module Express
|
|
7
|
+
# Ruby bridge to the expressir-core Rust crate (parse + model
|
|
8
|
+
# extraction). Available when the native extension is built;
|
|
9
|
+
# callers fall back to the pure-Ruby parser otherwise.
|
|
10
|
+
module Core
|
|
11
|
+
NATIVE_AVAILABLE = begin
|
|
12
|
+
require "expressir/expressir_core"
|
|
13
|
+
true
|
|
14
|
+
rescue LoadError
|
|
15
|
+
begin
|
|
16
|
+
require_relative "../../../ext/expressir_core/expressir_core"
|
|
17
|
+
true
|
|
18
|
+
rescue LoadError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Parse EXPRESS source and return the model as a plain Hash in the
|
|
24
|
+
# lutaml wire shape (to_hash-compatible), ready for
|
|
25
|
+
# Expressir::Model::ExpFile.from_hash. `path` is the relative file
|
|
26
|
+
# path used for file/base_path wiring in the model.
|
|
27
|
+
def self.parse_to_model_hash(source, path)
|
|
28
|
+
JSON.parse(::Expressir::Core.parse_to_model_hash(source, path))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse EXPRESS source into the hydrated model directly: the
|
|
32
|
+
# extension walks the wire definition once and constructs every
|
|
33
|
+
# node through Serializable.instantiate (lutaml-model's fast
|
|
34
|
+
# bulk constructor). Falls back to the hash path when either
|
|
35
|
+
# the extension or the lutaml-model instantiate API is absent.
|
|
36
|
+
def self.parse_to_model(source, path)
|
|
37
|
+
unless ::Expressir::Core.respond_to?(:parse_to_model) &&
|
|
38
|
+
Model::ExpFile.respond_to?(:instantiate)
|
|
39
|
+
raise NotImplementedError,
|
|
40
|
+
"direct model construction unavailable; use parse_to_model_hash"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
::Expressir::Core.parse_to_model(source, path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Compile many files concurrently: jobs are [read_path,
|
|
47
|
+
# wire_path] pairs; the block receives (wire_path, model, error)
|
|
48
|
+
# per job in completion order. Workers run on native threads, so
|
|
49
|
+
# they keep compiling while the block hydrates and post-processes
|
|
50
|
+
# each model.
|
|
51
|
+
def self.parse_batch(jobs, workers: 0)
|
|
52
|
+
unless ::Expressir::Core.respond_to?(:const_defined?) &&
|
|
53
|
+
::Expressir::Core.const_defined?(:BatchStream)
|
|
54
|
+
raise NotImplementedError,
|
|
55
|
+
"batch compile unavailable; parse files individually"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
stream = ::Expressir::Core::BatchStream.start(jobs, workers)
|
|
59
|
+
while (item = stream.next)
|
|
60
|
+
path, model, error = item
|
|
61
|
+
yield(path, model, error)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -82,16 +82,38 @@ module Expressir
|
|
|
82
82
|
return ""
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
place_remarks(send(handler, node), node)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Puts a node's remarks back around its formatted text. Each placement
|
|
91
|
+
# is written back a different way, and this is the one place that knows
|
|
92
|
+
# all three: an inline remark is appended after the whole statement, an
|
|
93
|
+
# opener remark is spliced onto its first line, and leading remarks go
|
|
94
|
+
# on lines above it.
|
|
95
|
+
#
|
|
96
|
+
# Some handlers return nil; appending and splicing must not turn that
|
|
97
|
+
# into "".
|
|
98
|
+
def place_remarks(formatted, node)
|
|
87
99
|
inline = format_inline_statement_remarks(node)
|
|
88
100
|
formatted += inline if formatted.is_a?(String) && !inline.empty?
|
|
101
|
+
formatted = splice_opener_remark(formatted,
|
|
102
|
+
format_opener_remarks(node))
|
|
89
103
|
|
|
90
104
|
leading = format_leading_statement_remarks(node)
|
|
91
105
|
leading.empty? ? formatted : [*leading, formatted].join("\n")
|
|
92
106
|
end
|
|
93
107
|
|
|
94
|
-
|
|
108
|
+
# A remark that trailed a compound statement's opening line goes back on
|
|
109
|
+
# that line. Appending it like an ordinary inline remark would carry the
|
|
110
|
+
# remark of `IF x THEN -- why` down past its END_IF.
|
|
111
|
+
def splice_opener_remark(formatted, opener)
|
|
112
|
+
return formatted unless formatted.is_a?(String) && !opener.empty?
|
|
113
|
+
|
|
114
|
+
head, newline, rest = formatted.partition("\n")
|
|
115
|
+
"#{head}#{opener}#{newline}#{rest}"
|
|
116
|
+
end
|
|
95
117
|
|
|
96
118
|
def format_noop(_node)
|
|
97
119
|
""
|
|
@@ -190,7 +190,7 @@ module Expressir
|
|
|
190
190
|
"GENERIC_ENTITY",
|
|
191
191
|
*if node.id
|
|
192
192
|
[
|
|
193
|
-
":",
|
|
193
|
+
" : ",
|
|
194
194
|
node.id,
|
|
195
195
|
]
|
|
196
196
|
end,
|
|
@@ -202,7 +202,7 @@ module Expressir
|
|
|
202
202
|
"GENERIC",
|
|
203
203
|
*if node.id
|
|
204
204
|
[
|
|
205
|
-
":",
|
|
205
|
+
" : ",
|
|
206
206
|
node.id,
|
|
207
207
|
]
|
|
208
208
|
end,
|