expressir 2.4.1 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.max-perf/01-restore-ci-green.md +29 -0
  3. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  4. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  5. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  6. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  7. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  8. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  9. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  10. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  11. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  12. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  13. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  14. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  15. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  16. data/benchmark/srl_benchmark.rb +76 -17
  17. data/expressir.gemspec +1 -1
  18. data/lib/expressir/cli.rb +3 -0
  19. data/lib/expressir/commands/coverage.rb +6 -2
  20. data/lib/expressir/commands/package.rb +4 -1
  21. data/lib/expressir/express/ast_key_converter.rb +114 -0
  22. data/lib/expressir/express/builder.rb +8 -119
  23. data/lib/expressir/express/error.rb +17 -0
  24. data/lib/expressir/express/parallel_files.rb +229 -0
  25. data/lib/expressir/express/parser.rb +44 -86
  26. data/lib/expressir/express/remark_attacher.rb +25 -7
  27. data/lib/expressir/express/schema_block_scanner.rb +3 -2
  28. data/lib/expressir/express/scope_resolver.rb +34 -5
  29. data/lib/expressir/express.rb +2 -0
  30. data/lib/expressir/model/model_element.rb +6 -1
  31. data/lib/expressir/model/repository.rb +18 -5
  32. data/lib/expressir/version.rb +1 -1
  33. data/lib/expressir.rb +18 -0
  34. metadata +22 -6
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Converts native-AST CamelCase keys to the snake_case keys the
6
+ # builders consume.
7
+ #
8
+ # build() descends into child nodes whose subtrees the parent already
9
+ # converted, so unchanged containers are marked with an invisible
10
+ # instance variable and skipped on re-visits — without it, every
11
+ # subtree is re-scanned once per ancestor level (~60 convert calls per
12
+ # model node on real schemas). The marker is invisible to equality,
13
+ # hashing, and inspection.
14
+ class AstKeyConverter
15
+ SNAKED_MARKER = :@_expressir_keys_snaked
16
+ UPPERCASE_PATTERN = /[A-Z]/
17
+
18
+ class << self
19
+ # Thread-local snake_case conversion cache. Thread-local avoids the
20
+ # mutable-constant anti-pattern while remaining thread-safe. The
21
+ # cache is bounded by the number of unique AST node-type names.
22
+ def snake_case(name)
23
+ cache[name] ||= begin
24
+ str = name.to_s
25
+ if /^[a-z_]+$/.match?(str)
26
+ str.to_sym
27
+ else
28
+ str
29
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
30
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
31
+ .downcase
32
+ .to_sym
33
+ end
34
+ end
35
+ end
36
+
37
+ # Returns the original object when no conversion is needed; the
38
+ # common no-conversion case allocates nothing and the conversion
39
+ # case walks the keys once.
40
+ def convert(obj)
41
+ case obj
42
+ when Hash
43
+ return obj if obj.empty?
44
+ return obj if obj.instance_variable_defined?(SNAKED_MARKER)
45
+
46
+ keys = obj.keys
47
+ converted_values = nil
48
+ new_keys = nil
49
+
50
+ keys.each_with_index do |k, i|
51
+ val = obj[k]
52
+ case val
53
+ when Hash, Array
54
+ unless val.empty?
55
+ converted_val = convert(val)
56
+ (converted_values ||= {})[k] = converted_val unless converted_val.equal?(val)
57
+ end
58
+ end
59
+
60
+ if k.match?(UPPERCASE_PATTERN)
61
+ (new_keys ||= keys.dup)[i] = snake_case(k)
62
+ end
63
+ end
64
+
65
+ return mark_snaked(obj) unless new_keys || converted_values
66
+
67
+ result = {}
68
+ keys.each_with_index do |k, i|
69
+ key = new_keys&.[](i) || k
70
+ result[key] = converted_values&.key?(k) ? converted_values[k] : obj[k]
71
+ end
72
+ mark_snaked(result)
73
+ when Array
74
+ return obj if obj.empty?
75
+ return obj if obj.instance_variable_defined?(SNAKED_MARKER)
76
+
77
+ needs_conversion = false
78
+ result = []
79
+
80
+ obj.each do |item|
81
+ case item
82
+ when Hash, Array
83
+ next if item.empty?
84
+
85
+ converted = convert(item)
86
+ result << converted
87
+ needs_conversion = true unless converted.equal?(item)
88
+ else
89
+ result << item
90
+ end
91
+ end
92
+
93
+ needs_conversion ? mark_snaked(result) : mark_snaked(obj)
94
+ else
95
+ obj
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def cache
102
+ Thread.current[:expressir_snake_case_cache] ||= {}
103
+ end
104
+
105
+ def mark_snaked(obj)
106
+ obj.instance_variable_set(SNAKED_MARKER, true)
107
+ obj
108
+ rescue FrozenError
109
+ obj
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -28,14 +28,6 @@ module Expressir
28
28
  current_context&.include_source
