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.
- checksums.yaml +4 -4
- data/TODO.max-perf/01-restore-ci-green.md +29 -0
- data/TODO.max-perf/02-streaming-parse-path.md +31 -0
- data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
- data/TODO.max-perf/04-benchmark-harness.md +28 -0
- data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
- data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
- data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
- data/TODO.max-perf/08-builder-build-perf.md +45 -0
- data/TODO.max-perf/09-grammar-cold-start.md +25 -0
- data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
- data/TODO.max-perf/11-ci-green-closeout.md +25 -0
- data/TODO.max-perf/12-require-boot-profile.md +25 -0
- data/TODO.max-perf/13-key-conversion-specs.md +26 -0
- data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
- data/benchmark/srl_benchmark.rb +76 -17
- data/expressir.gemspec +1 -1
- data/lib/expressir/cli.rb +3 -0
- data/lib/expressir/commands/coverage.rb +6 -2
- data/lib/expressir/commands/package.rb +4 -1
- data/lib/expressir/express/ast_key_converter.rb +114 -0
- data/lib/expressir/express/builder.rb +8 -119
- data/lib/expressir/express/error.rb +17 -0
- data/lib/expressir/express/parallel_files.rb +229 -0
- data/lib/expressir/express/parser.rb +44 -86
- data/lib/expressir/express/remark_attacher.rb +25 -7
- data/lib/expressir/express/schema_block_scanner.rb +3 -2
- data/lib/expressir/express/scope_resolver.rb +34 -5
- data/lib/expressir/express.rb +2 -0
- data/lib/expressir/model/model_element.rb +6 -1
- data/lib/expressir/model/repository.rb +18 -5
- data/lib/expressir/version.rb +1 -1
- data/lib/expressir.rb +18 -0
- 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 =
|
|
61
|
-
snake_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 =
|
|
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 =
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
#
|
|
225
|
-
|
|
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
|
|
136
|
+
yield(file, exp_file, nil) if block
|
|
112
137
|
rescue StandardError => e
|
|
113
|
-
|
|
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]
|
|
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
|
|
140
|
-
|
|
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 :
|
|
232
|
+
private_class_method :parse_files_sequentially
|
|
233
|
+
private_class_method :build_repository
|
|
276
234
|
end
|
|
277
235
|
end
|
|
278
236
|
end
|