expressir 2.4.3 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.cargo/config.toml +3 -0
  3. data/.github/workflows/rust-ext.yml +41 -0
  4. data/.github/workflows/verify_remarks.yml +33 -0
  5. data/.gitignore +6 -0
  6. data/.rubocop.yml +4 -0
  7. data/.rubocop_todo.yml +1 -0
  8. data/Cargo.toml +7 -0
  9. data/README.adoc +65 -0
  10. data/Rakefile +12 -0
  11. data/TODO.parity-ee/00-overview.md +58 -0
  12. data/TODO.parity-ee/01-annex-g-rule-extraction.md +41 -0
  13. data/TODO.parity-ee/02-eeng-algorithm-inventory.md +8 -0
  14. data/TODO.parity-ee/03-eeng-oracle-harness.md +33 -0
  15. data/TODO.parity-ee/04-shtolo-concatenate.md +44 -0
  16. data/TODO.parity-ee/05-shtolo-longform-flatten.md +53 -0
  17. data/TODO.parity-ee/06-interface-scheduling-parity.md +47 -0
  18. data/TODO.parity-ee/07-semantic-checks-port.md +35 -0
  19. data/TODO.parity-ee/08-pretty-roundtrip-gate.md +27 -0
  20. data/TODO.parity-ee/09-smrl-index-and-listing.md +22 -0
  21. data/TODO.parity-ee/10-interface-dot-graph.md +21 -0
  22. data/TODO.parity-ee/11-import-eeng-tests.md +79 -0
  23. data/TODO.parity-ee/parity-matrix.md +108 -0
  24. data/exe/expressir +6 -0
  25. data/ext/expressir_core/Cargo.toml +24 -0
  26. data/ext/expressir_core/extconf.rb +30 -0
  27. data/ext/expressir_core/src/lib.rs +446 -0
  28. data/lib/expressir/cli.rb +49 -4
  29. data/lib/expressir/commands/check.rb +56 -0
  30. data/lib/expressir/commands/expand.rb +26 -0
  31. data/lib/expressir/commands/fix.rb +33 -0
  32. data/lib/expressir/commands/flatten.rb +29 -0
  33. data/lib/expressir/commands/parity_inputs.rb +85 -0
  34. data/lib/expressir/commands/validate.rb +22 -0
  35. data/lib/expressir/commands.rb +5 -0
  36. data/lib/expressir/express/builder_registry.rb +11 -2
  37. data/lib/expressir/express/builders/entity_decl_builder.rb +17 -7
  38. data/lib/expressir/express/builders/subtype_constraint_builder.rb +3 -1
  39. data/lib/expressir/express/builders/type_builder.rb +8 -0
  40. data/lib/expressir/express/builders/unique_rule_builder.rb +8 -1
  41. data/lib/expressir/express/checker.rb +458 -0
  42. data/lib/expressir/express/concatenator.rb +79 -0
  43. data/lib/expressir/express/core.rb +66 -0
  44. data/lib/expressir/express/formatter.rb +25 -3
  45. data/lib/expressir/express/formatters/data_types_formatter.rb +2 -2
  46. data/lib/expressir/express/formatters/declarations_formatter.rb +1 -0
  47. data/lib/expressir/express/formatters/remark_formatter.rb +30 -6
  48. data/lib/expressir/express/formatters/statements_formatter.rb +7 -1
  49. data/lib/expressir/express/interface_dot.rb +105 -0
  50. data/lib/expressir/express/listing.rb +174 -0
  51. data/lib/expressir/express/model_traversal.rb +42 -0
  52. data/lib/expressir/express/node_position_index.rb +18 -4
  53. data/lib/expressir/express/parser.rb +242 -4
  54. data/lib/expressir/express/pretty_gate.rb +61 -0
  55. data/lib/expressir/express/refs_overlay.rb +47 -0
  56. data/lib/expressir/express/remark_attacher.rb +122 -10
  57. data/lib/expressir/express/remark_overlay.rb +86 -0
  58. data/lib/expressir/express/schema_plain_source_formatter.rb +11 -0
  59. data/lib/expressir/express/schema_source_formatter.rb +5 -5
  60. data/lib/expressir/express/self_schema_reference.rb +126 -0
  61. data/lib/expressir/express/shtolo.rb +522 -0
  62. data/lib/expressir/express.rb +13 -0
  63. data/lib/expressir/model/concerns.rb +15 -1
  64. data/lib/expressir/model/declarations/entity.rb +6 -1
  65. data/lib/expressir/model/declarations/interface.rb +13 -0
  66. data/lib/expressir/model/declarations/schema.rb +74 -23
  67. data/lib/expressir/model/indexes/item_graph.rb +135 -0
  68. data/lib/expressir/model/indexes.rb +1 -0
  69. data/lib/expressir/model/model_element.rb +30 -1
  70. data/lib/expressir/model/remark_info.rb +20 -1
  71. data/lib/expressir/model/remark_placement.rb +15 -2
  72. data/lib/expressir/model/repository.rb +8 -2
  73. data/lib/expressir/model.rb +2 -1
  74. data/lib/expressir/version.rb +1 -1
  75. data/lib/tasks/verify_remarks.rake +9 -0
  76. data/rakelib/verify_remarks.rake +16 -0
  77. metadata +42 -2