29
29
  end
30
30
 
31
- # Thread-local snake_case conversion cache. Thread-local avoids the
32
- # mutable-constant anti-pattern while remaining thread-safe.
33
- # Each thread gets its own cache; the cache grows with the number of
34
- # unique AST node-type names encountered (bounded by grammar size).
35
- def snake_case_cache
36
- Thread.current[:expressir_snake_case_cache] ||= {}
37
- end
38
-
39
31
  # Register a builder for a node type.
40
32
  # @param node_type [Symbol] The AST node type
41
33
  # @param builder [#call] Optional callable that takes (ast_data)
@@ -57,8 +49,8 @@ module Expressir
57
49
  node_type = ast.keys.first
58
50
  node_data = ast[node_type]
59
51
 
60
- handler_key = cached_snake_case(node_type)
61
- snake_data = fast_convert_keys(node_data)
52
+ handler_key = AstKeyConverter.snake_case(node_type)
53
+ snake_data = AstKeyConverter.convert(node_data)
62
54
 
63
55
  builder = @register[handler_key]
64
56
  if builder
@@ -80,12 +72,12 @@ module Expressir
80
72
  ast.each_key do |key|
81
73
  next if key == node_type
82
74
 
83
- h_key = cached_snake_case(key)
75
+ h_key = AstKeyConverter.snake_case(key)
84
76
  h_builder = @register[h_key]
85
77
  next unless h_builder
86
78
 
87
79
  n_data = ast[key]
88
- s_data = fast_convert_keys(n_data)
80
+ s_data = AstKeyConverter.convert(n_data)
89
81
  result = h_builder.call(s_data)
90
82
 
91
83
  unless result.nil?
@@ -219,113 +211,10 @@ module Expressir
219
211
  builder.call(data)
220
212
  end
221
213
 
