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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "json"
3
5
  require "parsanol"
4
6
 
5
7
  module Expressir
@@ -46,10 +48,22 @@ module Expressir
46
48
  # @param skip_references [Boolean] skip resolving references
47
49
  # @param include_source [Boolean] attach original source code to model elements
48
50
  # @param use_native [Boolean] use native parser (default: true when available)
51
+ # @param use_core [Boolean] use the Rust core parse path
52
+ # (default: true when the expressir-core extension is available;
53
+ # produces a byte-identical model to the Ruby path — see
54
+ # parser_core_parity_spec)
49
55
  # @return [Model::ExpFile] ExpFile containing parsed schemas
50
56
  # @raise [Error::SchemaParseFailure] if the schema file fails to parse
51
57
  def self.from_file(file, skip_references: nil, include_source: nil,
52
- root_path: nil, use_native: nil) # rubocop:disable Metrics/AbcSize
58
+ root_path: nil, use_native: nil,
59
+ use_core: nil) # rubocop:disable Metrics/AbcSize
60
+ use_core = Core::NATIVE_AVAILABLE if use_core.nil?
61
+ if use_core && Core::NATIVE_AVAILABLE
62
+ return from_file_core(file, skip_references: skip_references,
63
+ include_source: include_source,
64
+ root_path: root_path)
65
+ end
66
+
53
67
  Expressir::Benchmark.measure_file(file) do
54
68
  source = strip_bom(File.read(file))
55
69
 
@@ -100,8 +114,32 @@ module Expressir
100
114
  # @yield [filename, schemas, error] Optional block called for each file
101
115
  # @return [Model::Repository] Repository containing all parsed ExpFiles
102
116
  def self.from_files(files, skip_references: nil, include_source: nil,
103
- root_path: nil, use_native: nil, max_processes: nil, &progress)
104
- all_exp_files = if ParallelFiles.sequential?(files, max_processes)
117
+ root_path: nil, use_native: nil, max_processes: nil,
118
+ compiled_set: nil, &progress)
119
+ set_path = compiled_set || ENV.fetch("EXPRESSIR_COMPILED_SET", nil)
120
+ if set_path && core_set_available?
121
+ repo = from_compiled_set(set_path, files,
122
+ skip_references: skip_references,
123
+ include_source: include_source,
124
+ root_path: root_path)
125
+ return repo if repo
126
+ end
127
+
128
+ # EXPRESSIR_BATCH=0 escapes the native batch workers: their
129
+ # Rust threads crash the process under some loads (SIGILL in
130
+ # the mpmc recv park — see GH batch-crash report); sequential
131
+ # parsing is slower but proven stable.
132
+ batch_disabled = ENV["EXPRESSIR_BATCH"] == "0"
133
+ all_exp_files = if batch_available? && !batch_disabled && files.size > 1
134
+ from_files_batch(
135
+ files, include_source: include_source,
136
+ root_path: root_path,
137
+ max_processes: max_processes,
138
+ compiled_set_out: set_path
139
+ ) do |file, exp_file, error|
140
+ progress&.call(file, exp_file&.schemas, error)
141
+ end
142
+ elsif ParallelFiles.sequential?(files, max_processes)
105
143
  parse_files_sequentially(
106
144
  files, skip_references: skip_references, include_source: include_source,
107
145
  root_path: root_path, use_native: use_native
@@ -121,8 +159,208 @@ root_path: nil, use_native: nil, max_processes: nil, &progress)
121
159
  end
122
160
  end
123
161
 
