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.
- checksums.yaml +4 -4
- data/TODO.max-perf/01-restore-ci-green.md +36 -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/node_position_index.rb +133 -20
- data/lib/expressir/express/parallel_files.rb +229 -0
- data/lib/expressir/express/parser.rb +44 -86
- data/lib/expressir/express/remark_attacher.rb +34 -20
- 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 +3 -1
- 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
|
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|