@@ -0,0 +1,446 @@
1
+ //! Ruby bridge for expressir-core: exposes the Rust parse + model
2
+ //! extraction pipeline to the expressir gem.
3
+ //!
4
+ //! Two entry points:
5
+ //! - `parse_to_model_hash` — the wire JSON string (tooling, parity runs)
6
+ //! - `parse_to_model` — the hydrated model, built by walking the wire
7
+ //! JSON definition once and constructing each node through
8
+ //! `Serializable.instantiate` (lutaml-model's fast bulk constructor).
9
+ //! Skips the JSON string, `JSON.parse`, and the generic from_hash
10
+ //! mapping machinery.
11
+
12
+ use std::collections::HashMap;
13
+ use std::sync::mpsc::Receiver;
14
+ use std::sync::Mutex;
15
+
16
+ use expressir_rs::batch::{self, BatchOutcome};
17
+ use expressir_rs::compiled_set::{CompiledFile, CompiledSet};
18
+ use expressir_rs::ParsedTree;
19
+ use magnus::{function, method};
20
+ use magnus::prelude::*;
21
+ use magnus::typed_data::DataTypeBuilder;
22
+ use magnus::value::Lazy;
23
+ use magnus::IntoValue;
24
+ use magnus::{DataType, DataTypeFunctions, Error, RArray, RClass, RHash, RModule, Ruby, TypedData};
25
+ use serde_json::Value as Wire;
26
+
27
+ fn parse_error(ruby: &Ruby, e: expressir_rs::model_json::ModelJsonError) -> Error {
28
+ Error::new(
29
+ ruby.exception_runtime_error(),
30
+ format!("expressir-core: parse error: {e:?}"),
31
+ )
32
+ }
33
+
34
+ fn model_json(ruby: &Ruby, source: String, path: String) -> Result<String, Error> {
35
+ let tree = ParsedTree::parse(&source).map_err(|e| {
36
+ Error::new(
37
+ ruby.exception_runtime_error(),
38
+ format!("expressir-core: parse error: {e:?}"),
39
+ )
40
+ })?;
41
+ let value = tree.to_model_json(&path).map_err(|e| parse_error(ruby, e))?;
42
+ serde_json::to_string(&value).map_err(|e| {
43
+ Error::new(
44
+ ruby.exception_runtime_error(),
45
+ format!("expressir-core: serialize error: {e}"),
46
+ )
47
+ })
48
+ }
49
+
50
+ /// Resolves `Expressir::Model::*` classes by their wire `_class` name,
51
+ /// caching each lookup (the class set is closed).
52
+ #[derive(Default)]
53
+ struct ClassCache {
54
+ classes: HashMap<String, RClass>,
55
+ }
56
+
57
+ impl ClassCache {
58
+ fn resolve(&mut self, ruby: &Ruby, name: &str) -> Result<RClass, Error> {
59
+ if let Some(klass) = self.classes.get(name) {
60
+ return Ok(*klass);
61
+ }
62
+
63
+ let short = name.strip_prefix("Expressir::Model::").ok_or_else(|| {
64
+ Error::new(
65
+ ruby.exception_runtime_error(),
66
+ format!("expressir-core: unknown model class {name:?}"),
67
+ )
68
+ })?;
69
+ let mut current = ruby
70
+ .class_object()
71
+ .const_get::<_, RModule>("Expressir")?
72
+ .const_get::<_, RModule>("Model")?;
73
+ let segments: Vec<&str> = short.split("::").collect();
74
+ let last = segments.len() - 1;
75
+ for (i, segment) in segments.iter().enumerate() {
76
+ let value = current
77
+ .const_get::<_, magnus::Value>(segment.to_string())
78
+ .map_err(|e| {
79
+ Error::new(
80
+ ruby.exception_runtime_error(),
81
+ format!("expressir-core: unknown model class {name:?}: {e}"),
82
+ )
83
+ })?;
84
+ if i == last {
85
+ let klass = RClass::from_value(value).ok_or_else(|| {
86
+ Error::new(
87
+ ruby.exception_runtime_error(),
88
+ format!("expressir-core: {name:?} is not a class"),
89
+ )
90
+ })?;
91
+ self.classes.insert(name.to_string(), klass);
92
+ return Ok(klass);
93
+ }
94
+ current = RModule::from_value(value).ok_or_else(|| {
95
+ Error::new(
96
+ ruby.exception_runtime_error(),
97
+ format!("expressir-core: {name:?} is not a module"),
98
+ )
99
+ })?;
100
+ }
101
+ unreachable!()
102
+ }
103
+ }
104
+
105
+ /// Live handle to a running batch compile: the outcomes channel plus
106
+ /// the per-stream class cache and the wire values retained for
107
+ /// artifact assembly. `next` blocks until a worker delivers; workers
108
+ /// are Rust threads, so they progress while Ruby waits.
109
+ #[derive(DataTypeFunctions)]
110
+ struct BatchStream {
111
+ rx: Receiver<BatchOutcome>,
112
+ cache: Mutex<ClassCache>,
113
+ wires: Mutex<Vec<(String, Wire)>>,
114
+ /// Wire paths in job order — the artifact layout must be
115
+ /// deterministic regardless of completion order.
116
+ order: Mutex<Vec<String>>,
117
+ }
118
+
119
+ // SAFETY: BatchStream never crosses threads itself; Send is required by
120
+ // magnus to expose methods, and every access happens on the Ruby thread
121
+ // that owns the stream.
122
+ unsafe impl Send for BatchStream {}
123
+
124
+ unsafe impl TypedData for BatchStream {
125
+ fn class(ruby: &Ruby) -> RClass {
126
+ static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
127
+ let class = ruby
128
+ .class_object()
129
+ .const_get::<_, RModule>("Expressir")
130
+ .and_then(|m| m.const_get::<_, RModule>("Core"))
131
+ .and_then(|m| m.const_get::<_, RClass>("BatchStream"))
132
+ .expect("Expressir::Core::BatchStream defined in init");
133
+ class.undef_default_alloc_func();
134
+ class
135
+ });
136
+ ruby.get_inner(&CLASS)
137
+ }
138
+
139
+ fn data_type() -> &'static DataType {
140
+ static DATA_TYPE: DataType =
141
+ DataTypeBuilder::<BatchStream>::new(c"Expressir::Core::BatchStream").build();
142
+ &DATA_TYPE
143
+ }
144
+ }
145
+
146
+ fn batch_start(ruby: &Ruby, jobs: RArray, workers: usize) -> Result<BatchStream, Error> {
147
+ let mut parsed: Vec<(String, String)> = Vec::with_capacity(jobs.len());
148
+ for index in 0..jobs.len() {
149
+ let job = jobs.entry(index as isize)?;
150
+ let pair = RArray::from_value(job).ok_or_else(|| {
151
+ Error::new(
152
+ ruby.exception_arg_error(),
153
+ "jobs must be [read_path, wire_path] pairs",
154
+ )
155
+ })?;
156
+ let read_path: String = pair.entry(0)?;
157
+ let wire_path: String = pair.entry(1)?;
158
+ parsed.push((read_path, wire_path));
159
+ }
160
+ let order = parsed.iter().map(|(_, wire)| wire.clone()).collect();
161
+ let rx = batch::parse_batch(parsed, workers);
162
+ Ok(BatchStream {
163
+ rx,
164
+ cache: Mutex::new(ClassCache::default()),
165
+ wires: Mutex::new(Vec::new()),
166
+ order: Mutex::new(order),
167
+ })
168
+ }
169
+
170
+ /// Write the compiled-set artifact from this stream's retained wires:
171
+ /// `read_paths` maps wire_path → physical source for digesting.
172
+ fn batch_write_set(
173
+ ruby: &Ruby,
174
+ stream: &BatchStream,
175
+ out_path: String,
176
+ read_paths: RHash,
177
+ expressir_version: String,
178
+ ) -> Result<magnus::Value, Error> {
179
+ let wires = stream.wires.lock().expect("wires").clone();
180
+ let order = stream.order.lock().expect("order").clone();
181
+ let position: HashMap<String, usize> =
182
+ order.iter().enumerate().map(|(i, p)| (p.clone(), i)).collect();
183
+ let mut wires = wires;
184
+ wires.sort_by_key(|(wire_path, _)| position.get(wire_path).copied().unwrap_or(usize::MAX));
185
+
186
+ let mut files = Vec::with_capacity(wires.len());
187
+ for (wire_path, wire) in wires {
188
+ let read_path: Option<String> = read_paths
189
+ .fetch(ruby.str_new(&wire_path))
190
+ .map_err(|e| Error::new(ruby.exception_arg_error(), e.to_string()))?;
191
+ let source_sha = read_path
192
+ .as_deref()
193
+ .map(compiled_set_file_sha)
194
+ .unwrap_or_default();
195
+ let wire_json = serde_json::to_string(&wire).map_err(|e| {
196
+ Error::new(
197
+ ruby.exception_runtime_error(),
198
+ format!("wire encode {wire_path}: {e}"),
199
+ )
200
+ })?;
201
+ files.push(CompiledFile {
202
+ wire_path,
203
+ source_sha,
204
+ wire_json,
205
+ });
206
+ }
207
+ let set = CompiledSet::build(files, &expressir_version, &expressir_rs::grammar_digest());
208
+ set.write_to(&out_path).map_err(|e| {
209
+ Error::new(
210
+ ruby.exception_io_error(),
211
+ format!("compiled set {out_path}: {e}"),
212
+ )
213
+ })?;
214
+ Ok(ruby.str_new(&set.header.set_digest).as_value())
215
+ }
216
+
217
+ fn compiled_set_file_sha(path: &str) -> String {
218
+ expressir_rs::compiled_set::file_sha256(path)
219
+ }
220
+
221
+ /// `[wire_path, model, nil]` / `[wire_path, nil, message]`, or nil when
222
+ /// the queue has drained.
223
+ fn batch_next(ruby: &Ruby, stream: &BatchStream) -> Result<magnus::Value, Error> {
224
+ let outcome = stream.rx.recv();
225
+ let Ok(outcome) = outcome else {
226
+ return Ok(ruby.qnil().as_value());
227
+ };
228
+ let BatchOutcome { path, result } = outcome;
229
+ let triple = RArray::with_capacity(3);
230
+ triple.push(ruby.str_new(&path))?;
231
+ match result {
232
+ Ok(wire) => {
233
+ stream
234
+ .wires
235
+ .lock()
236
+ .expect("wires")
237
+ .push((path.clone(), wire.clone()));
238
+ let mut cache = stream.cache.lock().expect("class cache");
239
+ let model = value_to_ruby(ruby, &mut cache, &wire)?;
240
+ triple.push(model)?;
241
+ triple.push(ruby.qnil())?;
242
+ }
243
+ Err(message) => {
244
+ triple.push(ruby.qnil())?;
245
+ triple.push(ruby.str_new(&message))?;
246
+ }
247
+ }
248
+ Ok(triple.as_value())
249
+ }
250
+
251
+ /// Reader over a compiled-set artifact: ordered, lazy — each `next`
252
+ /// hydrates one file's wire model through the shared class cache.
253
+ #[derive(DataTypeFunctions)]
254
+ struct SetReader {
255
+ set: CompiledSet,
256
+ index: Mutex<usize>,
257
+ cache: Mutex<ClassCache>,
258
+ }
259
+
260
+ // SAFETY: only ever touched from the owning Ruby thread.
261
+ unsafe impl Send for SetReader {}
262
+
263
+ unsafe impl TypedData for SetReader {
264
+ fn class(ruby: &Ruby) -> RClass {
265
+ static CLASS: Lazy<RClass> = Lazy::new(|ruby| {
266
+ let class = ruby
267
+ .class_object()
268
+ .const_get::<_, RModule>("Expressir")
269
+ .and_then(|m| m.const_get::<_, RModule>("Core"))
270
+ .and_then(|m| m.const_get::<_, RClass>("Set"))
271
+ .expect("Expressir::Core::Set defined in init");
272
+ class.undef_default_alloc_func();
273
+ class
274
+ });
275
+ ruby.get_inner(&CLASS)
276
+ }
277
+
278
+ fn data_type() -> &'static DataType {
279
+ static DATA_TYPE: DataType =
280
+ DataTypeBuilder::<SetReader>::new(c"Expressir::Core::Set").build();
281
+ &DATA_TYPE
282
+ }
283
+ }
284
+
285
+ fn set_open(_ruby: &Ruby, path: String) -> Result<SetReader, Error> {
286
+ let set = CompiledSet::read_from(&path).map_err(|e| {
287
+ Error::new(
288
+ _ruby.exception_runtime_error(),
289
+ format!("compiled set {path}: {e}"),
290
+ )
291
+ })?;
292
+ Ok(SetReader {
293
+ set,
294
+ index: Mutex::new(0),
295
+ cache: Mutex::new(ClassCache::default()),
296
+ })
297
+ }
298
+
299
+ fn set_digest(ruby: &Ruby, reader: &SetReader) -> Result<magnus::Value, Error> {
300
+ Ok(ruby.str_new(&reader.set.header.set_digest).as_value())
301
+ }
302
+
303
+ fn set_count(_ruby: &Ruby, reader: &SetReader) -> usize {
304
+ reader.set.files.len()
305
+ }
306
+
307
+ /// `[wire_path, model]` for the next file in artifact order, nil at
308
+ /// the end.
309
+ fn set_next(ruby: &Ruby, reader: &SetReader) -> Result<magnus::Value, Error> {
310
+ let mut index = reader.index.lock().expect("index");
311
+ if *index >= reader.set.files.len() {
312
+ return Ok(ruby.qnil().as_value());
313
+ }
314
+ let file = &reader.set.files[*index];
315
+ *index += 1;
316
+ let wire = file.wire().map_err(|e| {
317
+ Error::new(ruby.exception_runtime_error(), format!("{}: {e}", file.wire_path))
318
+ })?;
319
+ let mut cache = reader.cache.lock().expect("class cache");
320
+ let model = value_to_ruby(ruby, &mut cache, &wire)?;
321
+ let pair = RArray::with_capacity(2);
322
+ pair.push(ruby.str_new(&file.wire_path))?;
323
+ pair.push(model)?;
324
+ Ok(pair.as_value())
325
+ }
326
+
327
+ /// Whether the artifact still matches the given {wire_path => read_path}
328
+ /// sources; nil when a source is unreadable.
329
+ fn set_matches_sources(ruby: &Ruby, reader: &SetReader, read_paths: RHash) -> Result<magnus::Value, Error> {
330
+ let lookup = |wire_path: &str| -> Option<String> {
331
+ read_paths
332
+ .fetch(ruby.str_new(wire_path))
333
+ .ok()
334
+ .and_then(|v| String::try_convert(v).ok())
335
+ };
336
+ match reader.set.matches_sources(&lookup) {
337
+ Some(ok) => Ok(ok.into_value()),
338
+ None => Ok(ruby.qnil().as_value()),
339
+ }
340
+ }
341
+
342
+ /// Write a compiled set from Ruby-provided entries —
343
+ /// `[wire_path, source_sha, wire_json]` triples (the post-remark wire
344
+ /// of finalized models, so warm loads skip remark attachment).
345
+ fn set_write(
346
+ ruby: &Ruby,
347
+ out_path: String,
348
+ entries: RArray,
349
+ expressir_version: String,
350
+ ) -> Result<magnus::Value, Error> {
351
+ let mut files = Vec::with_capacity(entries.len());
352
+ for index in 0..entries.len() {
353
+ let triple = RArray::from_value(entries.entry(index as isize)?).ok_or_else(|| {
354
+ Error::new(
355
+ ruby.exception_arg_error(),
356
+ "entries must be [wire_path, source_sha, wire_json] triples",
357
+ )
358
+ })?;
359
+ let wire_path: String = triple.entry(0)?;
360
+ let source_sha: String = triple.entry(1)?;
361
+ let wire_json: String = triple.entry(2)?;
362
+ files.push(CompiledFile {
363
+ wire_path,
364
+ source_sha,
365
+ wire_json,
366
+ });
367
+ }
368
+ let set = CompiledSet::build(files, &expressir_version, &expressir_rs::grammar_digest());
369
+ set.write_to(&out_path).map_err(|e| {
370
+ Error::new(
371
+ ruby.exception_io_error(),
372
+ format!("compiled set {out_path}: {e}"),
373
+ )
374
+ })?;
375
+ Ok(ruby.str_new(&set.header.set_digest).as_value())
376
+ }
377
+
378
+ fn value_to_ruby(ruby: &Ruby, cache: &mut ClassCache, wire: &Wire) -> Result<magnus::Value, Error> {
379
+ match wire {
380
+ Wire::Null => Ok(ruby.qnil().as_value()),
381
+ Wire::Bool(b) => Ok(b.into_value()),
382
+ Wire::Number(n) => {
383
+ if let Some(i) = n.as_i64() {
384
+ Ok(ruby.integer_from_i64(i).into_value())
385
+ } else {
386
+ Ok(ruby.float_from_f64(n.as_f64().unwrap_or(0.0)).into_value())
387
+ }
388
+ }
389
+ Wire::String(s) => Ok(ruby.str_new(s).as_value()),
390
+ Wire::Array(items) => {
391
+ let array = ruby.ary_new_capa(items.len());
392
+ for item in items {
393
+ array.push(value_to_ruby(ruby, cache, item)?)?;
394
+ }
395
+ Ok(array.as_value())
396
+ }
397
+ Wire::Object(map) => {
398
+ let class_name = map.get("_class").and_then(Wire::as_str).ok_or_else(|| {
399
+ Error::new(
400
+ ruby.exception_runtime_error(),
401
+ "expressir-core: model node without _class",
402
+ )
403
+ })?;
404
+ let klass = cache.resolve(ruby, class_name)?;
405
+ let attrs = RHash::new();
406
+ for (key, item) in map {
407
+ attrs.aset(ruby.sym_new(key.as_str()), value_to_ruby(ruby, cache, item)?)?;
408
+ }
409
+ klass.funcall::<_, _, magnus::Value>("instantiate", (attrs,))
410
+ }
411
+ }
412
+ }
413
+
414
+ fn model_object(ruby: &Ruby, source: String, path: String) -> Result<magnus::Value, Error> {
415
+ let tree = ParsedTree::parse(&source).map_err(|e| {
416
+ Error::new(
417
+ ruby.exception_runtime_error(),
418
+ format!("expressir-core: parse error: {e:?}"),
419
+ )
420
+ })?;
421
+ let wire = tree.to_model_json(&path).map_err(|e| parse_error(ruby, e))?;
422
+ let mut cache = ClassCache::default();
423
+ value_to_ruby(ruby, &mut cache, &wire)
424
+ }
425
+
426
+ #[magnus::init]
427
+ fn init(ruby: &Ruby) -> Result<(), Error> {
428
+ let module = ruby.define_module("Expressir")?.define_module("Core")?;
429
+ module.define_singleton_method("parse_to_model_hash", function!(model_json, 2))?;
430
+ module.define_singleton_method("parse_to_model", function!(model_object, 2))?;
431
+ module.define_singleton_method("write_set", function!(set_write, 3))?;
432
+ let batch_class = module.define_class("BatchStream", ruby.class_object())?;
433
+ batch_class.define_singleton_method("start", function!(batch_start, 2))?;
434
+ batch_class.define_method("next", method!(batch_next, 0))?;
435
+ batch_class.define_method(
436
+ "write_set",
437
+ method!(batch_write_set, 3),
438
+ )?;
439
+ let set_class = module.define_class("Set", ruby.class_object())?;
440
+ set_class.define_singleton_method("open", function!(set_open, 1))?;
441
+ set_class.define_method("digest", method!(set_digest, 0))?;
442
+ set_class.define_method("count", method!(set_count, 0))?;
443
+ set_class.define_method("next", method!(set_next, 0))?;
444
+ set_class.define_method("matches_sources", method!(set_matches_sources, 1))?;
445
+ Ok(())
446
+ }
data/lib/expressir/cli.rb CHANGED
@@ -18,17 +18,62 @@ module Expressir
18
18
  method_option :provenance, type: :boolean,
