expressir 2.4.1 → 2.4.3

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.max-perf/01-restore-ci-green.md +36 -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/node_position_index.rb +133 -20
  25. data/lib/expressir/express/parallel_files.rb +229 -0
  26. data/lib/expressir/express/parser.rb +44 -86
  27. data/lib/expressir/express/remark_attacher.rb +34 -20
  28. data/lib/expressir/express/schema_block_scanner.rb +3 -2
  29. data/lib/expressir/express/scope_resolver.rb +34 -5
  30. data/lib/expressir/express.rb +2 -0
  31. data/lib/expressir/model/model_element.rb +6 -1
  32. data/lib/expressir/model/repository.rb +18 -5
  33. data/lib/expressir/version.rb +1 -1
  34. data/lib/expressir.rb +3 -1
  35. 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
 
@@ -13,6 +13,9 @@ module Expressir
13
13
  # instances; this module turns those spans into a line-keyed index that
14
14
  # remark attachment can query.
15
15
  class NodePositionIndex
16
+ EMPTY = [].freeze
17
+ private_constant :EMPTY
18
+
16
19
  # Single source of truth for "what collections does this node have"
17
20
  # lives on the model — each class declares its own via the
18
21
  # `collection_attributes` macro. Referenced by both RemarkAttacher
@@ -21,31 +24,133 @@ module Expressir
21
24
 
22
25
  attr_reader :nodes
23
26
 
27
+ # Semantic entries starting on `line` (node order).
28
+ def starting_at(line)
29
+ semantic_by_line[line] || EMPTY
30
+ end
31
+
32
+ # Semantic entries ending on `line` (node order).
33
+ def ending_at(line)
34
+ semantic_by_end_line[line] || EMPTY
35
+ end
36
+
37
+ # Semantic entries whose span covers `line` (node order within the
38
+ # covering band).
39
+ def spanning(line)
40
+ band = line / BAND
41
+ bands = span_bands
42
+ return EMPTY unless bands.key?(band)
43
+
44
+ bands[band].select { |n| line.between?(n[:line], n[:end_line]) }
45
+ end
46
+
47
+ # Semantic entries with end_line < line, ascending by end_line.
48
+ def semantic_ending_before(line)
49
+ sorted = semantic_by_end_line_list
50
+ idx = sorted.bsearch_index { |n| n[:end_line] >= line } || sorted.size
51
+ sorted[0...idx]
52
+ end
53
+
54
+ # The first semantic entry (smallest line), for preamble remarks.
55
+ def first_semantic
56
+ first_by_line = semantic_by_line.keys.min
57
+ first_by_line && semantic_by_line[first_by_line]&.first
58
+ end
59
+
60
+ # Entries owned by `owner` within any of `collections`, in node order.
61
+ # Identity-keyed: ownership is object identity (the tree-walker's
62
+ # `.equal?` semantics), and value-equal model objects must not
63
+ # collide.
64
+ def children_in(owner, collections)
65
+ per_collection = children_index[owner]
66
+ return EMPTY unless per_collection
67
+
68
+ entries = per_collection.values_at(*collections).compact.flatten(1)
69
+ entries.sort_by { |n| node_order(n) }
70
+ end
71
+
72
+ # First node (in node order) that is_a?(type), memoized per type.
73
+ def first_node_of_type(type)
74
+ @first_of_type ||= {}
75
+ @first_of_type[type] ||= @nodes.find { |n| n[:node].is_a?(type) }&.dig(:node)
76
+ end
77
+
78
+ def semantic_entries
79
+ @semantic_entries ||=
80
+ @nodes.select { |n| n[:line] && semantic?(n[:node]) }
81
+ end
82
+
83
+ # Non-vivifying: missed lookups must not materialize keys, or
84
+ # `keys.min`-style queries see phantom lines.
85
+ def semantic_by_line
86
+ @semantic_by_line ||= begin
87
+ h = {}
88
+ semantic_entries.each { |n| (h[n[:line]] ||= []) << n }
89
+ h
90
+ end
91
+ end
92
+
93
+ def semantic_by_end_line
94
+ @semantic_by_end_line ||= begin
95
+ h = {}
96
+ semantic_entries.each { |n| (h[n[:end_line]] ||= []) << n if n[:end_line] }
97
+ h
98
+ end
99
+ end
100
+
101
+ def semantic_by_end_line_list
102
+ @semantic_by_end_line_list ||=
103
+ semantic_entries.select { |n| n[:end_line] }.sort_by { |n| n[:end_line] }
104
+ end
105
+
106
+ def children_index
107
+ @children_index ||= begin
108
+ index = {}.compare_by_identity
109
+ @nodes.each do |n|
110
+ next unless n[:owner] && n[:line]
111
+
112
+ per_owner = (index[n[:owner]] ||= {})
113
+ (per_owner[n[:collection]] ||= []) << n
114
+ end
115
+ index
116
+ end
117
+ end
118
+
119
+ def span_bands
120
+ @span_bands ||= begin
121
+ bands = Hash.new { |hash, key| hash[key] = [] }
122
+ semantic_entries.each do |n|
123
+ next unless n[:end_line]
124
+
125
+ ((n[:line] / BAND)..(n[:end_line] / BAND)).each do |band|
126
+ bands[band] << n
127
+ end
128
+ end
129
+ bands
130
+ end
131
+ end
132
+
133
+ BAND = 1024
134
+ private_constant :BAND
135
+
24
136
  def initialize(model, line_map)
