expressir 2.4.0 → 2.4.2

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bugs/01-stale-transformer-autoload.md +39 -0
  3. data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
  4. data/TODO.bugs/03-builder-mutable-state.md +43 -0
  5. data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
  6. data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
  7. data/TODO.bugs/06-collection-registry-single-source.md +53 -0
  8. data/TODO.bugs/07-require-relative-cleanup.md +42 -0
  9. data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
  10. data/TODO.bugs/09-parser-split.md +53 -0
  11. data/TODO.bugs/10-to-s-override.md +42 -0
  12. data/TODO.bugs/11-parser-class-variables.md +39 -0
  13. data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
  14. data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
  15. data/TODO.bugs/14-model-formatting-leak.md +30 -0
  16. data/TODO.bugs/15-expression-children-macro.md +27 -0
  17. data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
  18. data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
  19. data/TODO.bugs/18-const-get-private-constants.md +30 -0
  20. data/TODO.bugs/19-format-methods-public.md +22 -0
  21. data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
  22. data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
  23. data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
  24. data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
  25. data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
  26. data/TODO.bugs/25-debug-puts-in-production.md +21 -0
  27. data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
  28. data/TODO.bugs/27-package-build-god-method.md +19 -0
  29. data/TODO.bugs/28-package-god-class.md +30 -0
  30. data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
  31. data/TODO.bugs/30-unicode-map-extraction.md +19 -0
  32. data/TODO.bugs/README.md +43 -0
  33. data/TODO.max-perf/01-restore-ci-green.md +29 -0
  34. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  35. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  36. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  37. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  38. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  39. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  40. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  41. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  42. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  43. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  44. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  45. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  46. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  47. data/benchmark/srl_benchmark.rb +76 -17
  48. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  49. data/docs/_tutorials/index.adoc +10 -0
  50. data/docs/lychee.toml +3 -0
  51. data/expressir.gemspec +4 -2
  52. data/lib/expressir/cli.rb +3 -0
  53. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  54. data/lib/expressir/commands/changes_validate.rb +0 -2
  55. data/lib/expressir/commands/clean.rb +1 -1
  56. data/lib/expressir/commands/coverage.rb +6 -2
  57. data/lib/expressir/commands/file_violations.rb +70 -0
  58. data/lib/expressir/commands/format.rb +1 -1
  59. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  60. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  61. data/lib/expressir/commands/package.rb +143 -187
  62. data/lib/expressir/commands/validate_ascii.rb +0 -510
  63. data/lib/expressir/commands/version.rb +1 -1
  64. data/lib/expressir/commands.rb +3 -0
  65. data/lib/expressir/coverage.rb +49 -117
  66. data/lib/expressir/express/ast_key_converter.rb +114 -0
  67. data/lib/expressir/express/builder.rb +71 -194
  68. data/lib/expressir/express/builder_context.rb +22 -0
  69. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  70. data/lib/expressir/express/cache.rb +35 -8
  71. data/lib/expressir/express/error.rb +24 -0
  72. data/lib/expressir/express/formatter.rb +33 -18
  73. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  74. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  75. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  76. data/lib/expressir/express/grammar/parser.rb +705 -0
  77. data/lib/expressir/express/grammar.rb +11 -0
  78. data/lib/expressir/express/node_position_index.rb +215 -0
  79. data/lib/expressir/express/parallel_files.rb +229 -0
  80. data/lib/expressir/express/parser.rb +138 -950
  81. data/lib/expressir/express/pretty_formatter.rb +23 -5
  82. data/lib/expressir/express/remark_attacher.rb +497 -581
  83. data/lib/expressir/express/remark_scanner.rb +84 -19
  84. data/lib/expressir/express/schema_block_scanner.rb +137 -0
  85. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  86. data/lib/expressir/express/scope_resolver.rb +223 -0
  87. data/lib/expressir/express/source_formatter.rb +15 -0
  88. data/lib/expressir/express/streaming_builder.rb +147 -176
  89. data/lib/expressir/express.rb +10 -6
  90. data/lib/expressir/model/concerns.rb +3 -0
  91. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  92. data/lib/expressir/model/declarations/entity.rb +5 -0
  93. data/lib/expressir/model/declarations/function.rb +5 -0
  94. data/lib/expressir/model/declarations/procedure.rb +5 -0
  95. data/lib/expressir/model/declarations/rule.rb +6 -0
  96. data/lib/expressir/model/declarations/schema.rb +21 -8
  97. data/lib/expressir/model/declarations/type.rb +3 -0
  98. data/lib/expressir/model/exp_file.rb +2 -0
  99. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  100. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  101. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  102. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  103. data/lib/expressir/model/expressions/function_call.rb +1 -0
  104. data/lib/expressir/model/expressions/interval.rb +1 -0
  105. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  106. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  107. data/lib/expressir/model/model_element.rb +133 -14
  108. data/lib/expressir/model/remark_format.rb +17 -0
  109. data/lib/expressir/model/remark_info.rb +31 -3
  110. data/lib/expressir/model/remark_placement.rb +29 -0
  111. data/lib/expressir/model/repository.rb +18 -5
  112. data/lib/expressir/model/statements/alias.rb +2 -0
  113. data/lib/expressir/model/statements/assignment.rb +4 -0
  114. data/lib/expressir/model/statements/case.rb +21 -0
  115. data/lib/expressir/model/statements/case_action.rb +1 -0
  116. data/lib/expressir/model/statements/compound.rb +4 -0
  117. data/lib/expressir/model/statements/escape.rb +3 -0
  118. data/lib/expressir/model/statements/if.rb +5 -0
  119. data/lib/expressir/model/statements/null.rb +3 -0
  120. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  121. data/lib/expressir/model/statements/repeat.rb +3 -0
  122. data/lib/expressir/model/statements/return.rb +3 -0
  123. data/lib/expressir/model/statements/skip.rb +3 -0
  124. data/lib/expressir/model.rb +12 -3
  125. data/lib/expressir/package/builder.rb +2 -2
  126. data/lib/expressir/version.rb +6 -1
  127. data/lib/expressir.rb +56 -3
  128. metadata +81 -7
  129. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -0,0 +1,45 @@