222
- private
223
-
224
- # Cached snake_case conversion
225
- def cached_snake_case(name)
226
- snake_case_cache[name] ||= begin
227
- str = name.to_s
228
- # Check if already snake_case
229
- if /^[a-z_]+$/.match?(str)
230
- str.to_sym
231
- else
232
- str
233
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
234
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
235
- .downcase
236
- .to_sym
237
- end
238
- end
239
- end
240
-
241
- # Optimized key conversion - returns original object when no conversion needed
242
- # This avoids unnecessary allocations for AST nodes that don't need key conversion
243
- def fast_convert_keys(obj)
244
- case obj
245
- when Hash
246
- return obj if obj.empty?
247
-
248
- # First pass: check if any conversion is needed
249
- keys = obj.keys
250
- needs_conversion = false
251
- converted_values = nil
252
-
253
- keys.each do |k|
254
- key_str = k.to_s
255
- # Check if key needs conversion (has uppercase)
256
- if key_str.match?(/[A-Z]/)
257
- needs_conversion = true
258
- end
259
-
260
- # Check if value needs conversion
261
- val = obj[k]
262
- case val
263
- when Hash
264
- next if val.empty?
265
-
266
- converted_val = fast_convert_keys(val)
267
- if !converted_val.equal?(val) # Identity check - same object?
268
- needs_conversion = true
269
- converted_values ||= {}
270
- converted_values[k] = converted_val
271
- end
272
- when Array
273
- next if val.empty?
274
-
275
- converted_val = fast_convert_keys(val)
276
- if !converted_val.equal?(val)
277
- needs_conversion = true
278
- converted_values ||= {}
279
- converted_values[k] = converted_val
280
- end
281
- end
282
- end
283
-
284
- # Return original if no conversion needed (zero allocation!)
285
- return obj unless needs_conversion
286
-
287
- # Build result only when necessary
288
- result = {}
289
- keys.each do |k|
290
- key_str = k.to_s
291
- new_key = key_str.match?(/[A-Z]/) ? cached_snake_case(k) : k
292
- new_val = converted_values&.key?(k) ? converted_values[k] : obj[k]
293
- result[new_key] = new_val
294
- end
295
- result
296
- when Array
297
- return obj if obj.empty?
298
-
299
- # Check if any element needs conversion
300
- needs_conversion = false
301
- result = []
302
-
303
- obj.each do |item|
304
- case item
305
- when Hash
306
- next if item.empty?
307
-
308
- converted = fast_convert_keys(item)
309
- result << converted
310
- needs_conversion = true unless converted.equal?(item)
311
- when Array
312
- next if item.empty?
313
-
314
- converted = fast_convert_keys(item)
315
- result << converted
316
- needs_conversion = true unless converted.equal?(item)
317
- else
318
- result << item
319
- end
320
- end
321
-
322
- # Return original if no conversion needed
323
- needs_conversion ? result : obj
324
- else
325
- obj
326
- end
327
- end
328
-
214
+ # Keys containing uppercase need snake-casing; testing the key
215
+ # directly avoids allocating `to_s` strings per key per pass.
216
+ # Key conversion lives in AstKeyConverter (MECE: converting AST
217
+ # keys is not building models).
329
218
  def extract_source_info(data)
330
219
  return nil unless data
331
220
 
@@ -45,6 +45,23 @@ module Expressir
45
45
  end
46
46
  end
47
47
 
48
+ # Error raised when parallel file parsing loses a worker or its data
49
+ class ParallelParseError < ExpressError
50
+ def initialize(message = "Parallel parsing failed")
51
+ super
52
+ end
53
+ end
54
+
55
+ # Error raised when streaming parsing is requested but unavailable.
56
+ # The streaming paths require a parsanol release whose
57
+ # parse_with_builder is stable; parse_fresh cannot parse EXPRESS
58
+ # without packrat memoization (see parsanol-ruby#52).
59
+ class StreamingUnsupportedError < ExpressError
60
+ def initialize(message = "Streaming parsing is not supported by the installed parsanol release")
61
+ super
62
+ end
63
+ end
64
+
48
65
  # Base class for visitor-related errors
49
66
  class VisitorError < ExpressError; end
50
67
 