25
137
  @model = model
26
138
  @line_map = line_map
27
139
  @nodes = build_sorted_nodes
140
+ @node_order = nil
28
141
  end
29
142
 
30
143
  # Returns the most-specific node whose span contains `remark_line`,
31
144
  # preferring same-line starts/ends, then smallest containing span.
32
145
  # Excludes Repository and Cache (not semantic scopes for remarks).
33
146
  def nearest_node_to(remark_line)
34
- same_start = nodes.select do |n|
35
- n[:line] == remark_line && semantic?(n[:node])
36
- end
147
+ same_start = starting_at(remark_line)
37
148
  return same_start.last[:node] if same_start.any?
38
149
 
39
- same_end = nodes.select do |n|
40
- n[:end_line] == remark_line && semantic?(n[:node])
41
- end
150
+ same_end = ending_at(remark_line)
42
151
  return same_end.last[:node] if same_end.any?
43
152
 
44
- containing = nodes.select do |n|
45
- n[:line] && n[:end_line] &&
46
- n[:line] <= remark_line && n[:end_line] >= remark_line &&
47
- semantic?(n[:node])
48
- end
153
+ containing = spanning(remark_line)
49
154
 
50
155
  if containing.any?
51
156
  exp_file_node = containing.find { |n| n[:node].is_a?(Model::ExpFile) }
@@ -62,16 +167,13 @@ module Expressir
62
167
  candidates = containing if candidates.empty?
63
168
  candidates.min_by { |n| n[:end_line] - n[:line] }[:node]
64
169
  else
65
- before = nodes.select do |n|
66
- n[:end_line] && n[:end_line] < remark_line && semantic?(n[:node])
67
- end
170
+ before = semantic_ending_before(remark_line)
68
171
  if before.any?
69
172
  before.max_by { |n| n[:end_line] }[:node]
70
173
  else
71
174
  # Remark is before all nodes (e.g., preamble comment before SCHEMA).
72
175
  # Attach to the first semantic node.
73
- after = nodes.select { |n| n[:line] && semantic?(n[:node]) }
74
- after.min_by { |n| n[:line] }[:node] if after.any?
176
+ first_semantic&.dig(:node)
75
177
  end
76
178
  end
77
179
  end
@@ -90,17 +192,28 @@ module Expressir
90
192
  end
91
193
  return nil unless node_type
92
194
 
93
- matching = nodes.select do |n|
195
+ candidates = (0..2).flat_map do |back|
196
+ ending_at(remark_line - back)
197
+ end.select do |n|
94
198
  n[:node].is_a?(node_type) &&
95
- (n[:end_line] == remark_line ||
96
- (n[:end_line] && n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2))
199
+ n[:end_line] <= remark_line && n[:end_line] >= remark_line - 2
97
200
  end
98
201
 
99
- matching.first&.dig(:node) || nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
202
+ candidates.min_by { |n| node_order(n) }&.dig(:node) ||
203
+ first_node_of_type(node_type)
100
204
  end
101
205
 
102
206
  private
103
207
 
208
+ def node_order(entry)
209
+ @node_order ||= begin
210
+ map = {}.compare_by_identity
211
+ @nodes.each_with_index { |n, i| map[n] = i }
212
+ map
213
+ end
214
+ @node_order[entry]
215
+ end
216
+
104
217
  def semantic?(node)
105
218
  !node.is_a?(Model::Repository) && !node.is_a?(Model::Cache)
106
219
  end