19
19
  desc: "Include provenance information (ELF profile only)",
20
20
  default: true
21
- def format(path)
21
+ def format(path, _thor_options = nil)
22
22
  Expressir::Commands::Format.new(options).run(path)
23
23
  end
24
24
 
25
25
  desc "clean PATH", "Strip remarks and prettify EXPRESS schema at PATH"
26
26
  method_option :output, type: :string,
27
27
  desc: "Output file path (defaults to stdout)"
28
- def clean(path)
28
+ def clean(path, _thor_options = nil)
29
29
  Expressir::Commands::Clean.new(options).run(path)
30
30
  end
31
31
 
32
+ desc "fix PATH",
33
+ "Rewrite self-schema-qualified references (#125) in the EXPRESS schema at PATH"
34
+ method_option :output, type: :string,
35
+ aliases: "-o",
36
+ desc: "Output file path (defaults to stdout)"
37
+ def fix(path, _thor_options = nil)
38
+ Expressir::Commands::Fix.new(options).run(path)
39
+ end
40
+
41
+ desc "expand PATH",
42
+ "Concatenate the interface closure of PATH into one .exp artifact"
43
+ method_option :output, type: :string,
44
+ aliases: "-o",
45
+ desc: "Output file path (defaults to stdout)"
46
+ method_option :manifest, type: :string,
47
+ desc: "ELF schema manifest YAML defining where schemas live " \
48
+ "(primary resolution mode)"
49
+ method_option :stepmod, type: :string,
50
+ desc: "STEPmod checkout root — explicit opt-in to directory-" \
51
+ "convention resolution as the fallback"
52
+ def expand(path, _thor_options = nil)
53
+ Expressir::Commands::Expand.new(options).run(path)
54
+ end
55
+
56
+ desc "flatten PATH",
57
+ "Flatten the interface closure of PATH into an ISO 10303-11:1994 longform schema"
58
+ method_option :output, type: :string,
59
+ aliases: "-o",
60
+ desc: "Output file path (defaults to stdout)"
61
+ method_option :longform_name, type: :string,
62
+ desc: "Longform SCHEMA id (default: <root>_lf)"
63
+ method_option :extenders, type: :string,
64
+ desc: "Extensible type handling: 'all' (default, fold every " \
65
+ "extender in the closure per Annex G.2.3/G.2.4) or " \
66
+ "'none' (leave extensible types as declared)"
67
+ method_option :manifest, type: :string,
68
+ desc: "ELF schema manifest YAML defining where schemas live " \
69
+ "(primary resolution mode)"
70
+ method_option :stepmod, type: :string,
71
+ desc: "STEPmod checkout root — explicit opt-in to directory-" \
72
+ "convention resolution as the fallback"
73
+ def flatten(path, _thor_options = nil)
74
+ Expressir::Commands::Flatten.new(options).run(path)
75
+ end
76
+
32
77
  desc "benchmark FILE_OR_YAML",