124
- build_repository(all_exp_files, skip_references: skip_references)
162
+ repository = build_repository(all_exp_files, skip_references: skip_references)
163
+ if set_path && !skip_references && File.exist?(set_path)
164
+ files = all_exp_files.compact
165
+ RefsOverlay.write(set_path, files)
166
+ graph = Model::Indexes::ItemGraph.new(repository)
167
+ repository.item_graph = graph
168
+ require "json"
169
+ File.write("#{set_path}.graph.json", JSON.generate(graph.tables))
170
+ end
171
+
172
+ repository
173
+ end
174
+
175
+ # Core path: the Rust extension parses and emits the model hash
176
+ # directly (lutaml wire shape); Ruby hydrates, then remark
177
+ # attachment runs on the source as usual.
178
+ def self.from_file_core(file, skip_references: nil,
179
+ include_source: nil, root_path: nil, &block)
180
+ unless Core::NATIVE_AVAILABLE
181
+ raise Error::StreamingUnsupportedError,
182
+ "core parse requires the native extension"
183
+ end
184
+
185
+ Expressir::Benchmark.measure_file(file) do
186
+ source = strip_bom(File.read(file))
187
+ schema_file = root_path ? Pathname.new(file.to_s).relative_path_from(root_path).to_s : file.to_s
188
+
189
+ exp_file =
190
+ begin
191
+ Core.parse_to_model(source, schema_file).tap(&:wire_parents)
192
+ rescue NotImplementedError
193
+ begin
194
+ Model::ExpFile.from_hash(
195
+ Core.parse_to_model_hash(source, schema_file),
196
+ )&.tap(&:wire_parents)
197
+ rescue StandardError => e
198
+ raise Error::SchemaParseFailure.new(schema_file, e)
199
+ end
200
+ rescue StandardError => e
201
+ raise Error::SchemaParseFailure.new(schema_file, e)
202
+ end
203
+
204
+ finalize_loaded_file(exp_file, source, schema_file,
205
+ include_source: include_source)
206
+
207
+ unless skip_references
208
+ Expressir::Benchmark.measure_references do
209
+ ResolveReferencesModelVisitor.new.visit(exp_file)
210
+ end
211
+ end
212
+
213
+ exp_file
214
+ end
215
+ end
216
+ private_class_method :from_file_core
217
+
218
+ # Shared post-parse steps for every core-path file: remark
219
+ # attachment, header-remark transfer, and path wiring.
220
+ def self.finalize_loaded_file(exp_file, source, schema_file,
221
+ include_source: nil)
222
+ RemarkAttacher.new(source).attach(exp_file) if source && include_source != false
223
+
224
+ transfer_header_to_schema(exp_file, source)
225
+
226
+ exp_file.path = schema_file
227
+ exp_file.schemas.each do |schema|
228
+ schema.file = schema_file
229
+ schema.file_basename = File.basename(schema_file, ".exp")
230
+ end
231
+ end
232
+ private_class_method :finalize_loaded_file
233
+
234
+ # Whether the native batch compiler is usable: the extension is
235
+ # loaded and exposes BatchStream.
236
+ def self.batch_available?
237
+ Core::NATIVE_AVAILABLE &&
238
+ ::Expressir::Core.const_defined?(:BatchStream, false)
239
+ end
240
+ private_class_method :batch_available?
241
+
242
+ # Whether the compiled-set artifact APIs are exposed.
243
+ def self.core_set_available?
244
+ Core::NATIVE_AVAILABLE && ::Expressir::Core.const_defined?(:Set, false)
245
+ end
246
+ private_class_method :core_set_available?
247
+
248
+ # Concurrent core-path parse: workers compile in the background
249
+ # while this thread hydrates, attaches remarks, and reports
250
+ # progress per file. Results keep the input order; a file that
251
+ # fails to parse is nil-padded exactly like the other paths.
252
+ def self.from_files_batch(files, include_source: nil, root_path: nil,
253
+ max_processes: nil, compiled_set_out: nil,
254
+ &progress)
255
+ schema_file_for = lambda do |file|
256
+ root_path ? Pathname.new(file.to_s).relative_path_from(root_path).to_s : file.to_s
257
+ end
258
+ wire_paths = files.map { |f| schema_file_for.call(f) }
259
+ physical = wire_paths.zip(files.map(&:to_s)).to_h
260
+
261
+ results = {}
262
+ jobs = files.map { |f| [f.to_s, schema_file_for.call(f)] }
263
+ # Outcomes arrive in completion order; files are finalized and
264
+ # reported in input order (the contract every other path keeps).
265
+ pending = {}
266
+ stream = ::Expressir::Core::BatchStream.start(jobs, max_processes.to_i)
267
+ cursor = 0
268
+ while (item = stream.next)
269
+ wire_path, model, error = item
270
+ if error
271
+ # Unreadable sources raise like every other path; parse
272
+ # failures nil-pad and report.
273
+ raise Errno::ENOENT, error if error.start_with?("read ")
274
+
275
+ pending[wire_path] = [nil, error]
276
+ else
277
+ pending[wire_path] = [model, nil]
278
+ end
279
+ while (entry = pending[wire_paths[cursor]])
280
+ pending.delete(wire_paths[cursor])
281
+ ordered_path = wire_paths[cursor]
282
+ file = physical[ordered_path]
283
+ loaded, load_error = entry
284
+ if loaded
285
+ source = strip_bom(File.read(file))
286
+ loaded.wire_parents
287
+ finalize_loaded_file(loaded, source, ordered_path,
288
+ include_source: include_source)
289
+ results[ordered_path] = loaded
290
+ yield(file, loaded, nil)
291
+ else
292
+ results[ordered_path] = nil
293
+ yield(file, nil,
294
+ Error::SchemaParseFailure.new(ordered_path,
295
+ RuntimeError.new(load_error)))
296
+ end
297
+ cursor += 1
298
+ end
299
+ end
300
+
301
+ if compiled_set_out && !results.value?(nil)
302
+ stream.write_set(compiled_set_out, physical,
303
+ Expressir::Version::VERSION)
304
+ RemarkOverlay.write(compiled_set_out, results.values.compact)
305
+ end
306
+
307
+ wire_paths.map { |wire_path| results[wire_path] }
308
+ end
309
+ private_class_method :from_files_batch
310
+
311
+ # Warm start from a compiled-set artifact. Returns nil (caller
312
+ # falls back to compiling) when the artifact is unreadable or no
313
+ # longer matches the sources. Remarks are re-attached from the
314
+ # source files and references are resolved as usual — everything
315
+ # not carried by the wire is deterministic and cheap to rebuild.
316
+ def self.from_compiled_set(path, files, skip_references: nil,
317
+ include_source: nil, root_path: nil, &progress)
318
+ schema_file_for = lambda do |file|
319
+ root_path ? Pathname.new(file.to_s).relative_path_from(root_path).to_s : file.to_s
320
+ end
321
+ physical = files.to_h { |f| [schema_file_for.call(f), f.to_s] }
322
+
323
+ set = begin
324
+ ::Expressir::Core::Set.open(path)
325
+ rescue StandardError
326
+ nil
327
+ end
328
+ return nil if set.nil?
329
+ return nil unless set.matches_sources(physical) == true
330
+
331
+ models = []
332
+ while (pair = set.next)
333
+ wire_path, model = pair
334
+ file = physical[wire_path]
335
+ # The artifact wire is pre-remark; the overlay carries them
336
+ # (plus header remarks and remark-derived structures), so the
337
+ # RemarkAttacher never runs on a warm load.
338
+ model.wire_parents
339
+ finalize_loaded_file(model, nil, wire_path,
340
+ include_source: include_source)
341
+ models << model
342
+ progress&.call(file || wire_path, model.schemas, nil)
343
+ end
344
+ RemarkOverlay.apply(path, models)
345
+
346
+ repository = build_repository(models, skip_references: true)
347
+ graph_path = "#{path}.graph.json"
348
+ if File.exist?(graph_path)
349
+ require "json"
350
+ repository.item_graph =
351
+ Model::Indexes::ItemGraph.from_tables(JSON.parse(File.read(graph_path)))
352
+ end
353
+ if skip_references || RefsOverlay.apply_to(path, models)
354
+ # Either explicitly skipping resolution or the compiled-set
355
+ # refs sidecar already re-bound the references.
356
+ else
357
+ Expressir::Benchmark.measure_references do
358
+ ResolveReferencesModelVisitor.new.visit(repository)
359
+ end
360
+ end
361
+ repository
125
362
  end