@@ -0,0 +1,229 @@
1
+ require "etc"
2
+
3
+ module Expressir
4
+ module Express
5
+ # Fork-based worker pool for parsing many EXPRESS files in parallel.
6
+ # The native parser holds the GVL for the whole parse, so process-level
7
+ # parallelism is the only way to use multiple cores. Files are
8
+ # independent until reference resolution, which stays in the parent.
9
+ #
10
+ # Unlike sequential parsing, the progress block fires in file order
11
+ # only after all files have been parsed.
12
+ class ParallelFiles
13
+ DEFAULT_MAX_PROCESSES = 4
14
+ FRAME_HEADER_BYTES = 4
15
+
16
+ FORK_SUPPORTED = Process.respond_to?(:fork).freeze
17
+
18
+ # Forking is never the default: a library must not spawn processes on
19
+ # behalf of its host (forked children inherit broken thread and lock
20
+ # state, and fork does not exist on all Rubies). Parallelism requires
21
+ # an explicit max_processes > 1 from the caller and a platform that
22
+ # supports fork (e.g. not Windows); otherwise the request degrades
23
+ # to sequential parsing.
24
+ def self.sequential?(files, max_processes)
25
+ !FORK_SUPPORTED || max_processes.nil? || max_processes <= 1 ||
26
+ files.size < 3
27
+ end
28
+
29
+ # @param files [Array<String>] EXPRESS file paths
30
+ # @param max_processes [Integer, nil] worker cap; nil auto-selects
31
+ # @param parse [Proc] callback taking a file path, returning an ExpFile
32
+ # @param strict [Boolean] re-raise every error, including
33
+ # Error::SchemaParseFailure, instead of skipping the file
34
+ # @yield [file, exp_file, error] called in original file order
35
+ # @return [Array<Expressir::Model::ExpFile, nil>] parsed files in order;
36
+ # nil marks a file that failed with Error::SchemaParseFailure
37
+ def self.run(files, parse:, max_processes: nil, strict: false, &block)
38
+ new(files, max_processes, parse, block, strict).run
39
+ end
40
+
41
+ def initialize(files, max_processes, parse, block, strict)
42
+ @files = files
43
+ @parse = parse
44
+ @block = block
45
+ @strict = strict
46
+ @worker_count = [
47
+ files.size - 1,
48
+ max_processes || [Etc.nprocessors, DEFAULT_MAX_PROCESSES].min,
49
+ ].min
50
+ end
51
+
52
+ def run
53
+ job_pipes = Array.new(@worker_count) { IO.pipe }
54
+ result_pipes = Array.new(@worker_count) { IO.pipe }
55
+ pids = spawn_workers(job_pipes, result_pipes)
56
+
57
+ files_results = schedule_jobs(job_pipes, result_pipes)
58
+
59
+ ordered_pass(files_results)
60
+ ensure
61
+ cleanup(job_pipes, result_pipes, pids)
62
+ end
63
+
64
+ private
65
+
66
+ def spawn_workers(job_pipes, result_pipes)
67
+ job_pipes.each_index.map do |i|
68
+ job_r, job_w = job_pipes[i]
69
+ result_r, result_w = result_pipes[i]
70
+ fork do
71
+ job_w.close
72
+ result_r.close
73
+ other_pipes = (job_pipes + result_pipes).flatten -
74
+ [job_r, result_w]
75
+ other_pipes.each { |io| io.close unless io.closed? }
76
+ worker_loop(job_r, result_w)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Assigns one job at a time to whichever worker reports a result, so
82
+ # each file is parsed exactly once and each worker holds at most one
83
+ # in-flight job (keeping result pipes free of interleaved frames).
84
+ def schedule_jobs(job_pipes, result_pipes)
85
+ writers = job_pipes.map(&:last)
86
+ readers = result_pipes.map(&:first)
87
+ files_results = Array.new(@files.size)
88
+ next_job = 0
89
+ busy = {}
90
+
91
+ writers.each_index do |wi|
92
+ break if next_job == @files.size
93
+
94
+ dispatch(writers[wi], next_job)
95
+ busy[wi] = true
96
+ next_job += 1
97
+ end
98
+
99
+ until busy.empty?
100
+ ready, = IO.select(readers.values_at(*busy.keys))
101
+ ready.each do |io|
102
+ wi = readers.index(io)
103
+ payload = Marshal.load(read_frame(io)) # rubocop:disable Security/MarshalLoad
104
+ files_results[payload[:index]] = payload
105
+
106
+ if next_job < @files.size
107
+ dispatch(writers[wi], next_job)
108
+ next_job += 1
109
+ else
110
+ writers[wi].close unless writers[wi].closed?
111
+ busy.delete(wi)
112
+ end
113
+ end
114
+ end
115
+
116
+ files_results
117
+ end
118
+
119
+ def dispatch(writer, job_index)
120
+ write_frame(writer, pack_frame(Marshal.dump([job_index, @files[job_index]])))
121
+ end
122
+
123
+ def worker_loop(job_r, result_w)
124
+ until job_r.eof?
125
+ data = read_frame(job_r)
126
+ index, file = Marshal.load(data) # rubocop:disable Security/MarshalLoad
127
+ begin
128
+ payload = { index: index, exp_file: @parse.call(file) }
129
+ rescue StandardError => e
130
+ payload = { index: index, error: transferable_error(e) }
131
+ end
132
+ write_frame(result_w, pack_frame(Marshal.dump(payload)))
133
+ end
134
+ rescue Errno::EPIPE
135
+ # parent went away; nothing to report to
136
+ exit!(0)
137
+ end
138
+
139
+ # SIGTERM cannot interrupt a worker blocked in the native parser (the
140
+ # GVL is held), so escalate to SIGKILL after a grace period instead of
141
+ # blocking in waitpid forever.
142
+ def cleanup(job_pipes, result_pipes, pids)
143
+ (job_pipes.to_a + result_pipes.to_a).each do |r, w|
144
+ r.close unless r.closed?
145
+ w.close unless w.closed?
146
+ end
147
+ pids.to_a.each { |pid| Process.kill("TERM", pid) if alive?(pid) }
148
+
149
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 2
150
+ pids.to_a.each do |pid|
151
+ while alive?(pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
152
+ Process.waitpid(pid, Process::WNOHANG)
153
+ sleep 0.05
154
+ end
155
+ Process.kill("KILL", pid) if alive?(pid)
156
+ reap(pid)
157
+ end
158
+ end
159
+
160
+ def ordered_pass(files_results)
161
+ files_results.each_with_index do |payload, index|
162
+ error = payload[:error]
163
+ @block&.call(@files[index], payload[:exp_file], error)
164
+ raise error if error && (@strict ||
165
+ !error.is_a?(Error::SchemaParseFailure))
166
+ end
167
+
168
+ files_results.map do |payload|
169
+ payload[:error] ? nil : payload[:exp_file]
170
+ end
171
+ end
172
+
173
+ # Errors carrying native-parser state (e.g. Parsanol::ParseFailed with
174
+ # its cause tree) cannot cross a fork boundary; rebuild them without
175
+ # the untransferable internals, preserving class and message.
176
+ def transferable_error(error)
177
+ Marshal.dump(error)
178
+ error
179
+ rescue StandardError
180
+ if error.is_a?(Error::SchemaParseFailure)
181
+ Error::SchemaParseFailure.new(error.filename,
182
+ StandardError.new(error.message))
183
+ else
184
+ StandardError.new(error.message)
185
+ end
186
+ end
187
+
188
+ def pack_frame(data)
189
+ [data.bytesize].pack("N") + data
190
+ end
191
+
192
+ def write_frame(io, frame)
193
+ io.write(frame)
194
+ end
195
+
196
+ def read_frame(io)
197
+ header = read_exactly(io, FRAME_HEADER_BYTES)
198
+ read_exactly(io, header.unpack1("N"))
199
+ end
200
+
201
+ def read_exactly(io, count)
202
+ data = +""
203
+ while data.bytesize < count
204
+ chunk = io.read(count - data.bytesize)
205
+ unless chunk
206
+ raise Error::ParallelParseError,
207
+ "worker exited before sending its result"
208
+ end
209
+
210
+ data << chunk
211
+ end
212
+ data
213
+ end
214
+
215
+ def alive?(pid)
216
+ Process.kill(0, pid)
217
+ true
218
+ rescue Errno::ESRCH, Errno::EPERM
219
+ false
220
+ end
221
+
222
+ def reap(pid)
223
+ Process.waitpid(pid)
224
+ rescue Errno::ECHILD, Errno::EINVAL
225
+ nil
226
+ end
227
+ end
228
+ end
229
+ end
@@ -100,7 +100,32 @@ module Expressir
100
100
  # @yield [filename, schemas, error] Optional block called for each file
101
101
  # @return [Model::Repository] Repository containing all parsed ExpFiles
102
102
  def self.from_files(files, skip_references: nil, include_source: nil,
103
- root_path: nil, use_native: nil)
103
+ root_path: nil, use_native: nil, max_processes: nil, &progress)
104
+ all_exp_files = if ParallelFiles.sequential?(files, max_processes)
105
+ parse_files_sequentially(
106
+ files, skip_references: skip_references, include_source: include_source,
107
+ root_path: root_path, use_native: use_native
108
+ ) do |file, exp_file, error|
109
+ progress&.call(file, exp_file&.schemas, error)
110
+ end
111
+ else
112
+ ParallelFiles.run(
113
+ files,
114
+ max_processes: max_processes,
115
+ parse: lambda do |file|
116
+ from_file(file, skip_references: true, root_path: root_path,
117
+ use_native: use_native)
118
+ end,
119
+ ) do |file, exp_file, error|
120
+ progress&.call(file, exp_file&.schemas, error)
121
+ end
122
+ end
123
+
124
+ build_repository(all_exp_files, skip_references: skip_references)
125
+ end
126
+
127
+ def self.parse_files_sequentially(files, skip_references: nil,
128
+ include_source: nil, root_path: nil, use_native: nil, &block)
104
129
  all_exp_files = []