33
78
  "Benchmark schema loading performance for a file or list of files from YAML"
34
79
  method_option :ips, type: :boolean,
@@ -37,7 +82,7 @@ module Expressir
37
82
  method_option :save, type: :boolean, desc: "Save benchmark results to file"
38
83
  method_option :format, type: :string,
39
84
  desc: "Output format (json, csv, default)"
40
- def benchmark(path)
85
+ def benchmark(path, _thor_options = nil)
41
86
  Expressir::Commands::Benchmark.new(options).run(path)
42
87
  end
43
88
 
@@ -50,7 +95,7 @@ module Expressir
50
95
  desc: "Output format (json, csv, default)"
51
96
  method_option :cache_path, type: :string,
52
97
  desc: "Path to store the cache file"
53
- def benchmark_cache(path)
98
+ def benchmark_cache(path, _thor_options = nil)
54
99
  Expressir::Commands::BenchmarkCache.new(options).run(path)
55
100
  end
56
101
 
@@ -0,0 +1,56 @@
1
+ require "json"
2
+
3
+ module Expressir
4
+ module Commands
5
+ # `validate check` — semantic checks modeled on eeng check-p11 note
6
+ # subset (expressir#282). Exits 1 when any :error notes are emitted.
7
+ # Loads the interface closure of each input file so USE FROM targets
8
+ # are visible — matches Concatenator/Shtolo expectations.
9
+ class Check < Base
10
+ def run(*paths)
11
+ files = Array(paths).flat_map do |p|
12
+ if File.directory?(p)
13
+ Dir["#{p}/**/*.exp"]
14
+ else
15
+ ParityInputs.closure_paths(
16
+ p, manifest: options[:manifest], stepmod: options[:stepmod]
17
+ ).select { |f| File.exist?(f) }
18
+ end
19
+ end.uniq
20
+ repository = Expressir::Express::Parser.from_files(files)
21
+ # A file that failed to parse is a nil entry: it must fail the run,
22
+ # not pass silently as a "clean" schema (#413).
23
+ failed = repository.files.count(&:nil?)
24
+ if failed.positive?
25
+ raise Thor::Error, "#{failed} file(s) failed to parse"
26
+ end
27
+
28
+ result = Expressir::Express::Checker.new(repository).check
29
+
30
+ if options[:json]
31
+ say JSON.generate(
32
+ valid: result.valid?,
33
+ errors: serialize(result.errors),
34
+ warnings: serialize(result.warnings),
35
+ )
36
+ else
37
+ result.notes.each { |n| say "[#{n.severity}] #{n.id}: #{n.message}" }
38
+ say "#{result.errors.size} error(s), #{result.warnings.size} warning(s)"
39
+ end
40
+
41
+ return if result.valid?
42
+
43
+ raise Thor::Error, "schema check produced #{result.errors.size} error(s)"
44
+ end
45
+
46
+ private
47
+
48
+ def serialize(notes)
49
+ notes.map do |n|
50
+ { id: n.id.to_s, severity: n.severity.to_s, schema: n.schema,
51
+ message: n.message }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,26 @@
1
+ module Expressir
2
+ module Commands
3
+ # `expand` — flattens an interface closure into a single concatenated
4
+ # .exp artifact (expressir#247). Equivalent to eeng's
5
+ # `--concat_schema` over the root schema's closure.
6
+ class Expand < Base
7
+ def run(path)
8
+ root, repository = ParityInputs.root_schema(
9
+ path, manifest: options[:manifest], stepmod: options[:stepmod]
10
+ )
11
+ exit_with_error "schema for #{path} not found in closure" unless root
12
+
13
+ closure = Expressir::Express::Concatenator.closure(root, repository)
14
+
15
+ if options[:output]
16
+ File.open(options[:output], "w") do |io|
17
+ Expressir::Express::Concatenator.write(io, closure)
18
+ end
19
+ say "Concatenated schema written to #{options[:output]}"
20
+ else
21
+ Expressir::Express::Concatenator.write($stdout, closure)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,33 @@
1
+ module Expressir
2
+ module Commands
3
+ # `fix` — rewrites self-schema-qualified references (#125): string
4
+ # literals like `'THIS_SCHEMA.ITEM'` lose the current-schema prefix
5
+ # when the item is local, or gain the item's true defining schema
6
+ # otherwise. Outputs the corrected EXPRESS file.
7
+ class Fix < Base
8
+ def run(path)
9
+ # Load the interface closure (same-dir by default, or --manifest /
10
+ # --stepmod) so SelfSchemaReference can resolve where each item
11
+ # truly comes from.
12
+ files = ParityInputs.closure_paths(
13
+ path, manifest: options[:manifest], stepmod: options[:stepmod]
14
+ ).select { |f| File.exist?(f) }
15
+ repository = Expressir::Express::Parser.from_files(
16
+ files, skip_references: true
17
+ )
18
+ total = repository.schemas.sum do |schema|
19
+ Expressir::Express::SelfSchemaReference.fix!(schema)
20
+ end
21
+
22
+ text = Expressir::Express::Formatter.format(repository)
23
+ if options[:output]
24
+ File.write(options[:output], text)
25
+ say "#{total} self-schema reference(s) fixed; written to " \
26
+ "#{options[:output]}"
27
+ else
28
+ $stdout.write(text)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ module Expressir
2
+ module Commands
3
+ # `flatten` — converts a STEPmod-style multi-schema root into a single
4
+ # ISO 10303-11:1994 longform schema (SHTOLO, expressir#32).
5
+ # Equivalent to eeng's `--flat -mode _longform`.
6
+ class Flatten < Base
7
+ def run(path)
8
+ root, repository = ParityInputs.root_schema(
9
+ path, manifest: options[:manifest], stepmod: options[:stepmod]
10
+ )
11
+ exit_with_error "schema for #{path} not found in closure" unless root
12
+
13
+ longform_name = options[:longform_name] || "#{root.id}_lf"
14
+ extenders = options[:extenders] == "none" ? :none : :all
15
+ flat = Expressir::Express::Shtolo.new(root, repository,
16
+ longform_name: longform_name,
17
+ extenders: extenders).flatten.schema
18
+ text = Expressir::Express::Formatter.format(flat)
19
+
20
+ if options[:output]
21
+ File.write(options[:output], text)
22
+ say "Longform schema written to #{options[:output]}"
23
+ else
24
+ $stdout.write(text)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end