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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.github/workflows/rust-ext.yml +41 -0
  4. data/.github/workflows/verify_remarks.yml +33 -0
  5. data/.gitignore +6 -0
  6. data/.rubocop.yml +4 -0
  7. data/.rubocop_todo.yml +1 -0
  8. data/Cargo.toml +7 -0
  9. data/README.adoc +65 -0
  10. data/Rakefile +12 -0
  11. data/TODO.parity-ee/00-overview.md +58 -0
  12. data/TODO.parity-ee/01-annex-g-rule-extraction.md +41 -0
  13. data/TODO.parity-ee/02-eeng-algorithm-inventory.md +8 -0
  14. data/TODO.parity-ee/03-eeng-oracle-harness.md +33 -0
  15. data/TODO.parity-ee/04-shtolo-concatenate.md +44 -0
  16. data/TODO.parity-ee/05-shtolo-longform-flatten.md +53 -0
  17. data/TODO.parity-ee/06-interface-scheduling-parity.md +47 -0
  18. data/TODO.parity-ee/07-semantic-checks-port.md +35 -0
  19. data/TODO.parity-ee/08-pretty-roundtrip-gate.md +27 -0
  20. data/TODO.parity-ee/09-smrl-index-and-listing.md +22 -0
  21. data/TODO.parity-ee/10-interface-dot-graph.md +21 -0
  22. data/TODO.parity-ee/11-import-eeng-tests.md +79 -0
  23. data/TODO.parity-ee/parity-matrix.md +108 -0
  24. data/exe/expressir +6 -0
  25. data/ext/expressir_core/Cargo.toml +24 -0
  26. data/ext/expressir_core/extconf.rb +30 -0
  27. data/ext/expressir_core/src/lib.rs +446 -0
  28. data/lib/expressir/cli.rb +49 -4
  29. data/lib/expressir/commands/check.rb +56 -0
  30. data/lib/expressir/commands/expand.rb +26 -0
  31. data/lib/expressir/commands/fix.rb +33 -0
  32. data/lib/expressir/commands/flatten.rb +29 -0
  33. data/lib/expressir/commands/parity_inputs.rb +85 -0
  34. data/lib/expressir/commands/validate.rb +22 -0
  35. data/lib/expressir/commands.rb +5 -0
  36. data/lib/expressir/express/builder_registry.rb +11 -2
  37. data/lib/expressir/express/builders/entity_decl_builder.rb +17 -7
  38. data/lib/expressir/express/builders/subtype_constraint_builder.rb +3 -1
  39. data/lib/expressir/express/builders/type_builder.rb +8 -0
  40. data/lib/expressir/express/builders/unique_rule_builder.rb +8 -1
  41. data/lib/expressir/express/checker.rb +458 -0
  42. data/lib/expressir/express/concatenator.rb +79 -0
  43. data/lib/expressir/express/core.rb +66 -0
  44. data/lib/expressir/express/formatter.rb +25 -3
  45. data/lib/expressir/express/formatters/data_types_formatter.rb +2 -2
  46. data/lib/expressir/express/formatters/declarations_formatter.rb +1 -0
  47. data/lib/expressir/express/formatters/remark_formatter.rb +30 -6
  48. data/lib/expressir/express/formatters/statements_formatter.rb +7 -1
  49. data/lib/expressir/express/interface_dot.rb +105 -0
  50. data/lib/expressir/express/listing.rb +174 -0
  51. data/lib/expressir/express/model_traversal.rb +42 -0
  52. data/lib/expressir/express/node_position_index.rb +18 -4
  53. data/lib/expressir/express/parser.rb +242 -4
  54. data/lib/expressir/express/pretty_gate.rb +61 -0
  55. data/lib/expressir/express/refs_overlay.rb +47 -0
  56. data/lib/expressir/express/remark_attacher.rb +122 -10
  57. data/lib/expressir/express/remark_overlay.rb +86 -0
  58. data/lib/expressir/express/schema_plain_source_formatter.rb +11 -0
  59. data/lib/expressir/express/schema_source_formatter.rb +5 -5
  60. data/lib/expressir/express/self_schema_reference.rb +126 -0
  61. data/lib/expressir/express/shtolo.rb +522 -0
  62. data/lib/expressir/express.rb +13 -0
  63. data/lib/expressir/model/concerns.rb +15 -1
  64. data/lib/expressir/model/declarations/entity.rb +6 -1
  65. data/lib/expressir/model/declarations/interface.rb +13 -0
  66. data/lib/expressir/model/declarations/schema.rb +74 -23
  67. data/lib/expressir/model/indexes/item_graph.rb +135 -0
  68. data/lib/expressir/model/indexes.rb +1 -0
  69. data/lib/expressir/model/model_element.rb +30 -1
  70. data/lib/expressir/model/remark_info.rb +20 -1
  71. data/lib/expressir/model/remark_placement.rb +15 -2
  72. data/lib/expressir/model/repository.rb +8 -2
  73. data/lib/expressir/model.rb +2 -1
  74. data/lib/expressir/version.rb +1 -1
  75. data/lib/tasks/verify_remarks.rake +9 -0
  76. data/rakelib/verify_remarks.rake +16 -0
  77. 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
- formatted = send(handler, node)
86
- # Some handlers return nil; appending must not turn that into "".
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
- private
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,
@@ -607,6 +607,7 @@ module Expressir
607
607
  [
608
608
  "END_SCHEMA",
609
609
  ";",
610
+ format_end_scope_remark(node),
610
611
  ].join,
611
612
  ].join("\n")
612
613
  end