105
130
 
106
131
  files.each do |file|
@@ -108,12 +133,19 @@ root_path: nil, use_native: nil)
108
133
  root_path: root_path, use_native: use_native)
109
134
  all_exp_files << exp_file
110
135
 
111
- yield(file, exp_file&.schemas, nil) if block_given?
136
+ yield(file, exp_file, nil) if block
112
137
  rescue StandardError => e
113
- yield(file, nil, e) if block_given?
138
+ # Nil-pad so results align with files by index, exactly like the
139
+ # parallel path does.
140
+ all_exp_files << nil if e.is_a?(Error::SchemaParseFailure)
141
+ yield(file, nil, e) if block
114
142
  raise unless e.is_a?(Error::SchemaParseFailure)
115
143
  end
116
144
 
145
+ all_exp_files
146
+ end
147
+
148
+ def self.build_repository(all_exp_files, skip_references: nil)
117
149
  repository = Model::Repository.new(files: all_exp_files)
118
150
 
119
151
  unless skip_references
@@ -130,15 +162,18 @@ root_path: nil, use_native: nil)
130
162
  # @param skip_references [Boolean] skip resolving references
131
163
  # @param include_source [Boolean] attach original source code to model elements
132
164
  # @param use_native [Boolean] use native parser (default: true when available)
