expressir 2.4.2 → 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.max-perf/01-restore-ci-green.md +8 -1
- 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 +148 -21
- 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 +130 -22
- 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/expressir.rb +3 -19
- data/lib/tasks/verify_remarks.rake +9 -0
- data/rakelib/verify_remarks.rake +16 -0
- 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
|
|
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,
|
|
104
|
-
|
|
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
|
|
@@ -245,21 +245,31 @@ module Expressir
|
|
|
245
245
|
# shares a line with a node or sits outside any statement-bearing node.
|
|
246
246
|
def find_body_comment_target(remark)
|
|
247
247
|
line = remark.line
|
|
248
|
-
nodes = @node_index.nodes
|
|
249
248
|
# An own-line comment shares its line with no node. A node STARTING
|
|
250
249
|
# here means the remark is an inline tail (code; -- note). The
|
|
251
250
|
# end-line check is restricted to statements: container end_lines are
|
|
252
251
|
# child-derived approximations that can collide with comment lines.
|
|
253
|
-
|
|
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
|
|
254
264
|
|
|
255
265
|
# A closing keyword on the next code line is decisive: the comment
|
|
256
266
|
# closes that body. Without this check the comment would instead be
|
|
257
267
|
# read as leading the next statement of an OUTER region, which is
|
|
258
268
|
# where it would wrongly render.
|
|
259
|
-
closing = closing_region_target(line
|
|
269
|
+
closing = closing_region_target(line)
|
|
260
270
|
return closing if closing.first
|
|
261
271
|
|
|
262
|
-
enclosing, region, = statement_region_for(line
|
|
272
|
+
enclosing, region, = statement_region_for(line)
|
|
263
273
|
return [nil, nil, nil] unless region
|
|
264
274
|
|
|
265
275
|
following = region
|
|
@@ -287,21 +297,116 @@ module Expressir
|
|
|
287
297
|
!content[0...opener].strip.empty?
|
|
288
298
|
end
|
|
289
299
|
|
|
290
|
-
# A comment trailing code on its line belongs to the statement
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
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.
|
|
294
309
|
def inline_target(remark, nodes)
|
|
295
|
-
|
|
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
|
|
296
360
|
.select do |n|
|
|
297
|
-
n[:node].is_a?(Model::
|
|
298
|
-
n[:
|
|
361
|
+
n[:node].is_a?(Model::TakesInlineRemark) &&
|
|
362
|
+
n[:end_line] == remark.line && n[:line] &&
|
|
363
|
+
n[:line] < remark.line &&
|
|
299
364
|
n[:position] && n[:position] < remark.position
|
|
300
365
|
end
|
|
301
|
-
.max_by { |n| n[:position]
|
|
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) }
|
|
302
400
|
return [nil, nil, nil] unless owner
|
|
303
401
|
|
|
304
|
-
|
|
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]
|
|
305
410
|
end
|
|
306
411
|
|
|
307
412
|
# Which closing keyword ends which region of which owner. A comment
|
|
@@ -339,7 +444,7 @@ module Expressir
|
|
|
339
444
|
# every span and never reaches statement_region_for. Resolve it from
|
|
340
445
|
# the keyword that follows: it names both the owner type and the body
|
|
341
446
|
# being closed.
|
|
342
|
-
def closing_region_target(line
|
|
447
|
+
def closing_region_target(line)
|
|
343
448
|
keyword_owner, region, keyword_line = closing_keyword_after(line)
|
|
344
449
|
return [nil, nil, nil] unless keyword_owner
|
|
345
450
|
|
|
@@ -351,8 +456,8 @@ module Expressir
|
|
|
351
456
|
opener_line = active_opener_line(keyword_line, keyword_owner)
|
|
352
457
|
return [nil, nil, nil] unless opener_line
|
|
353
458
|
|
|
354
|
-
owner =
|
|
355
|
-
n[:node].is_a?(keyword_owner)
|
|
459
|
+
owner = @node_index.starting_at(opener_line).find do |n|
|
|
460
|
+
n[:node].is_a?(keyword_owner)
|
|
356
461
|
end
|
|
357
462
|
return [nil, nil, nil] unless owner
|
|
358
463
|
|
|
@@ -458,18 +563,15 @@ module Expressir
|
|
|
458
563
|
[nil, nil, nil]
|
|
459
564
|
end
|
|
460
565
|
|
|
461
|
-
def statement_region_for(line, nodes)
|
|
462
|
-
candidates = nodes.select do |n|
|
|
566
|
+
def statement_region_for(line, nodes = nil)
|
|
567
|
+
candidates = (nodes || @node_index.spanning(line)).select do |n|
|
|
463
568
|
n[:line] && n[:end_line] && n[:line] <= line && n[:end_line] >= line &&
|
|
464
569
|
(n[:node].is_a?(Model::Statement) || function_rule_procedure?(n[:node]))
|
|
465
570
|
end
|
|
466
571
|
enclosing = innermost_candidate(candidates)
|
|
467
572
|
return [nil, nil, nil] unless enclosing
|
|
468
573
|
|
|
469
|
-
children =
|
|
470
|
-
n[:owner].equal?(enclosing[:node]) &&
|
|
471
|
-
STATEMENT_REGIONS.include?(n[:collection]) && n[:line]
|
|
472
|
-
end
|
|
574
|
+
children = @node_index.children_in(enclosing[:node], STATEMENT_REGIONS)
|
|
473
575
|
return [enclosing, nil, nil] if children.empty?
|
|
474
576
|
|
|
475
577
|
preceding = children.select { |n| n[:line] < line }.max_by { |n| n[:position] }
|
|
@@ -870,6 +972,12 @@ module Expressir
|
|
|
870
972
|
placement: nil, region: nil, source_offset: nil)
|
|
871
973
|
return unless node
|
|
872
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?
|
|
873
981
|
|
|
874
982
|
if supports_remarks?(node)
|
|
875
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
|