1
+ # TODO.max-perf/08 — Instrument and optimize Builder.build internals
2
+
3
+ ## Context
4
+
5
+ TODO.max-perf/06 established the split (CPU-time, parsanol 1.3.30, 4 mid-size
6
+ SRL schemas): native parse 50%, `Builder.build_with_remarks` 48%, references
7
+ 2%; within the builder, model build is 1.12s vs remark attachment 0.06s.
8
+ `fast_convert_keys`/`cached_snake_case` are already single-pass and
9
+ allocation-avoiding. The unmeasured stages inside `Builder.build`:
10
+
11
+ 1. `builder.call(snake_data)` — registered handler → lutaml-model
12
+ instantiation (thousands of attribute setters per schema)
13
+ 2. `attach_source_info` → `extract_source_info` → `find_slice` — a
14
+ depth-capped recursive scan of each node's data subtree; nested data can
15
+ be re-scanned by ancestors, which is quadratic-ish on deep nesting
16
+ 3. The `when Array` recursion
17
+
18
+ ## Work
19
+
20
+ Findings: fast_convert_keys ran 172k–266k times per schema (~60 calls per
21
+ model node): build() descends into subtrees the parent's deep conversion
22
+ already scanned, so every subtree was re-scanned once per ancestor level.
23
+ attach_source_info is negligible (0.02–0.07s instrumented upper bound);
24
+ the remaining unoptimized share is lutaml-model instantiation inside
25
+ builder.call (upstream territory).
26
+
27
+ - [x] Instrumented (counters + timers) over topology / presentation /
28
+ measure / action schemas; split recorded above
29
+ - [x] Implemented the evidenced win: fast_convert_keys marks scanned or
30
+ converted Hash/Array containers with an invisible ivar and skips
31
+ them on re-visits
32
+ - [x] Before/after (CPU-time, warm): topology 0.42→0.25s (−40%),
33
+ presentation 0.27→0.22s (−19%), measure 0.43→0.30s (−30%),
34
+ action 0.11→0.06s (−48%)
35
+ - [x] Full suite green (1551 examples) including golden-file parser specs;
36
+ to_hash equality verified against pre-change output on all four
37
+ schemas
38
+
39
+ - [x] Handler-level audit (TODO 14 overlap): `expression` 25.8% / `syntax` 15.4% of builder self-CPU — structural dispatch cost (per-level key cascades + build_node wrapper-hash allocations) spread across ~20k nodes; no single fixable waste. Architectural cure = skip the intermediate Hash AST via parsanol's stable parse_with_builder (blocked upstream, TODO 02 / parsanol#59).
40
+
41
+ ## Acceptance
42
+
43
+ Numbers table for the three stages; either a measured improvement merged or
44
+ the cost definitively attributed to lutaml-model instantiation (which moves
45
+ the next step upstream to lutaml-model).
@@ -0,0 +1,25 @@
1
+ # TODO.max-perf/09 — Grammar cold-start cost and disk cache
2
+
3
+ ## Context
4
+
5
+ Every process boot builds the parsanol grammar and serializes ~2,300 atoms to
6
+ JSON (`Grammar::Parser.cached_parser` / `cached_grammar_json`) before the
7
+ first parse. Short-lived processes (CLI invocations, tests) pay this on every
8
+ run. parsanol-ruby#59 tracks upstream grammar-to-code compilation; an
9
+ expressir-side JSON disk cache would help regardless.
10
+
11
+ ## Work
12
+
13
+ Measured (fresh process, 3 runs): boot (require expressir) 161–181ms,
14
+ grammar build ~28ms, JSON serialization ~30ms (83KB), first parse ~25ms.
15
+ Grammar-related cold cost is ~58ms — below the 100ms bar; a disk cache
16
+ would save ~50ms per process at the cost of temp-file staleness machinery.
17
+
18
+ - [x] Cold-start measured and recorded (above)
19
+ - [x] Documented as negligible — no cache implemented (the larger cold
20
+ cost is the 161ms require boot, a separate lazy-loading concern)
21
+
22
+ ## Acceptance
23
+
24
+ Cold-start number recorded; cache implemented with specs if the number
25
+ justifies it, otherwise documented as negligible.
@@ -0,0 +1,23 @@
1
+ # TODO.max-perf/10 — Parser facade API hygiene
2
+
3
+ ## Context
4
+
5
+ The `Parser` facade accumulated internal helpers with public visibility
6
+ during the parallel work: `parse_files_sequentially` and `build_repository`
7
+ are implementation details of `from_files`. `Commands::Coverage` defines a
8
+ `DEFAULT_MAX_PROCESSES` constant that duplicates
9
+ `ParallelFiles::DEFAULT_MAX_PROCESSES` (the CLI option default already
10
+ references the latter directly) — a DRY violation with a dead constant.
11
+
12
+ ## Work
13
+
14
+ - [x] `parse_files_sequentially` and `build_repository` are now
15
+ `private_class_method` (no external callers existed)
16
+ - [x] Dead `Coverage::DEFAULT_MAX_PROCESSES` removed; the CLI option
17
+ default references `ParallelFiles::DEFAULT_MAX_PROCESSES` directly
18
+ - [x] Rubocop clean; full suite green (1551 examples, 40s)
19
+
20
+ ## Acceptance
21
+
22
+ Facade exposes only the public contract; single source for the worker-cap
23
+ constant.
@@ -0,0 +1,25 @@
1
+ # TODO.max-perf/11 — CI green close-out
2
+
3
+ ## Context
4
+
5
+ TODO 01's final checkbox. Main carries the Windows fork guard (#353), the
6
+ engine-selection guard pinning :standard adapters on `Gem.win_platform?`
7
+ (#354), and the builder memoization (#355). yeptris mingw remains broken
8
+ upstream (leptris/yeptris#318); the guard must keep it unloaded.
9
+
10
+ ## Work
11
+
12
+ - [x] Confirmed locally via full `bundle exec rake` (CI-equivalent):
13
+ 1558 examples, 0 failures, rubocop clean across 325 files
14
+ - [x] If red: diagnose the failing job, fix in expressir if the defect is
15
+ here, escalate upstream otherwise
16
+
17
+ - [x] Root causes of the red run on 35c1f62 found and fixed: (1) rubocop offenses in the committed benchmark harness + a directive typo in another session's remark perf spec — fixed; (2) sequential-path nil-pad parity bug in from_files — fixed with regression spec; (3) strict-mode spec unguarded on fork-less platforms — guarded; (4) yeptris still loading on Windows: root cause is lutaml-model#798 (configured adapter fell through to detection, loading yeptris before any expressir pin could apply) — fixed upstream + expressir spec_helper now pins json too, with a temporary require-spy proving zero yeptris loads in the suite
18
+
19
+ - [x] Landed on main via #357 (the #356 merge had gone to its stale base
20
+ branch `feat/max-perf-todos`; cherry-picked 17aa264 onto main and
21
+ re-landed as 0576a08)
22
+
23
+ ## Acceptance
24
+
25
+ rake green on main; TODO 01's checkbox ticked.
@@ -0,0 +1,25 @@
1
+ # TODO.max-perf/12 — Require-boot profile and deferrable loads
2
+
3
+ ## Context
4
+
5
+ TODO 09 measured the cold start: grammar work is only ~58ms; the dominant
6
+ cold cost is `require "expressir"` itself at 161–181ms. Short-lived
7
+ processes (CLI, CI) pay it per invocation. expressir.rb eagerly requires
8
+ `lutaml/model` and `liquid` (for Lutaml::Model::Liquefiable); the gemspec
9
+ also pulls csv, benchmark-ips, ruby-progressbar, nokogiri-adjacent moxml,
10
+ thor — some needed only by CLI commands, not by library consumers.
11
+
12
+ ## Work
13
+
14
+ - [x] Profiled: per-gem load times via a require-timing probe in a fresh
15
+ process (results below)
16
+ - [x] Evaluated deferrals: nothing safely deferrable (results below)
17
+ - [x] Full suite green (no deferred loads introduced)
18
+
19
+ - [x] Per-gem boot profile (fresh process, 2 runs): liquid 172-207ms, lutaml/model 327-559ms (includes moxml chain), thor 19-40ms, expressir own lib ~1ms (autoloads already optimal)
20
+ - [x] Documented as not safely deferrable: liquid backs Lutaml::Model::Liquefiable used by expressir models in metanorma rendering; lutaml-model's boot is upstream (its lazy-loading is tracked there). expressir's own load adds ~1ms — nothing to defer.
21
+
22
+ ## Acceptance
23
+
24
+ Per-gem boot profile recorded; measured reduction if safely deferrable,
25
+ otherwise documented why not.
@@ -0,0 +1,26 @@
1
+ # TODO.max-perf/13 — Unit specs for the key-conversion memoization
2
+
3
+ ## Context
4
+
5
+ PR #355 optimized `Builder.fast_convert_keys` with an invisible-ivar marker
6
+ so re-visited containers are skipped. The optimization is currently covered
7
+ only indirectly by golden-file parser specs. The memoization's own contract
8
+ needs pinning so future edits cannot silently break it.
9
+
10
+ ## Work
11
+
12
+ - [x] Unit specs for the converter (renamed `AstKeyConverter.convert`):
13
+ - converts CamelCase keys at every depth; untouched data keeps object
14
+ identity
15
+ - idempotence: converted results convert to themselves
16
+ - marked containers are skipped on re-visits (marker on both paths)
17
+ - frozen input hashes do not raise
18
+ - [x] Marker invisibility: marked hashes remain == to unmarked copies and
19
+ survive Marshal round-trip without affecting content
20
+
21
+ - [x] All contract specs written against the extracted public Expressir::Express::AstKeyConverter (key conversion was promoted from a private Builder helper to its own class — MECE: converting AST keys is not building models)
22
+ - [x] Depth conversion, identity preservation, idempotence, marker set on both paths, frozen-hash safety, marker invisibility (== and Marshal), snake_case behavior — spec/expressir/express/ast_key_converter_spec.rb
23
+
24
+ ## Acceptance
25
+
26
+ Dedicated spec file green; the optimization's behavior is pinned.
@@ -0,0 +1,28 @@
1
+ # TODO.max-perf/14 — Per-handler builder.call audit
2
+
3
+ ## Context
4
+
5
+ TODO 08 attributed the remaining (post-memoization) builder cost to model
6
+ instantiation inside `builder.call` — but that was inferred, not measured
7
+ per handler. ~200 registered handlers exist (builders/*.rb); expressir-side
8
+ waste in the hottest handlers (e.g., redundant intermediate arrays, repeated
9
+ `build()` calls, hash re-shaping) would be ours to fix.
10
+
11
+ ## Work
12
+
13
+ - [x] Instrumented per handler via a self-time build wrapper (child CPU
14
+ subtracted) over representative schemas; top handlers recorded below
15
+ - [x] Inspected the top handlers' source (expression_builder.rb et al.)
16
+ - [x] No evident expressir-side waste to fix (see verdict below); the
17
+ optimization taken instead was the fast_convert_keys memoization
18
+ (merged in #355) after TODO 08's stage instrumentation
19
+ - [x] Full suite green
20
+
21
+ - [x] Handler-level instrumentation via a self-time build wrapper (child-time subtracted): 20,270 nodes, 1.58s self CPU over 3 schemas
22
+ - [x] Top handlers: expression 25.8% (3,184 calls), syntax 15.4%, entity_ref 7.7%, simple_expression 6.0%, entity_decl 5.1% — long tail of leaf handlers below 4% each
23
+ - [x] Verdict: no single expressir-side waste; `expression`'s share is structural dispatch (if/elsif key cascades + per-call build_node wrapper-hash allocations). The architectural fix is bypassing the intermediate Hash AST entirely — blocked on parsanol's stable parse_with_builder (TODO 02, parsanol#59). Recorded; not refactored blindly.
24
+
25
+ ## Acceptance
26
+
27
+ Handler-level numbers table; either a merged improvement or the cost
28
+ definitively attributed to lutaml-model constructors (upstream follow-up).
@@ -17,7 +17,8 @@ require "parsanol/native"
17
17
  require "expressir"
18
18
 
19
19
  # Configuration
20
- SRL_PATH = "/Users/mulgogi/src/mn/iso-10303/schemas/resources"
20
+ SRL_PATH = ENV["SRL_PATH"] ||
21
+ "/Users/mulgogi/src/mn/iso-10303/schemas/resources"
21
22
  ITERATIONS = (ENV["ITERATIONS"] || 1).to_i
22
23
  TIMEOUT_SECONDS = (ENV["TIMEOUT"] || 30).to_i # Timeout per file
23
24
 
@@ -104,6 +105,69 @@ def find_exp_files
104
105
  end
105
106
  end
106
107
 
108
+ # Parse one file in a forked child with a hard wall-clock kill.
109
+ # Ruby's Timeout cannot interrupt the native parser (the GVL is held
110
+ # for the whole FFI call), so process isolation is the only reliable
111
+ # guard against pathological backtracking hangs.
112
+ def parse_file_isolated(file, use_native:, timeout: TIMEOUT_SECONDS)
113
+ rd, wr = IO.pipe
114
+ pid = fork do
115
+ rd.close
116
+ t = Time.now
117
+ result = { status: "ok", elapsed: 0.0, error: nil }
118
+ begin
119
+ if use_native
120
+ content = File.read(file)
121
+ Expressir::Express::Parser.from_exp(content, skip_references: true,
122
+ use_native: true)
123
+ else
124
+ Expressir::Express::Parser.from_file(file, skip_references: true,
125
+ use_native: false)
126
+ end
127
+ rescue StandardError => e
128
+ result[:status] = "err"
129
+ result[:error] = "#{e.class}: #{e.message[0..60]}"
130
+ end
131
+ result[:elapsed] = Time.now - t
132
+ data = Marshal.dump(result)
133
+ wr.write([data.bytesize].pack("N"))
134
+ wr.write(data)
135
+ wr.close
136
+ exit!(0)
137
+ end
138
+ wr.close
139
+
140
+ deadline = Time.now + timeout
141
+ timed_out = false
142
+ loop do
143
+ done = Process.waitpid(pid, Process::WNOHANG)
144
+ break if done
145
+
146
+ if Time.now > deadline
147
+ Process.kill("TERM", pid)
148
+ sleep 1
149
+ begin
150
+ Process.kill("KILL", pid) if Process.waitpid(pid, Process::WNOHANG).nil?
151
+ rescue StandardError
152
+ nil
153
+ end
154
+ timed_out = true
155
+ break
156
+ end
157
+ sleep 0.1
158
+ end
159
+
160
+ if timed_out
161
+ rd.close
162
+ { status: "timeout", elapsed: timeout.to_f, error: "Timeout after #{timeout}s" }
163
+ else
164
+ header = rd.read(4)
165
+ payload = header && Marshal.load(rd.read(header.unpack1("N"))) # rubocop:disable Security/MarshalLoad
166
+ rd.close
167
+ payload
168
+ end
169
+ end
170
+
107
171
  def count_lines(files)
108
172
  files.sum { |f| File.read(f).lines.count }
109
173
  end
@@ -176,28 +240,22 @@ class ParserBenchmark
176
240
  file_start = Time.now
177
241
  schema_lines = File.read(file).lines.count
178
242
 
179
- begin
180
- require "timeout"
181
- Timeout.timeout(TIMEOUT_SECONDS) do
182
- if @use_native
183
- content = File.read(file)
184
- Expressir::Express::Parser.from_exp(content, skip_references: true,
185
- use_native: true)
186
- else
187
- Expressir::Express::Parser.from_file(file, skip_references: true)
188
- end
189
- end
243
+ result = parse_file_isolated(file, use_native: @use_native)
244
+ result[:elapsed]
245
+
246
+ case result[:status]
247
+ when "ok"
190
248
  iteration_results[:success] += 1
191
249
  status = "#{BRIGHT_GREEN}✓#{RESET}"
192
- rescue Timeout::Error
250
+ when "timeout"
193
251
  iteration_results[:failed] += 1
194
252
  iteration_results[:errors] << { file: File.basename(file),
195
- error: "Timeout after #{TIMEOUT_SECONDS}s" }
253
+ error: result[:error] }
196
254
  status = "#{BRIGHT_YELLOW}⏱#{RESET}"
197
- rescue StandardError => e
255
+ else
198
256
  iteration_results[:failed] += 1
199
257
  iteration_results[:errors] << { file: File.basename(file),
200
- error: e.message[0..60] }
258
+ error: result[:error] }
201
259
  status = "#{BRIGHT_RED}✗#{RESET}"
202
260
  end
203
261
 
@@ -346,7 +404,8 @@ print_warmup_start
346
404
  warmup_file = files.first
347
405
 
348
406
  begin
349
- Expressir::Express::Parser.from_file(warmup_file, skip_references: true)
407
+ Expressir::Express::Parser.from_file(warmup_file, skip_references: true,
408
+ use_native: false)
350
409
  rescue StandardError => e
351
410
  puts "#{BRIGHT_YELLOW}⚠️ Ruby warmup warning: #{e.message[0..40]}#{RESET}"
352
411
  end
@@ -0,0 +1,89 @@
1
+ ---
2
+ title: Formatting Schemas
3
+ nav_order: 7
4
+ ---
5
+
6
+ == Formatting Schemas
7
+
8
+ Expressir renders parsed EXPRESS schemas back to source text via the `Formatter`. This is what powers the `expressir format` CLI command, the `Schema#full_source` method, and the Liquid drop exposed to documentation templates.
9
+
10
+ === The default formatter
11
+
12
+ The default `Expressir::Express::Formatter` produces a normalized, consistently-indented version of the schema — comments preserved, original line breaks normalized:
13
+
14
+ [source,ruby]
15
+ ----
16
+ require "expressir"
17
+
18
+ exp_file = Expressir::Express::Parser.from_file("my_schema.exp")
19
+ schema = exp_file.schemas.first
20
+
21
+ puts Expressir::Express::Formatter.format(schema)
22
+ ----
23
+
24
+ To format every schema in an ExpFile (or Repository), pass the container:
25
+
26
+ [source,ruby]
27
+ ----
28
+ puts Expressir::Express::Formatter.format(exp_file)
29
+ ----
30
+
31
+ === Stripping remarks (`no_remarks:`)
32
+
33
+ Pass `no_remarks: true` to produce a listing without tail (`--`) or embedded (`(* ... *)`) remarks. Useful for diffs, license headers, and "code-only" outputs.
34
+
35
+ [source,ruby]
36
+ ----
37
+ formatter = Class.new(Expressir::Express::Formatter) do
38
+ def initialize
39
+ super(no_remarks: true)
40
+ end
41
+ end
42
+
43
+ puts formatter.format(schema)
44
+ ----
45
+
46
+ === CLI: `expressir format`
47
+
48
+ The CLI wraps the same formatter:
49
+
50
+ [source,sh]
51
+ ----
52
+ expressir format my_schema.exp # prints formatted output
53
+ expressir format my_schema.exp -o out.exp # writes to a file
54
+ expressir format --no-remarks my_schema.exp
55
+ ----
56
+
57
+ === Schema head and hyperlink formatters
58
+
59
+ Two optional mixins layer on top of the base formatter:
60
+
61
+ * `Expressir::Express::SchemaHeadFormatter` — emits a file-level header banner before each schema (schema name, version, source file).
62
+ * `Expressir::Express::HyperlinkFormatter` — inserts cross-reference hyperlinks (`<<express:...>>` anchors) into the formatted text for use by the Metanorma documentation pipeline.
63
+
64
+ Combine them by defining a subclass that includes both:
65
+
66
+ [source,ruby]
67
+ ----
68
+ formatter = Class.new(Expressir::Express::Formatter) do
69
+ include Expressir::Express::SchemaHeadFormatter
70
+ include Expressir::Express::HyperlinkFormatter
71
+ end
72
+
73
+ puts formatter.format(exp_file)
74
+ ----
75
+
76
+ === `Schema#full_source`
77
+
78
+ For convenience, `Schema#full_source` returns the default-formatted text (memoized). This is what templates should use when they want the canonical rendered schema body:
79
+
80
+ [source,ruby]
81
+ ----
82
+ schema = exp_file.schemas.first
83
+ puts schema.full_source
84
+ ----
85
+
86
+ === See also
87
+
88
+ * link:querying-schemas.html[Querying Schemas] — find entities, types, and remarks programmatically.
89
+ * link:liquid-templates.html[Liquid Templates] — use the formatted output inside documentation templates.
@@ -58,6 +58,11 @@ Duration: 45-60 minutes
58
58
  +
59
59
  Analyze and improve schema documentation quality with coverage tools and metrics.
60
60
 
61
+ **link:formatting-schemas.html[Formatting Schemas]**::
62
+ Duration: 15-20 minutes
63
+ +
64
+ Render parsed schemas back to EXPRESS source text — default formatting, remark stripping, schema-head and hyperlink mixins.
65
+
61
66
  === Tutorial Features
62
67
 
63
68
  Each tutorial includes:
@@ -121,6 +126,11 @@ Where to go after completing the tutorial
121
126
  | Advanced
122
127
  | Coverage analysis
123
128
  | 45-60 min
129
+
130
+ | link:formatting-schemas.html[Formatting Schemas]
131
+ | Beginner
132
+ | Rendering schemas to source text
133
+ | 15-20 min
124
134
  |===
125
135
 
126
136
  === Additional Resources
data/docs/lychee.toml CHANGED
@@ -59,6 +59,9 @@ exclude = [
59
59
  "https://www\\.nist\\.gov/.*",
60
60
  "https://www\\.steptools\\.com/.*",
61
61
 
62
+ # iso.org returns 403 to bots — the ISO store page is reachable in a browser
63
+ "https://www\\.iso\\.org/.*",
64
+
62
65
  # GitHub URLs that require authentication
63
66
  "https://github\\.com/.*/(issues|pull)/[0-9]+/files.*",
64
67
 
data/expressir.gemspec CHANGED
@@ -4,7 +4,7 @@ require "expressir/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "expressir"
7
- spec.version = Expressir::VERSION
7
+ spec.version = Expressir::Version::VERSION
8
8
  spec.authors = ["Ribose Inc."]
9
9
  spec.email = ["open.source@ribose.com"]
10
10
 
@@ -42,7 +42,9 @@ Gem::Specification.new do |spec|
42
42
  spec.add_dependency "paint"
43
43
  spec.add_dependency "parsanol", "~> 1.3.9", ">= 1.3.9"
44
44
  spec.add_dependency "ruby-progressbar", "~> 1.11"
45
- spec.add_dependency "rubyzip", "~> 2.3"
45
+ spec.add_dependency "rubyzip"
46
46
  spec.add_dependency "table_tennis"
47
47
  spec.add_dependency "thor", "~> 1.0"
48
+ # Native YAML/JSON engine (lutaml-model opt-in by bundle contents)
49
+ spec.add_dependency "yeptris", "~> 0.6.1"
48
50
  end
data/lib/expressir/cli.rb CHANGED
@@ -67,6 +67,9 @@ module Expressir
67
67
  desc: "Output file path for JSON/YAML formats (defaults to coverage_report.json/yaml)"
68
68
  method_option :ignore_files, type: :string,
69
69
  desc: "Path to YAML file containing array of files to ignore from overall coverage calculation"
70
+ method_option :max_processes, type: :numeric,
71
+ default: Expressir::Express::ParallelFiles::DEFAULT_MAX_PROCESSES,
72
+ desc: "Parallel parse workers (1 = sequential; falls back to sequential where fork is unavailable)"
70
73
  def coverage(*paths)
71
74
  Expressir::Commands::Coverage.new(options).run(paths)
72
75
  end
@@ -12,8 +12,6 @@ module Expressir
12
12
  # @param options [Hash] Additional options
13
13
  # @return [Expressir::Changes::SchemaChange]
14
14
  def self.from_xml(xml_content, schema_name, version, **)
15
- require "expressir/changes"
16
-
17
15
  # Parse into CompareReport using Lutaml::Model
18
16
  compare_report = Expressir::Eengine::CompareReport.from_xml(xml_content)
19
17
 
@@ -24,8 +22,6 @@ module Expressir
24
22
 
25
23
  # File-based workflow (backward compatible)
26
24
  def self.call(input_file, output_file, schema_name, version, **options)
27
- require "expressir/changes"
28
-
29
25
  xml_content = File.read(input_file)
30
26
 
31
27
  # Load existing schema if output file exists
@@ -51,8 +47,6 @@ module Expressir
51
47
 
52
48
  def convert_to_schema_change(compare_report, schema_name, version,
53
49
  **options)
54
- require "expressir/changes"
55
-
56
50
  # Extract changes from CompareReport
57
51
  changes = {
58
52
  additions: extract_items(compare_report.additions,
@@ -5,8 +5,6 @@ module Expressir
5
5
  # Command to validate and normalize EXPRESS Changes YAML files
6
6
  class ChangesValidate < Base
7
7
  def run(path)
8
- require "expressir/changes"
9
-
10
8
  # Check if file exists
11
9
  unless File.exist?(path)
12
10
  exit_with_error("File not found: #{path}")
@@ -5,7 +5,7 @@ module Expressir
5
5
  repository = Expressir::Express::Parser.from_file(path)
6
6
  formatted_schemas = repository.schemas.map do |schema|
7
7
  # Format schema without remarks
8
- schema.to_s(no_remarks: true)
8
+ schema.format(no_remarks: true)
9
9
  end.join("\n\n")
10
10
 
11
11
  if options[:output]
@@ -73,7 +73,9 @@ module Expressir
73
73
 
74
74
  # Parse all files and create a repository with progress tracking
75
75
  begin
76
- repository = Expressir::Express::Parser.from_files(exp_files) do |filename, _schemas, error|
76
+ repository = Expressir::Express::Parser.from_files(
77
+ exp_files, max_processes: options[:max_processes]
78
+ ) do |filename, _schemas, error|
77
79
  if error
78
80
  say " Error processing #{File.basename(filename)}: #{error.message}"
79
81
  end
@@ -125,7 +127,9 @@ module Expressir
125
127
  )
126
128
 
127
129
  # Process files with progress tracking
128
- repository = Expressir::Express::Parser.from_files(schema_files) do |filename, _schemas, error|
130
+ repository = Expressir::Express::Parser.from_files(
131
+ schema_files, max_processes: options[:max_processes]
132
+ ) do |filename, _schemas, error|
129
133
  if error
130
134
  say " Error processing #{File.basename(filename)}: #{error.message}"
131
135
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Commands
5
+ class FileViolations
6
+ attr_reader :path, :filename, :directory, :violations
7
+
8
+ def initialize(file_path)
9
+ @path = file_path
10
+ @filename = File.basename(file_path)
11
+ @directory = File.dirname(file_path)
12
+ @characters = {} # Map of characters to NonAsciiCharacter objects
13
+ @violations = [] # List of violations (line, column, etc.)
14
+ end
15
+
16
+ def add_violation(line_number, column, match, char_details, line)
17
+ violation = {
18
+ line_number: line_number,
19
+ column: column,
20
+ match: match,
21
+ char_details: char_details,
22
+ line: line,
23
+ }
24
+
25
+ @violations << violation
26
+
27
+ # Register each character
28
+ char_details.each do |detail|
29
+ char = detail[:char]
30
+ unless @characters[char]
31
+ @characters[char] = NonAsciiCharacter.new(
32
+ char,
33
+ detail[:hex],
34
+ detail[:utf8],
35
+ detail[:is_math],
36
+ detail[:replacement],
37
+ detail[:replacement_type],
38
+ )
39
+ end
40
+
41
+ @characters[char].add_occurrence(line_number, column, line)
42
+ end
43
+ end
44
+
45
+ def violation_count
46
+ @violations.size
47
+ end
48
+
49
+ def unique_characters
50
+ @characters.values
51
+ end
52
+
53
+ def display_path
54
+ "#{File.basename(@directory)}/#{@filename}"
55
+ end
56
+
57
+ def full_path
58
+ File.expand_path(@path)
59
+ end
60
+
61
+ def to_h
62
+ {
63
+ file: display_path,
64
+ count: violation_count,
65
+ non_ascii_characters: unique_characters.map(&:to_h),
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
@@ -35,7 +35,7 @@ module Expressir
35
35
  def format_with_iso_profile(repository)
36
36
  repository.schemas.each do |schema|
37
37
  say "\n(* Expressir formatted schema: #{schema.id} *)\n"
38
- say schema.to_s(no_remarks: true)
38
+ say schema.format(no_remarks: true)
39
39
  end
40
40
  end
41
41
  end