133
- # @param use_streaming [Boolean] use streaming builder for maximum performance
165
+ # @param use_streaming [Boolean] unsupported on current parsanol;
166
+ # passing true raises {Error::StreamingUnsupportedError}. The
167
+ # streaming paths return when parsanol exposes a stable
168
+ # parse_with_builder (see parsanol-ruby#52 and the TODO.max-perf/02
169
+ # notes).
134
170
  # @return [Model::ExpFile] Parsed ExpFile
135
171
  # @raise [Error::SchemaParseFailure] if the content fails to parse
136
172
  def self.from_exp(content, skip_references: nil, include_source: nil,
137
173
  use_native: nil, use_streaming: false)
138
174
  content = strip_bom(content)
139
- if use_streaming && Grammar::Parser.native_available? && defined?(Parsanol::Native.parse_with_builder)
140
- return from_exp_streaming(content, skip_references: skip_references,
141
- include_source: include_source)
175
+ if use_streaming
176
+ raise Error::StreamingUnsupportedError
142
177
  end
143
178
 
144
179
  use_native = Grammar::Parser.native_available? if use_native.nil?
@@ -173,83 +208,6 @@ root_path: nil, use_native: nil)
173
208
  exp_file
174
209
  end
175
210
 
176
- # Parse using streaming builder (construct-by-construct).
177
- # @param content [String] EXPRESS source code
178
- # @param skip_references [Boolean] skip resolving references
179
- # @param include_source [Boolean] attach original source code to model elements
180
- # @return [Model::ExpFile] Parsed ExpFile
181
- # @raise [Error::SchemaParseFailure] if the content fails to parse
182
- def self.from_exp_streaming_builder(content, skip_references: nil,
183
- include_source: nil)
184
- grammar_json = Grammar::Parser.cached_grammar_json
185
- builder = ::Expressir::Express::StreamingBuilder.new(source: content,
186
- include_source: include_source)
187
-
188
- begin
189
- exp_file = Parsanol::Native.parse_with_builder(grammar_json,
190
- content, builder)
191
- rescue StandardError => e
192
- raise Error::SchemaParseFailure.new("(streaming)", e)
193
- end
194
-
195
- exp_file.schemas.each do |schema|
196
- schema.file = nil
197
- schema.file_basename = nil
198
- end
199
-
200
- unless skip_references
201
- Expressir::Benchmark.measure_references do
202
- ResolveReferencesModelVisitor.new.visit(exp_file)
203
- end
204
- end
205
-
206
- exp_file
207
- end
208
-
209
- # Parse each schema separately with fresh arena (memory-bounded).
210
- #
211
- # Splits source into schema blocks via {SchemaBlockScanner} and parses
212
- # each independently. Memory is bounded by the largest schema, not the
213
- # entire file.
214
- #
215
- # @param content [String] EXPRESS source code
216
- # @param skip_references [Boolean] skip resolving references
217
- # @param include_source [Boolean] attach original source code to model elements
218
- # @return [Model::ExpFile] Parsed ExpFile
219
- def self.from_exp_streaming(content, skip_references: nil,
220
- include_source: nil)
221
- grammar_json = Grammar::Parser.cached_schema_grammar_json
222
-
223
- schema_blocks = SchemaBlockScanner.extract_schema_blocks(content)
224
-
225
- schemas = schema_blocks.map do |block|
226
- ast = Parsanol::Native.parse_fresh(grammar_json, block[:source])
227
- schema_model = Builder.build(ast)
228
- schema_model.source = block[:source]
229
- schema_model
230
- rescue StandardError => e
231
- raise Error::SchemaParseFailure.new(
232
- "(schema #{block[:name] || 'unknown'})", e
233
- )
234
- end
235
-
236
- exp_file = Expressir::Model::ExpFile.new
237
- exp_file.schemas = schemas
238
-
239
- exp_file.schemas.each do |schema|
240
- schema.file = nil
241
- schema.file_basename = nil
242
- end
243
-
244
- unless skip_references
245
- Expressir::Benchmark.measure_references do
246
- ResolveReferencesModelVisitor.new.visit(exp_file)
247
- end
248
- end
249
-
250
- exp_file
251
- end
252
-
253
211
  # Transfer file-level untagged remarks that appear before the first
254
212
  # SCHEMA keyword to the first schema's +header+ attribute so they are
255
213
  # accessible via +schema.header+ and through Liquid drops.
@@ -271,8 +229,8 @@ include_source: nil)
271
229
  exp_file.untagged_remarks -= header_remarks
272
230
  end
273
231
  private_class_method :transfer_header_to_schema
274
-
275
- private_class_method :from_exp_streaming
232
+ private_class_method :parse_files_sequentially
233
+ private_class_method :build_repository
276
234
  end
277
235
  end
278
236
  end