expressir 2.4.3 → 2.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.cargo/config.toml +3 -0
- data/.github/workflows/rust-ext.yml +41 -0
- data/.github/workflows/verify_remarks.yml +33 -0
- data/.gitignore +6 -0
- data/.rubocop.yml +4 -0
- data/.rubocop_todo.yml +1 -0
- data/Cargo.toml +7 -0
- data/README.adoc +65 -0
- data/Rakefile +12 -0
- data/TODO.parity-ee/00-overview.md +58 -0
- data/TODO.parity-ee/01-annex-g-rule-extraction.md +41 -0
- data/TODO.parity-ee/02-eeng-algorithm-inventory.md +8 -0
- data/TODO.parity-ee/03-eeng-oracle-harness.md +33 -0
- data/TODO.parity-ee/04-shtolo-concatenate.md +44 -0
- data/TODO.parity-ee/05-shtolo-longform-flatten.md +53 -0
- data/TODO.parity-ee/06-interface-scheduling-parity.md +47 -0
- data/TODO.parity-ee/07-semantic-checks-port.md +35 -0
- data/TODO.parity-ee/08-pretty-roundtrip-gate.md +27 -0
- data/TODO.parity-ee/09-smrl-index-and-listing.md +22 -0
- data/TODO.parity-ee/10-interface-dot-graph.md +21 -0
- data/TODO.parity-ee/11-import-eeng-tests.md +79 -0
- data/TODO.parity-ee/parity-matrix.md +108 -0
- data/exe/expressir +6 -0
- data/ext/expressir_core/Cargo.toml +24 -0
- data/ext/expressir_core/extconf.rb +30 -0
- data/ext/expressir_core/src/lib.rs +446 -0
- data/lib/expressir/cli.rb +49 -4
- data/lib/expressir/commands/check.rb +56 -0
- data/lib/expressir/commands/expand.rb +26 -0
- data/lib/expressir/commands/fix.rb +33 -0
- data/lib/expressir/commands/flatten.rb +29 -0
- data/lib/expressir/commands/parity_inputs.rb +85 -0
- data/lib/expressir/commands/validate.rb +22 -0
- data/lib/expressir/commands.rb +5 -0
- data/lib/expressir/express/builder_registry.rb +11 -2
- data/lib/expressir/express/builders/entity_decl_builder.rb +17 -7
- data/lib/expressir/express/builders/subtype_constraint_builder.rb +3 -1
- data/lib/expressir/express/builders/type_builder.rb +8 -0
- data/lib/expressir/express/builders/unique_rule_builder.rb +8 -1
- data/lib/expressir/express/checker.rb +458 -0
- data/lib/expressir/express/concatenator.rb +79 -0
- data/lib/expressir/express/core.rb +66 -0
- data/lib/expressir/express/formatter.rb +25 -3
- data/lib/expressir/express/formatters/data_types_formatter.rb +2 -2
- data/lib/expressir/express/formatters/declarations_formatter.rb +1 -0
- data/lib/expressir/express/formatters/remark_formatter.rb +30 -6
- data/lib/expressir/express/formatters/statements_formatter.rb +7 -1
- data/lib/expressir/express/interface_dot.rb +105 -0
- data/lib/expressir/express/listing.rb +174 -0
- data/lib/expressir/express/model_traversal.rb +42 -0
- data/lib/expressir/express/node_position_index.rb +18 -4
- data/lib/expressir/express/parser.rb +242 -4
- data/lib/expressir/express/pretty_gate.rb +61 -0
- data/lib/expressir/express/refs_overlay.rb +47 -0
- data/lib/expressir/express/remark_attacher.rb +122 -10
- data/lib/expressir/express/remark_overlay.rb +86 -0
- data/lib/expressir/express/schema_plain_source_formatter.rb +11 -0
- data/lib/expressir/express/schema_source_formatter.rb +5 -5
- data/lib/expressir/express/self_schema_reference.rb +126 -0
- data/lib/expressir/express/shtolo.rb +522 -0
- data/lib/expressir/express.rb +13 -0
- data/lib/expressir/model/concerns.rb +15 -1
- data/lib/expressir/model/declarations/entity.rb +6 -1
- data/lib/expressir/model/declarations/interface.rb +13 -0
- data/lib/expressir/model/declarations/schema.rb +74 -23
- data/lib/expressir/model/indexes/item_graph.rb +135 -0
- data/lib/expressir/model/indexes.rb +1 -0
- data/lib/expressir/model/model_element.rb +30 -1
- data/lib/expressir/model/remark_info.rb +20 -1
- data/lib/expressir/model/remark_placement.rb +15 -2
- data/lib/expressir/model/repository.rb +8 -2
- data/lib/expressir/model.rb +2 -1
- data/lib/expressir/version.rb +1 -1
- data/lib/tasks/verify_remarks.rake +9 -0
- data/rakelib/verify_remarks.rake +16 -0
- metadata +42 -2
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
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
|
-
|
|
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::
|
|
297
|
-
n[:
|
|
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]
|
|
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
|
-
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|