363
+ private_class_method :from_compiled_set
126
364
 
127
365
  def self.parse_files_sequentially(files, skip_references: nil,
128
366
  include_source: nil, root_path: nil, use_native: nil, &block)
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Pretty round-trip gate (parity-ee stage 08 / eeng qualify.sh
6
+ # self-check): parse → format → re-parse and compare a structural
7
+ # fingerprint of the model. Byte identity is deliberately not
8
+ # required — remark placement and whitespace drift are tolerated;
9
+ # declaration inventory and interface shape must match.
10
+ class PrettyGate
11
+ Result = Struct.new(:path, :ok, :before, :after, :error, keyword_init: true)
12
+
13
+ def self.fingerprint(schema)
14
+ {
15
+ id: schema.id&.safe_downcase,
16
+ interfaces: Array(schema.interfaces).map do |i|
17
+ {
18
+ kind: i.kind,
19
+ schema: (i.schema.is_a?(String) ? i.schema : i.schema&.id)&.safe_downcase,
20
+ items: Array(i.items).map do |entry|
21
+ ref = entry.ref.is_a?(String) ? entry.ref : entry.ref&.id
22
+ [entry.id&.safe_downcase, ref&.safe_downcase]
23
+ end.sort,
24
+ }
25
+ end,
26
+ constants: ids_of(schema.constants),
27
+ types: ids_of(schema.types),
28
+ entities: ids_of(schema.entities),
29
+ subtype_constraints: ids_of(schema.subtype_constraints),
30
+ functions: ids_of(schema.functions),
31
+ procedures: ids_of(schema.procedures),
32
+ rules: ids_of(schema.rules),
33
+ entity_attrs: Array(schema.entities).to_h do |e|
34
+ [e.id.safe_downcase, Array(e.attributes).filter_map { |a| a.id&.safe_downcase }.sort]
35
+ end,
36
+ }
37
+ end
38
+
39
+ def self.ids_of(coll)
40
+ Array(coll).filter_map { |d| d.id&.safe_downcase }.sort
41
+ end
42
+
43
+ def self.check_file(path)
44
+ source = File.read(path)
45
+ original = Parser.from_exp(source, skip_references: true)
46
+ formatted = Formatter.format(original)
47
+ roundtrip = Parser.from_exp(formatted, skip_references: true)
48
+
49
+ before = original.schemas.map { |s| fingerprint(s) }
50
+ after = roundtrip.schemas.map { |s| fingerprint(s) }
51
+ Result.new(path: path, ok: before == after, before: before, after: after)
52
+ rescue StandardError => e
53
+ Result.new(path: path, ok: false, error: e)
54
+ end
55
+
56
+ def self.check_files(paths)
57
+ paths.map { |p| check_file(p) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Resolved references are deterministic given the sources and the
6
+ # repository composition, so the batch path persists every
7
+ # SimpleReference's final base_path as an overlay next to the
8
+ # compiled-set artifact; warm loads re-apply them instead of
9
+ # re-running the {ResolveReferencesModelVisitor}.
10
+ module RefsOverlay
11
+ module_function
12
+
13
+ def write(set_path, models)
14
+ refs = {}
15
+ ModelTraversal.each_model(models) do |node, key|
16
+ next unless node.is_a?(Model::References::SimpleReference)
17
+ next if node.base_path.nil?
18
+
19
+ refs[key] = node.base_path
20
+ end
21
+ return if refs.empty?
22
+
23
+ require "json"
24
+ File.write("#{set_path}.refs.json", JSON.generate(refs))
25
+ end
26
+
27
+ # Re-binds resolved references from the compiled-set refs
28
+ # sidecar. Returns the sidecar path when it was applied (caller
29
+ # then skips the resolver); nil when absent — caller falls back
30
+ # to resolving.
31
+ def apply_to(set_path, models)
32
+ refs_path = "#{set_path}.refs.json"
33
+ return nil unless File.exist?(refs_path)
34
+
35
+ require "json"
36
+ refs = JSON.parse(File.read(refs_path))
37
+ ModelTraversal.each_model(models) do |node, key|
38
+ next unless node.is_a?(Model::References::SimpleReference)
39
+
40
+ base_path = refs[key]
41
+ node.base_path = base_path unless base_path.nil?
42
+ end
43
+ refs_path
44
+ end
45
+ end
46
+ end
47
+ end
@@ -249,7 +249,18 @@ module Expressir
249
249
  # here means the remark is an inline tail (code; -- note). The
250
250
  # end-line check is restricted to statements: container end_lines are
251
251
  # child-derived approximations that can collide with comment lines.
252
- return inline_target(remark, @node_index.starting_at(line)) if inline_remark?(remark)
252
+ if inline_remark?(remark)
253
+ target, placement, region = inline_target(remark,
254
+ @node_index.starting_at(line))
255
+ return [target, placement, region] if target
256
+
257
+ # A remark trailing a mid-construct keyword (`ELSE -- why`) starts
258
+ # nothing and closes nothing; handled before the legacy fallthrough,
259
+ # which would hand it to the enclosing construct unplaced — where
260
+ # nothing renders it.
261
+ mid = mid_keyword_inline_target(remark)
262
+ return mid if mid
263
+ end
253
264
 
254
265
  # A closing keyword on the next code line is decisive: the comment
255
266
  # closes that body. Without this check the comment would instead be
@@ -286,21 +297,116 @@ module Expressir
286
297
  !content[0...opener].strip.empty?
287
298
  end
288
299
 
289
- # A comment trailing code on its line belongs to the statement that
290
- # ends closest before it: `x := 1; -- why`. Only single-line statements
291
- # qualify, because appending to a statement spanning several lines
292
- # would move the remark down to its closing keyword.
300
+ # A comment trailing code on its line belongs to the statement it
301
+ # follows: `x := 1; -- why`, or the opener of a compound one, as in
302
+ # `IF x THEN -- why`.
303
+ #
304
+ # Multi-line statements were once excluded here, because appending a
305
+ # remark to one would carry it down to its closing keyword. They are
306
+ # admitted now that the formatter can put an opener remark back on the
307
+ # first line; the OPENER_REGION on the returned placement is what tells
308
+ # it to, and without that region the old appending behaviour stands.
293
309
  def inline_target(remark, nodes)
294
- owner = nodes
310
+ started_here = nodes.select do |n|
311
+ n[:node].is_a?(Model::TakesInlineRemark) &&
312
+ n[:line] == remark.line &&
313
+ n[:position] && n[:position] < remark.position
314
+ end
315
+
316
+ # Of the two that began here, whichever starts later is the one the
317
+ # remark follows: `x := 0; IF n > 0 THEN -- why` trails the IF.
318
+ began = [single_line_owner(started_here, remark.line),
319
+ opener_owner(started_here, remark.line)]
320
+ .compact.max_by { |n| n[:position] }
321
+
322
+ owner = began || closing_owner(nodes, remark)
323
+ return [nil, nil, nil] unless owner
324
+
325
+ [owner[:node], Model::RemarkPlacement::INLINE,
326
+ opener_region(owner, remark.line)]
327
+ end
328
+
329
+ # A node beginning and ending on the remark's line. Ranked by where it
330
+ # ends, so of several sharing the line the outermost complete one wins:
331
+ # `CASE n OF 1 : x := 2; END_CASE; -- why` trails the CASE, not its
332
+ # action.
333
+ def single_line_owner(candidates, line)
334
+ candidates
335
+ .select { |n| n[:end_line] == line }
336
+ .max_by { |n| n[:position] + n[:node].source.to_s.length }
337
+ end
338
+
339
+ # A node whose opener is on the remark's line but which continues past
340
+ # it. Ranked by where it STARTS, not where it ends: such a node's source
341
+ # spans its whole body, so ranking by end would let an enclosing block
342
+ # outrank the one the remark actually follows.
343
+ def opener_owner(candidates, line)
344
+ candidates
345
+ .select { |n| n[:end_line] && n[:end_line] > line }
346
+ .max_by { |n| n[:position] }
347
+ end
348
+
349
+ # Nothing began on the remark's line, so it may still be closing one
350
+ # that began earlier:
351
+ #
352
+ # REFERENCE FROM x
353
+ # (a, b); -- why
354
+ #
355
+ # Only reached as a fallback. Were it folded into the main lookup, an
356
+ # enclosing block would become a candidate for every remark inside it,
357
+ # since a node's end line is derived from its children.
358
+ def closing_owner(nodes, remark)
359
+ nodes
295
360
  .select do |n|
296
- n[:node].is_a?(Model::Statement) &&
297
- n[:line] == remark.line && n[:end_line] == remark.line &&
361
+ n[:node].is_a?(Model::TakesInlineRemark) &&
362
+ n[:end_line] == remark.line && n[:line] &&
363
+ n[:line] < remark.line &&
298
364
  n[:position] && n[:position] < remark.position
299
365
  end
300
- .max_by { |n| n[:position] + n[:node].source.to_s.length }
366
+ .max_by { |n| n[:position] }
367
+ end
368
+
369
+ # A remark trailing a node that closes on its line follows the whole
370
+ # node, so it is written back the way an ordinary inline remark is. One
371
+ # that continues past the line trailed the node's opener instead, and
372
+ # belongs back there rather than after the closing keyword.
373
+ def opener_region(owner, line)
374
+ return nil unless owner[:end_line] && owner[:end_line] > line
375
+
376
+ Model::RemarkPlacement::OPENER_REGION
377
+ end
378
+
379
+ # Lines beginning with a mid-construct keyword. The keyword closes
380
+ # nothing and opens nothing, but the remark trailing it belongs to the
381
+ # construct it branches — written back after the keyword, not after the
382
+ # construct's END_.
383
+ MID_KEYWORD_REGIONS = {
384
+ /\AELSE\b/i => [Model::Statements::If,
385
+ Model::RemarkPlacement::ELSE_REGION],
386
+ /\AOTHERWISE\b/i => [Model::Statements::Case,
387
+ Model::RemarkPlacement::OTHERWISE_REGION],
388
+ }.freeze
389
+
390
+ # Attach `ELSE -- why` / `OTHERWISE : -- why` to the innermost
391
+ # enclosing IF / CASE with INLINE placement and the keyword's region.
392
+ # Returns [nil, nil, nil] when the line head is no mid-construct
393
+ # keyword or no construct of the right kind spans the line.
394
+ def mid_keyword_inline_target(remark)
395
+ content = line_content_for(remark.line).to_s
396
+ opener = content.index("--")
397
+ return [nil, nil, nil] unless opener
398
+
399
+ owner = MID_KEYWORD_REGIONS.find { |pattern, _| content[0...opener].strip.match?(pattern) }
301
400
  return [nil, nil, nil] unless owner
302
401
 
303
- [owner[:node], Model::RemarkPlacement::INLINE, nil]
402
+ klass, region = owner[1]
403
+ innermost = @node_index.nodes.select do |n|
404
+ n[:node].is_a?(klass) && n[:line] && n[:end_line] &&
405
+ n[:line] < remark.line && n[:end_line] > remark.line
406
+ end.max_by { |n| n[:line] }
407
+ return [nil, nil, nil] unless innermost
408
+
409
+ [innermost[:node], Model::RemarkPlacement::INLINE, region]
304
410
  end
305
411
 
306
412
  # Which closing keyword ends which region of which owner. A comment
@@ -866,6 +972,12 @@ module Expressir
866
972
  placement: nil, region: nil, source_offset: nil)
867
973
  return unless node
868
974
  return unless node.is_a?(Model::ModelElement)
975
+ # An empty remark body carries no content; storing it produced
976
+ # remarks == [""] entries (GH-363). Bare `--IPn:` informal
977
+ # propositions keep their declarations through
978
+ # create_or_find_informal_proposition, which does not run through
979
+ # here — only the redundant empty-body storage drops.
980
+ return if text.nil? || text.strip.empty?
869
981
 
870
982
  if supports_remarks?(node)
871
983
  if node_has_remarks?(node)
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Remarks are deterministic given the sources, so the batch path
6
+ # persists them as an overlay next to the compiled-set artifact;
7
+ # warm loads re-apply the overlay instead of re-running the
8
+ # {RemarkAttacher}.
9
+ #
10
+ # Nodes are keyed structurally — attribute path plus array index —
11
+ # because {Model::ModelElement#path} is empty for id-less nodes
12
+ # (statements), and the wire is byte-identical between the cold
13
+ # write and the warm apply, so structural keys match exactly.
14
+ module RemarkOverlay
15
+ CAPTURED = %w[remarks remark_items informal_propositions untagged_remarks header].freeze
16
+
17
+ class << self
18
+ def write(set_path, models)
19
+ overlay = {}
20
+ ModelTraversal.each_model(models) do |node, key|
21
+ entry = {}
22
+ CAPTURED.each do |attr|
23
+ next unless node.respond_to?(attr)
24
+
25
+ value = node.public_send(attr)
26
+ entry[attr] = value if attr == "header" ? value : value&.any?
27
+ end
28
+ overlay[key] = entry if entry.any?
29
+ end
30
+ return if overlay.empty?
31
+
32
+ require "json"
33
+ File.write("#{set_path}.remarks.json", JSON.generate(overlay))
34
+ end
35
+
36
+ # Recursively parent restored nodes and invalidate id-memos so
37
+ # find()/resolve_path_in_scope sees them, exactly as the
38
+ # RemarkAttacher's own bookkeeping does.
39
+ def wire_overlay_value(parent, value)
40
+ case value
41
+ when Array
42
+ value.each { |item| wire_overlay_value(parent, item) }
43
+ when Model::ModelElement
44
+ parent_value = parent.is_a?(Model::ModelElement) ? parent : nil
45
+ value.parent = parent_value if value.respond_to?(:parent=)
46
+ if parent_value.respond_to?(:reset_children_by_id)
47
+ parent_value.reset_children_by_id
48
+ end
49
+ value.class.attributes.each_key do |attr|
50
+ next if Model::ModelElement::SKIP_ATTRIBUTES.include?(attr) || attr == :parent
51
+
52
+ inner = value.public_send(attr)
53
+ next unless inner.is_a?(Model::ModelElement) || inner.is_a?(Array)
54
+
55
+ wire_overlay_value(value, inner)
56
+ end
57
+ end
58
+ end
59
+
60
+ def apply(set_path, models)
61
+ overlay_path = "#{set_path}.remarks.json"
62
+ return unless File.exist?(overlay_path)
63
+
64
+ require "json"
65
+ overlay = JSON.parse(File.read(overlay_path))
66
+ ModelTraversal.each_model(models) do |node, key|
67
+ entry = overlay[key]
68
+ next unless entry
69
+
70
+ CAPTURED.each do |attr|
71
+ setter = :"#{attr}="
72
+ next unless entry[attr] && node.respond_to?(setter)
73
+
74
+ node.public_send(setter, entry[attr])
75
+ # The writer casts JSON hashes into model objects; wire
76
+ # the cast collection (the attacher parents every
77
+ # created node and resets the child-id memo — reference
78
+ # resolution relies on both).
79
+ wire_overlay_value(node, node.public_send(attr))
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Pre-built Formatter subclass rendering the schema head without
6
+ # hyperlinks — the plain `source` face of Schema (#255).
7
+ class SchemaPlainSourceFormatter < Formatter
8
+ include SchemaHeadFormatter
9
+ end
10
+ end
11
+ end
@@ -2,11 +2,11 @@
2
2
 
3
3
  module Expressir
4
4
  module Express
5
- # Pre-built Formatter subclass for Schema#source.
6
- #
7
- # Combines SchemaHeadFormatter (renders the SCHEMA header block) with
8
- # HyperlinkFormatter (renders cross-references as hyperlinks). Allocated
9
- # once at load time rather than per #source call.
5
+ # Pre-built Formatter subclass rendering the schema head with
6
+ # hyperlinks: SchemaHeadFormatter + HyperlinkFormatter. Allocated
7
+ # once at load time rather than per call. It backs
8
+ # Schema#source_hyperlinked; Schema#source uses
9
+ # SchemaPlainSourceFormatter, the unlinked counterpart (#255).
10
10
  class SchemaSourceFormatter < Formatter
11
11
  include SchemaHeadFormatter
12
12
  include HyperlinkFormatter