expressir 2.3.7 → 2.4.1

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 (110) 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/docs/_tutorials/formatting-schemas.adoc +89 -0
  34. data/docs/_tutorials/index.adoc +10 -0
  35. data/docs/lychee.toml +3 -0
  36. data/expressir.gemspec +4 -2
  37. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  38. data/lib/expressir/commands/changes_validate.rb +0 -2
  39. data/lib/expressir/commands/clean.rb +1 -1
  40. data/lib/expressir/commands/file_violations.rb +70 -0
  41. data/lib/expressir/commands/format.rb +1 -1
  42. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  43. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  44. data/lib/expressir/commands/package.rb +138 -185
  45. data/lib/expressir/commands/validate_ascii.rb +0 -510
  46. data/lib/expressir/commands/version.rb +1 -1
  47. data/lib/expressir/commands.rb +3 -0
  48. data/lib/expressir/coverage.rb +49 -117
  49. data/lib/expressir/express/builder.rb +74 -86
  50. data/lib/expressir/express/builder_context.rb +22 -0
  51. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  52. data/lib/expressir/express/cache.rb +35 -8
  53. data/lib/expressir/express/error.rb +7 -0
  54. data/lib/expressir/express/formatter.rb +33 -18
  55. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  56. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  57. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  58. data/lib/expressir/express/grammar/parser.rb +705 -0
  59. data/lib/expressir/express/grammar.rb +11 -0
  60. data/lib/expressir/express/line_map.rb +48 -0
  61. data/lib/expressir/express/node_position_index.rb +215 -0
  62. data/lib/expressir/express/parser.rb +114 -884
  63. data/lib/expressir/express/pretty_formatter.rb +23 -5
  64. data/lib/expressir/express/remark_attacher.rb +483 -669
  65. data/lib/expressir/express/remark_scanner.rb +245 -0
  66. data/lib/expressir/express/schema_block_scanner.rb +136 -0
  67. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  68. data/lib/expressir/express/scope_resolver.rb +194 -0
  69. data/lib/expressir/express/source_formatter.rb +15 -0
  70. data/lib/expressir/express/streaming_builder.rb +147 -176
  71. data/lib/expressir/express.rb +10 -6
  72. data/lib/expressir/model/concerns.rb +3 -0
  73. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  74. data/lib/expressir/model/declarations/entity.rb +5 -0
  75. data/lib/expressir/model/declarations/function.rb +5 -0
  76. data/lib/expressir/model/declarations/procedure.rb +5 -0
  77. data/lib/expressir/model/declarations/rule.rb +6 -0
  78. data/lib/expressir/model/declarations/schema.rb +21 -8
  79. data/lib/expressir/model/declarations/type.rb +3 -0
  80. data/lib/expressir/model/exp_file.rb +2 -0
  81. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  82. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  83. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  84. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  85. data/lib/expressir/model/expressions/function_call.rb +1 -0
  86. data/lib/expressir/model/expressions/interval.rb +1 -0
  87. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  88. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  89. data/lib/expressir/model/model_element.rb +128 -14
  90. data/lib/expressir/model/remark_format.rb +17 -0
  91. data/lib/expressir/model/remark_info.rb +31 -3
  92. data/lib/expressir/model/remark_placement.rb +29 -0
  93. data/lib/expressir/model/statements/alias.rb +2 -0
  94. data/lib/expressir/model/statements/assignment.rb +4 -0
  95. data/lib/expressir/model/statements/case.rb +21 -0
  96. data/lib/expressir/model/statements/case_action.rb +1 -0
  97. data/lib/expressir/model/statements/compound.rb +4 -0
  98. data/lib/expressir/model/statements/escape.rb +3 -0
  99. data/lib/expressir/model/statements/if.rb +5 -0
  100. data/lib/expressir/model/statements/null.rb +3 -0
  101. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  102. data/lib/expressir/model/statements/repeat.rb +3 -0
  103. data/lib/expressir/model/statements/return.rb +3 -0
  104. data/lib/expressir/model/statements/skip.rb +3 -0
  105. data/lib/expressir/model.rb +12 -3
  106. data/lib/expressir/package/builder.rb +2 -2
  107. data/lib/expressir/version.rb +6 -1
  108. data/lib/expressir.rb +38 -3
  109. metadata +65 -5
  110. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -4,6 +4,14 @@ module Expressir
4
4
  module Express
5
5
  # Handles attaching remarks (comments) to model elements after parsing.
6
6
  #
7
+ # Two collaborators sit behind the `attach` interface:
8
+ # - {ScopeResolver} answers "which scope contains line N?"
9
+ # - {NodePositionIndex} answers "which model node is nearest line N?"
10
+ #
11
+ # Remark scanning itself lives in {RemarkScanner}; line→byte lookup lives
12
+ # in {LineMap}. This class is the orchestrator: it walks the remarks, asks
13
+ # the collaborators for targets, and writes the remarks onto the model.
14
+ #
7
15
  # NOTE: Post-processing remark attachment has inherent limitations for scope-based
8
16
  # matching. Remarks with simple tags (like "WR1") inside scopes (TYPE, ENTITY, etc.)
9
17
  # cannot be perfectly matched without parsing context. This implementation prioritizes:
@@ -12,192 +20,78 @@ module Expressir
12
20
  # 3. NOT creating spurious schema-level items for ambiguous tags
13
21
  class RemarkAttacher
14
22
  # Type-driven registry: maps each model class to its collection attributes.
15
- # Replaces runtime method probing (method_defined?) with explicit type declarations.
16
- COLLECTION_REGISTRY = {
17
- Model::Declarations::Schema => %i[
18
- constants types entities subtype_constraints
19
- functions rules procedures remark_items
20
- ],
21
- Model::Declarations::Entity => %i[
22
- attributes derived_attributes inverse_attributes
23
- unique_rules where_rules informal_propositions remark_items
24
- ],
25
- Model::Declarations::Function => %i[
26
- parameters types entities subtype_constraints
27
- functions procedures constants variables statements remark_items
28
- ],
29
- Model::Declarations::Procedure => %i[
30
- parameters types entities subtype_constraints
31
- functions procedures constants variables statements remark_items
32
- ],
33
- Model::Declarations::Rule => %i[
34
- applies_to types entities subtype_constraints
35
- functions procedures constants variables statements
36
- where_rules informal_propositions remark_items
37
- ],
38
- Model::Declarations::Type => %i[
39
- where_rules informal_propositions remark_items
40
- ],
41
- Model::ExpFile => %i[schemas],
42
- Model::Statements::Compound => %i[statements],
43
- Model::Statements::If => %i[statements],
44
- Model::Statements::Alias => %i[statements],
45
- Model::Statements::Repeat => %i[statements],
46
- }.freeze
23
+ # Shared with {NodePositionIndex} via that class's own copy of the table.
24
+ # Two declarations rather than a cross-reference so each module is loadable
25
+ # on its own without forcing the other to load.
26
+ COLLECTION_REGISTRY = NodePositionIndex::COLLECTION_REGISTRY
27
+
28
+ # Collections holding executable statements — the regions a body
29
+ # comment can belong to.
30
+ STATEMENT_REGIONS = %i[
31
+ statements else_statements action_statements otherwise_statements
32
+ ].freeze
33
+
34
+ # Expression and statement child attributes are declared on the model
35
+ # via `child_attributes :foo, :bar, ...`. See TODO.bugs/15.
36
+ EXPRESSION_CHILDREN = Model::ModelElement.child_attributes_registry
47
37
 
48
38
  def initialize(source)
49
39
  @source = source
50
40
  @attached_spans = Set.new
51
- @line_cache = {}
41
+ @line_map = LineMap.new(source.b)
52
42
  @model = nil
53
- @source_lines = nil # cached @source.lines
54
- @scope_map = nil # cached scope at each line number
43
+ @scope_resolver = nil
44
+ @node_index = nil
45
+ @owner_map = nil
46
+ @active_scope_map = nil
55
47
  end
56
48
 
57
49
  def attach(model)
58
50
  @model = model
59
- remarks = extract_all_remarks
60
-
61
- # Build nodes_with_positions ONCE for both tagged and untagged remark passes.
62
- # This avoids double tree walk (381K nodes × 2 = 762K visits) which was
63
- # the largest memory overhead in remark attachment (~430MB for large files).
64
- nodes_with_positions = build_sorted_nodes_with_positions(model)
51
+ remarks = RemarkScanner.new(@source).scan
65
52
 
66
- attach_tagged_remarks(model, remarks, nodes_with_positions)
67
- attach_untagged_remarks(remarks, nodes_with_positions)
53
+ @node_index = NodePositionIndex.new(model, @line_map)
54
+ @scope_resolver = ScopeResolver.new(
55
+ source: @source,
56
+ model: model,
57
+ nodes_with_positions: @node_index.nodes,
58
+ )
68
59
 
69
- # Free expensive data structures after attachment is complete.
70
- # These are only needed during the attach process.
71
- @source = nil
72
- @source_lines = nil
73
- @scope_map = nil
74
- @line_cache = nil
60
+ attach_tagged_remarks(remarks)
61
+ attach_untagged_remarks(remarks)
75
62
 
76
63
  model
64
+ ensure
65
+ # Free expensive data structures once attachment is over. On the
66
+ # raising path this also drops the memoized ownership map, which
67
+ # would otherwise outlive the node index it was derived from.
68
+ @source = nil
69
+ @scope_resolver = nil
70
+ @node_index = nil
71
+ @line_map = nil
72
+ @owner_map = nil
73
+ @active_scope_map = nil
77
74
  end
78
75
 
79
76
  private
80
77
 
81
- def extract_all_remarks
82
- remarks = []
83
- byte_position = 0
84
-
85
- @source.each_line.with_index do |line, line_idx|
86
- line_bytesize = line.bytesize
87
- line_bytes = line.b # Get byte string for indexing
88
- if (dash_byte_idx = line_bytes.index("--"))
89
- remark_text = line.byteslice((dash_byte_idx + 2)..).strip
90
-
91
- # Check for special patterns like --IP1: content (informal proposition)
92
- if remark_text.match?(/^IP\d+:\s*(.*)$/)
93
- tag = remark_text[/^(IP\d+):/, 1]
94
- content = remark_text[/^IP\d+:\s*(.*)$/, 1]
95
- remarks << {
96
- position: byte_position + dash_byte_idx,
97
- line: line_idx + 1,
98
- text: content,
99
- tag: tag,
100
- format: "tail",
101
- }
102
- else
103
- tag, content = parse_tagged_remark(remark_text)
104
- remarks << {
105
- position: byte_position + dash_byte_idx,
106
- line: line_idx + 1,
107
- text: content || remark_text,
108
- tag: tag,
109
- format: "tail",
110
- }
111
- end
112
- end
113
- byte_position += line_bytesize
114
- end
115
-
116
- extract_embedded_remarks(remarks)
117
- # Sort by position to ensure remarks are processed in source order
118
- remarks.sort_by! { |r| r[:position] }
119
- remarks
120
- end
121
-
122
- def extract_embedded_remarks(remarks)
123
- source_bytes = @source.b
124
- start_pos = 0
125
- while (start_idx = source_bytes.index("(*", start_pos))
126
- end_idx = source_bytes.index("*)", start_idx + 2)
127
- break unless end_idx
128
-
129
- content = @source.byteslice((start_idx + 2)...end_idx)
130
- line_num = get_line_number(start_idx)
131
-
132
- tag, text = parse_tagged_embedded_remark(content)
133
-
134
- remarks << {
135
- position: start_idx,
136
- line: line_num,
137
- text: text,
138
- tag: tag,
139
- format: "embedded",
140
- }
141
-
142
- start_pos = end_idx + 2
143
- end
144
- end
145
-
146
- def parse_tagged_remark(text)
147
- if text.start_with?('"') && (end_quote = text.index('"', 1))
148
- [text[1...end_quote], text[(end_quote + 1)..].strip]
149
- else
150
- [nil, text]
151
- end
152
- end
153
-
154
- def parse_tagged_embedded_remark(content)
155
- stripped = content.strip
156
- if stripped.start_with?('"') && (end_quote = stripped.index('"', 1))
157
- [stripped[1...end_quote], stripped[(end_quote + 1)..].strip]
158
- else
159
- [nil, stripped]
160
- end
161
- end
162
-
163
- def source_lines
164
- @source_lines ||= @source.lines
165
- end
166
-
167
- def get_line_number(position)
168
- return 1 if position.nil? || position.zero?
78
+ # ----- Tagged remark attachment -----
169
79
 
170
- @line_cache[position] ||= @source.byteslice(0...position).count("\n") + 1
171
- end
172
-
173
- def attach_tagged_remarks(model, remarks, nodes_with_positions)
174
- tagged = remarks.select { |r| r[:tag] }
80
+ def attach_tagged_remarks(remarks)
81
+ tagged = remarks.select(&:tag)
175
82
  return if tagged.empty?
176
83
 
177
- @model = model
178
-
179
- # Build scope map ONCE: O(file_lines) scan instead of O(n*file_lines) for n remarks
180
- # This is the key optimization that makes scope lookup O(1) per remark
181
- @scope_map ||= build_scope_map
84
+ tagged.each do |remark|
85
+ next if @attached_spans.include?(remark.position)
182
86
 
183
- tagged.sort_by { |r| r[:position] }.each do |remark|
184
- next if @attached_spans.include?(remark[:position])
185
-
186
- tag = remark[:tag]
87
+ tag = remark.tag
187
88
  target = nil
188
89
 
189
- # Find containing scope using pre-computed scope map (O(1))
190
- # Falls back to position-based lookup if scope map doesn't have the line
191
- containing_scope = find_containing_scope_by_name(remark[:line])
192
- containing_scope ||= find_containing_scope_position(remark[:line],
193
- nodes_with_positions)
90
+ containing_scope = @scope_resolver.containing_scope_for(remark.line)
194
91
 
195
92
  # Check if this is an informal proposition tag (IP\d+)
196
93
  if tag.match?(/^IP\d+$/)
197
- scope = containing_scope
198
- if scope.nil?
199
- scope = find_scope_by_source_text(remark[:line])
200
- end
94
+ scope = containing_scope || @scope_resolver.find_by_source_text(remark.line)
201
95
  if scope && supports_informal_propositions?(scope)
202
96
  target = create_or_find_informal_proposition(scope, tag)
203
97
  end
@@ -207,31 +101,28 @@ module Expressir
207
101
  if target.nil?
208
102
  # Handle prefixed tags like wr:WR1, ip:IP1, ur:UR1
209
103
  if tag.include?(":") && !tag.include?(".")
210
- target = handle_prefixed_tag(tag, containing_scope, model,
211
- get_schema_ids(model))
104
+ target = handle_prefixed_tag(tag, containing_scope, @model,
105
+ get_schema_ids(@model))
212
106
  end
213
107
 
214
108
  # Strategy 1: Try exact path lookup
215
109
  if target.nil?
216
- target = find_by_exact_path(model, tag)
110
+ target = find_by_exact_path(@model, tag)
217
111
  end
218
112
 
219
113
  # Strategy 1b: For paths with dots, try with scope path prefix first
220
114
  if target.nil? && tag.include?(".")
221
- # First, try building full path from containing scope
222
115
  if containing_scope && function_rule_procedure?(containing_scope)
223
116
  scope_path = build_scope_path(containing_scope)
224
117
  if scope_path
225
- full_path = "#{scope_path}.#{tag}"
226
- target = find_by_exact_path(model, full_path)
118
+ target = find_by_exact_path(@model, "#{scope_path}.#{tag}")
227
119
  end
228
120
  end
229
121
 
230
- # Then try schema prefix
231
122
  if target.nil?
232
- schema_ids = get_schema_ids(model)
123
+ schema_ids = get_schema_ids(@model)
233
124
  schema_ids.each do |schema_id|
234
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
125
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
235
126
  break if target
236
127
  end
237
128
  end
@@ -240,28 +131,24 @@ module Expressir
240
131
  # Strategy 2: For simple tags, find in containing scope first
241
132
  if target.nil? && !tag.include?(".")
242
133
  if containing_scope
243
- # Search within the containing scope
244
134
  target = find_node_in_scope(containing_scope, tag)
245
135
 
246
- # Special handling for remarks inside WHERE clauses
247
136
  if target.nil? && supports_where_rules?(containing_scope)
248
137
  target = find_target_in_where_clause(containing_scope, tag,
249
- remark[:line])
138
+ remark.line)
250
139
  end
251
140
 
252
- # Only fall back to schema prefix if NOT inside a function/rule/procedure
253
141
  if target.nil? && !function_rule_procedure?(containing_scope)
254
- schema_ids = get_schema_ids(model)
142
+ schema_ids = get_schema_ids(@model)
255
143
  schema_ids.each do |schema_id|
256
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
144
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
257
145
  break if target
258
146
  end
259
147
  end
260
148
  else
261
- # No containing scope, try with schema prefix
262
- schema_ids = get_schema_ids(model)
149
+ schema_ids = get_schema_ids(@model)
263
150
  schema_ids.each do |schema_id|
264
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
151
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
265
152
  break if target
266
153
  end
267
154
  end
@@ -269,147 +156,390 @@ module Expressir
269
156
 
270
157
  # Strategy 3: Create implicit item for qualified paths only
271
158
  if target.nil? && tag.include?(".")
272
- # Try with scope path first
273
159
  if containing_scope && function_rule_procedure?(containing_scope)
274
160
  scope_path = build_scope_path(containing_scope)
275
161
  if scope_path
276
- full_path = "#{scope_path}.#{tag}"
277
- target = create_implicit_remark_item(model, full_path,
278
- get_schema_ids(model))
162
+ target = create_implicit_remark_item(@model, "#{scope_path}.#{tag}",
163
+ get_schema_ids(@model))
279
164
  end
280
165
  end
281
- # Fall back to schema prefix
282
166
  if target.nil?
283
- target = create_implicit_remark_item(model, tag,
284
- get_schema_ids(model))
167
+ target = create_implicit_remark_item(@model, tag,
168
+ get_schema_ids(@model))
285
169
  end
286
170
  end
287
171
 
288
172
  # Strategy 4: For simple tags at schema level, create implicit item
289
173
  if target.nil? && !tag.include?(".")
290
- schema_ids = get_schema_ids(model)
174
+ schema_ids = get_schema_ids(@model)
291
175
  if schema_ids.any?
292
- target = create_implicit_remark_item_at_schema(model, tag,
176
+ target = create_implicit_remark_item_at_schema(@model, tag,
293
177
  schema_ids.first)
294
178
  end
295
179
  end
296
180
  end
297
181
 
298
182
  if target
299
- add_remark(target, remark[:text], format: remark[:format],
300
- tag: remark[:tag])
301
- @attached_spans << remark[:position]
183
+ add_remark(target, remark.text, format: remark.format, tag: remark.tag,
184
+ source_offset: remark.position)
185
+ @attached_spans << remark.position
302
186
  end
303
187
  end
304
188
  end
305
189
 
306
- # Position-based fallback for finding containing scope.
307
- # Used when scope map lookup returns nil (e.g., for remarks at lines
308
- # outside any declared scope's end_line, or for non-scope-containers).
309
- def find_containing_scope_position(remark_line, nodes_with_positions)
310
- containing_nodes = nodes_with_positions.select do |n|
311
- n[:line] && n[:end_line] && remark_line >= n[:line] && remark_line <= n[:end_line] &&
312
- !repository?(n[:node]) && !cache?(n[:node])
313
- end
190
+ # ----- Untagged remark attachment -----
314
191
 
315
- containing_nodes.reverse_each do |n|
316
- node = n[:node]
317
- return node if node.is_a?(Model::ScopeContainer)
318
- end
192
+ def attach_untagged_remarks(remarks)
193
+ untagged = remarks.reject(&:tag)
194
+ return unless untagged.any?
319
195
 
320
- nil
196
+ untagged.each do |remark|
197
+ next if @attached_spans.include?(remark.position)
198
+
199
+ line_content = line_content_for(remark.line)
200
+ if end_scope_line?(line_content)
201
+ matched_node = @node_index.node_for_end_scope_at(remark.line, line_content)
202
+ if matched_node
203
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil,
204
+ source_offset: remark.position)
205
+ @attached_spans << remark.position
206
+ next
207
+ end
208
+ end
209
+
210
+ target, placement, region = find_body_comment_target(remark)
211
+ if target
212
+ add_remark(target, remark.text, format: remark.format, tag: nil,
213
+ placement: placement, region: region)
214
+ @attached_spans << remark.position
215
+ next
216
+ end
217
+
218
+ matched_node = @node_index.nearest_node_to(remark.line)
219
+ if matched_node
220
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil,
221
+ source_offset: remark.position)
222
+ @attached_spans << remark.position
223
+ end
224
+ end
225
+ end
226
+
227
+ def end_scope_line?(line_content)
228
+ line_content =~ /END_(SCHEMA|ENTITY|TYPE|FUNCTION|PROCEDURE|RULE)/i
229
+ end
230
+
231
+ # Own-line body comments belong to the next statement in the same
232
+ # statement region (Function body, THEN branch, ELSE branch, loop body):
233
+ # attached there with LEADING placement. A comment with no following
234
+ # statement in its region closes that region, so it attaches to the
235
+ # region's owner with TRAILING placement and the region's name — an IF
236
+ # owns two bodies that close at different keywords.
237
+ #
238
+ # Returns [nil, nil, nil] — "use the legacy fallback" — when the remark
239
+ # shares a line with a node or sits outside any statement-bearing node.
240
+ def find_body_comment_target(remark)
241
+ line = remark.line
242
+ nodes = @node_index.nodes
243
+ # An own-line comment shares its line with no node. A node STARTING
244
+ # here means the remark is an inline tail (code; -- note). The
245
+ # end-line check is restricted to statements: container end_lines are
246
+ # child-derived approximations that can collide with comment lines.
247
+ return inline_target(remark, nodes) if inline_remark?(remark)
248
+
249
+ # A closing keyword on the next code line is decisive: the comment
250
+ # closes that body. Without this check the comment would instead be
251
+ # read as leading the next statement of an OUTER region, which is
252
+ # where it would wrongly render.
253
+ closing = closing_region_target(line, nodes)
254
+ return closing if closing.first
255
+
256
+ enclosing, region, = statement_region_for(line, nodes)
257
+ return [nil, nil, nil] unless region
258
+
259
+ following = region
260
+ .select { |n| n[:line] > line }
261
+ .min_by { |n| n[:position] }
262
+ if following
263
+ return [following[:node], Model::RemarkPlacement::LEADING, nil]
264
+ end
265
+
266
+ # No following statement and no closing keyword above: the comment is
267
+ # not demonstrably inside this body (it may sit after the whole
268
+ # declaration). Keep the legacy attachment rather than guessing.
269
+ [enclosing[:node], nil, nil]
270
+ end
271
+
272
+ # Whether the remark trails code on its own line. Decided from the
273
+ # source text before it, not from node positions: container end lines
274
+ # are child-derived approximations that collide with comment lines and
275
+ # would misread an own-line comment as a trailing one.
276
+ def inline_remark?(remark)
277
+ content = line_content_for(remark.line).to_s
278
+ opener = content.index("--")
279
+ return false unless opener
280
+
281
+ !content[0...opener].strip.empty?
282
+ end
283
+
284
+ # A comment trailing code on its line belongs to the statement that
285
+ # ends closest before it: `x := 1; -- why`. Only single-line statements
286
+ # qualify, because appending to a statement spanning several lines
287
+ # would move the remark down to its closing keyword.
288
+ def inline_target(remark, nodes)
289
+ owner = nodes
290
+ .select do |n|
291
+ n[:node].is_a?(Model::Statement) &&
292
+ n[:line] == remark.line && n[:end_line] == remark.line &&
293
+ n[:position] && n[:position] < remark.position
294
+ end
295
+ .max_by { |n| n[:position] + n[:node].source.to_s.length }
296
+ return [nil, nil, nil] unless owner
297
+
298
+ [owner[:node], Model::RemarkPlacement::INLINE, nil]
299
+ end
300
+
301
+ # Which closing keyword ends which region of which owner. A comment
302
+ # sitting between a body's last statement and one of these keywords
303
+ # closes that body.
304
+ CLOSING_KEYWORDS = {
305
+ /\AELSE\b/i => [Model::Statements::If, :statements],
306
+ /\AEND_IF\b/i => [Model::Statements::If, :else_statements],
307
+ /\AOTHERWISE\b/i => [Model::Statements::Case, :action_statements],
308
+ /\AEND_CASE\b/i => [Model::Statements::Case, :otherwise_statements],
309
+ /\AEND_REPEAT\b/i => [Model::Statements::Repeat, :statements],
310
+ /\AEND_ALIAS\b/i => [Model::Statements::Alias, :statements],
311
+ /\AEND\s*;/i => [Model::Statements::Compound, :statements],
312
+ # A RULE's executable body ends at WHERE, not at END_RULE.
313
+ /\AWHERE\b/i => [Model::Declarations::Rule, :statements],
314
+ /\AEND_FUNCTION\b/i => [Model::Declarations::Function, :statements],
315
+ /\AEND_PROCEDURE\b/i => [Model::Declarations::Procedure, :statements],
316
+ # END_RULE closes the WHERE section when the rule has one.
317
+ /\AEND_RULE\b/i => [Model::Declarations::Rule, :where_rules],
318
+ }.freeze
319
+
320
+ # Regions whose owner may not have that body, in which case the
321
+ # keyword closes the earlier region instead.
322
+ REGION_FALLBACKS = {
323
+ [Model::Statements::If, :else_statements] =>
324
+ [:statements, ->(n) { n.else_statements&.length&.positive? }],
325
+ [Model::Statements::Case, :otherwise_statements] =>
326
+ [:action_statements, ->(n) { !n.otherwise_statement.nil? }],
327
+ [Model::Declarations::Rule, :where_rules] =>
328
+ [:statements, ->(n) { n.where_rules&.length&.positive? }],
329
+ }.freeze
330
+
331
+ # A node's indexed span stops at its last child, so a comment written
332
+ # after that child but before the node's closing keyword sits outside
333
+ # every span and never reaches statement_region_for. Resolve it from
334
+ # the keyword that follows: it names both the owner type and the body
335
+ # being closed.
336
+ def closing_region_target(line, nodes)
337
+ keyword_owner, region, keyword_line = closing_keyword_after(line)
338
+ return [nil, nil, nil] unless keyword_owner
339
+
340
+ # The owner is the construct the keyword actually closes — the
341
+ # innermost one still open at that line. Picking the latest node of
342
+ # the right class instead would grab an already-closed inner block
343
+ # (nested IFs) or an unrelated earlier declaration (a RULE, when the
344
+ # WHERE really belongs to an ENTITY).
345
+ opener_line = active_opener_line(keyword_line, keyword_owner)
346
+ return [nil, nil, nil] unless opener_line
347
+
348
+ owner = nodes.find do |n|
349
+ n[:node].is_a?(keyword_owner) && n[:line] == opener_line
350
+ end
351
+ return [nil, nil, nil] unless owner
352
+
353
+ # END_IF closes the THEN body when there is no ELSE; END_CASE closes
354
+ # the last action when there is no OTHERWISE.
355
+ fallback, present = REGION_FALLBACKS[[keyword_owner, region]]
356
+ region = fallback if fallback && !present.call(owner[:node])
357
+
358
+ [owner[:node], Model::RemarkPlacement::TRAILING, region.to_s]
321
359
  end
322
360
 
323
- # Done once per RemarkAttacher instance (O(file_lines)).
324
- # Each find_containing_scope call then becomes O(1).
325
- def build_scope_map
326
- lines = source_lines
327
- scope_map = {}
328
- return scope_map if lines.empty?
361
+ # The first non-blank, non-comment source line after `line`.
362
+ # Source keywords that open a nestable construct, paired with the class
363
+ # of node they produce. Used to find which construct a closing keyword
364
+ # actually belongs to.
365
+ OPENERS = [
366
+ [/\bIF\b.*?\bTHEN\b/i, Model::Statements::If],
367
+ [/\bCASE\b.*?\bOF\b/i, Model::Statements::Case],
368
+ [/\bREPEAT\b/i, Model::Statements::Repeat],
369
+ [/\bALIAS\b/i, Model::Statements::Alias],
370
+ [/\bBEGIN\b/i, Model::Statements::Compound],
371
+ [/\A\s*FUNCTION\b/i, Model::Declarations::Function],
372
+ [/\A\s*PROCEDURE\b/i, Model::Declarations::Procedure],
373
+ [/\A\s*RULE\b/i, Model::Declarations::Rule],
374
+ [/\A\s*ENTITY\b/i, :other],
375
+ [/\A\s*TYPE\b/i, :other],
376
+ ].freeze
329
377
 
330
- # Track nested scopes by scanning all lines once
331
- scope_stack = [] # array of {type:, name:, line:}
378
+ CLOSERS = /\bEND_IF\b|\bEND_CASE\b|\bEND_REPEAT\b|\bEND_ALIAS\b|\bEND_FUNCTION\b|\bEND_PROCEDURE\b|\bEND_RULE\b|\bEND_ENTITY\b|\bEND_TYPE\b|\bEND\s*;/i
332
379
 
333
- lines.each_with_index do |line, idx|
334
- line_num = idx + 1
380
+ # Strips what must not be scanned for keywords: string literals and a
381
+ # trailing `--` remark. Without this, `x := 'IF a THEN'` or a comment
382
+ # mentioning REPEAT would push a construct that never opened.
383
+ def keyword_scannable(content)
384
+ without_strings = content.gsub(/'[^']*'/, "''")
385
+ tail = without_strings.index("--")
386
+ tail ? without_strings[0...tail] : without_strings
387
+ end
335
388
 
336
- # Check for START keywords first
337
- if line =~ /^\s*SCHEMA\s+(\w+)/i
338
- scope_stack << { type: :schema, name: $1, line: line_num }
339
- end
389
+ # The opening line of the innermost construct still open at
390
+ # `keyword_line`, or nil when that construct is not of `expected_class`.
391
+ def active_opener_line(keyword_line, expected_class)
392
+ active = active_scope_map[keyword_line]
393
+ return nil unless active && active[0] == expected_class
340
394
 
341
- if line =~ /^\s*FUNCTION\s+(\w+)/i
342
- scope_stack << { type: :function, name: $1, line: line_num }
343
- end
395
+ active[1]
396
+ end
344
397
 
345
- if line =~ /^\s*PROCEDURE\s+(\w+)/i
346
- scope_stack << { type: :procedure, name: $1, line: line_num }
347
- end
398
+ # Line number => the construct open at the START of that line, as
399
+ # [class, opening_line]. Built once per source: rescanning from line 1
400
+ # for every trailing comment is quadratic, and on a comment-dense file
401
+ # that cost dominates parsing entirely.
402
+ def active_scope_map
403
+ @active_scope_map ||= build_active_scope_map
404
+ end
348
405
 
349
- if line =~ /^\s*RULE\s+(\w+)/i
350
- scope_stack << { type: :rule, name: $1, line: line_num }
351
- end
406
+ def build_active_scope_map
407
+ map = {}
408
+ stack = []
409
+ (1..source_line_count).each do |ln|
410
+ map[ln] = stack.last
411
+ content = keyword_scannable(line_content_for(ln).to_s.strip)
412
+ next if content.empty? || content.start_with?("--")
352
413
 
353
- if line =~ /^\s*ENTITY\s+(\w+)/i
354
- scope_stack << { type: :entity, name: $1, line: line_num }
414
+ line_events(content).each do |_offset, kind, klass|
415
+ kind == :open ? stack << [klass, ln] : stack.pop
355
416
  end
417
+ end
418
+ map
419
+ end
356
420
 
357
- if line =~ /^\s*TYPE\s+(\w+)/i
358
- scope_stack << { type: :type, name: $1, line: line_num }
421
+ # Opener/closer events on one line, ordered by where they appear.
422
+ # EVERY occurrence is collected, not just the first: a line holding two
423
+ # complete IF blocks contributes two openers and two closers, and
424
+ # recording only one opener would over-pop the enclosing construct.
425
+ def line_events(content)
426
+ events = []
427
+ OPENERS.each do |pattern, klass|
428
+ content.enum_for(:scan, pattern).each do
429
+ events << [Regexp.last_match.begin(0), :open, klass]
359
430
  end
431
+ end
432
+ content.enum_for(:scan, CLOSERS).each do
433
+ events << [Regexp.last_match.begin(0), :close, nil]
434
+ end
435
+ events.sort_by(&:first)
436
+ end
360
437
 
361
- # Check for END keywords (inline closures on same line handled here)
362
- if (line =~ /END_TYPE/i) && (scope_stack.last&.dig(:type) == :type)
363
- scope_stack.pop
364
- end
365
- if (line =~ /END_FUNCTION/i) && (scope_stack.last&.dig(:type) == :function)
366
- scope_stack.pop
367
- end
368
- if (line =~ /END_PROCEDURE/i) && (scope_stack.last&.dig(:type) == :procedure)
369
- scope_stack.pop
370
- end
371
- if (line =~ /END_RULE/i) && (scope_stack.last&.dig(:type) == :rule)
372
- scope_stack.pop
373
- end
374
- if (line =~ /END_ENTITY/i) && (scope_stack.last&.dig(:type) == :entity)
375
- scope_stack.pop
376
- end
377
- if (line =~ /END_SCHEMA/i) && (scope_stack.last&.dig(:type) == :schema)
378
- scope_stack.pop
379
- end
438
+ def closing_keyword_after(line)
439
+ probe = line + 1
440
+ # Skip further comment lines AND blank lines: a comment separated
441
+ # from its closing keyword by an empty line still closes that body.
442
+ while probe <= source_line_count
443
+ content = line_content_for(probe).to_s.strip
444
+ break unless content.empty? || content.start_with?("--")
445
+
446
+ probe += 1
447
+ end
448
+ content = line_content_for(probe).to_s.strip
449
+ CLOSING_KEYWORDS.each do |pattern, owner_region|
450
+ return [*owner_region, probe] if content.match?(pattern)
451
+ end
452
+ [nil, nil, nil]
453
+ end
454
+
455
+ def statement_region_for(line, nodes)
456
+ candidates = nodes.select do |n|
457
+ n[:line] && n[:end_line] && n[:line] <= line && n[:end_line] >= line &&
458
+ (n[:node].is_a?(Model::Statement) || function_rule_procedure?(n[:node]))
459
+ end
460
+ enclosing = innermost_candidate(candidates)
461
+ return [nil, nil, nil] unless enclosing
380
462
 
381
- # Record the innermost scope for this line
382
- scope_map[line_num] = scope_stack.last&.dig(:name)
463
+ children = nodes.select do |n|
464
+ n[:owner].equal?(enclosing[:node]) &&
465
+ STATEMENT_REGIONS.include?(n[:collection]) && n[:line]
383
466
  end
467
+ return [enclosing, nil, nil] if children.empty?
384
468
 
385
- scope_map
469
+ preceding = children.select { |n| n[:line] < line }.max_by { |n| n[:position] }
470
+ following = children.select { |n| n[:line] > line }.min_by { |n| n[:position] }
471
+ region_attr = region_attr_for(line, preceding, following)
472
+ return [enclosing, nil, nil] unless region_attr
473
+
474
+ [enclosing, children.select { |n| n[:collection] == region_attr }, region_attr]
386
475
  end
387
476
 
388
- # O(1) scope lookup using pre-computed scope map
389
- def find_containing_scope_by_name(remark_line)
390
- return nil unless @scope_map
477
+ # Node end lines are child-derived approximations, so a parent's span
478
+ # can come out SMALLER than a child's and span size alone picks the
479
+ # wrong container. Ownership links are exact: drop every candidate
480
+ # that is an ancestor of another candidate, then pick the smallest
481
+ # span among the true leaves.
482
+ def innermost_candidate(candidates)
483
+ return candidates.first if candidates.length <= 1
391
484
 
392
- scope_name = @scope_map[remark_line]
393
- return nil unless scope_name
485
+ owner_of = owner_map
486
+ ancestors = Set.new.compare_by_identity
487
+ candidates.each do |cand|
488
+ current = owner_of[cand[:node]]
489
+ while current
490
+ ancestors << current
491
+ current = owner_of[current]
492
+ end
493
+ end
394
494
 
395
- # Find the model node for this scope
396
- return nil unless @model
495
+ leaves = candidates.reject { |n| ancestors.include?(n[:node]) }
496
+ (leaves.empty? ? candidates : leaves).min_by { |n| n[:end_line] - n[:line] }
497
+ end
397
498
 
398
- @model.schemas.each do |schema|
399
- return schema if schema.id == scope_name
499
+ # The node index is immutable during attachment, so its ownership map
500
+ # only needs to be built once for all body remarks.
501
+ def owner_map
502
+ # Identity comparison must be enabled BEFORE the hash is populated.
503
+ # Model elements compare by value, so two distinct-but-equal nodes
504
+ # would collapse into one entry during a plain build, and switching
505
+ # to identity afterwards cannot recover the lost entry.
506
+ @owner_map ||= @node_index.nodes.each_with_object(
507
+ {}.compare_by_identity,
508
+ ) { |n, map| map[n[:node]] = n[:owner] }
509
+ end
510
+
511
+ # The keyword that opens each region, for regions that follow another
512
+ # region of the same owner. A comment in the gap belongs to whichever
513
+ # side of this keyword it was written on.
514
+ REGION_OPENERS = {
515
+ else_statements: /\A(?:.*;)?\s*ELSE(?:\s*--.*)?\z/i,
516
+ otherwise_statements: /\A\s*OTHERWISE\b/i,
517
+ }.freeze
400
518
 
401
- %i[functions procedures rules entities types].each do |decl_type|
402
- collection = schema.public_send(decl_type)
403
- next unless collection.is_a?(Array)
519
+ # A comment between two regions of the same owner — between the THEN
520
+ # body and ELSE, or between the last CASE action and OTHERWISE — sits
521
+ # on one side of the keyword that opens the second region. The gap can
522
+ # hold only that keyword and comments, so scanning it is exact.
523
+ # Comment lines are skipped so prose mentioning the keyword cannot
524
+ # match.
525
+ def region_attr_for(line, preceding, following)
526
+ return following&.dig(:collection) unless preceding
404
527
 
405
- found = collection.find { |n| n.id == scope_name }
406
- return found if found
528
+ following_attr = following&.dig(:collection)
529
+ opener = REGION_OPENERS[following_attr]
530
+ if opener && following_attr != preceding[:collection]
531
+ opener_line = (preceding[:end_line]...following[:line]).find do |ln|
532
+ content = line_content_for(ln).strip
533
+ !content.start_with?("--") && opener.match?(content)
407
534
  end
535
+ return following_attr if opener_line && line > opener_line
408
536
  end
409
537
 
410
- nil
538
+ preceding[:collection]
411
539
  end
412
540
 
541
+ # ----- Tag resolution (within a scope) -----
542
+
413
543
  def find_node_in_scope(scope, tag)
414
544
  return nil unless scope
415
545
 
@@ -420,20 +550,17 @@ module Expressir
420
550
  end
421
551
  end
422
552
 
423
- # Search inside types for enumeration items
424
553
  types = get_collection(scope, :types)
425
554
  types&.each do |type|
426
555
  result = find_enumeration_item_in_type(type, tag)
427
556
  return result if result
428
557
  end
429
558
 
430
- # Search inside statements for nested items (alias, repeat, query)
431
559
  statements = get_collection(scope, :statements)
432
560
  statements&.each do |stmt|
433
561
  result = find_node_in_statement(stmt, tag)
434
562
  return result if result
435
563
 
436
- # Search inside expressions for QueryExpression (nested in assignments, etc.)
437
564
  result = find_query_in_expression(stmt, tag)
438
565
  return result if result
439
566
  end
@@ -443,45 +570,19 @@ module Expressir
443
570
 
444
571
  def find_enumeration_item_in_type(type, tag)
445
572
  return nil unless type
573
+ return nil unless type.is_a?(Model::Declarations::Type)
446
574
 
447
- # Check if type is a Type declaration with enumeration
448
- if type.is_a?(Model::Declarations::Type)
449
- # Check enumeration_items on the type itself
450
- type.enumeration_items&.each do |item|
451
- return item if item.id == tag
452
- end
453
-
454
- # Also check underlying_type if it's an enumeration
455
- ut = type.underlying_type
456
- if ut.is_a?(Model::DataTypes::Enumeration) && ut.items
457
- ut.items.each do |item|
458
- return item if item.id == tag
459
- end
460
- end
575
+ type.enumeration_items&.each do |item|
576
+ return item if item.id == tag
461
577
  end
462
578
 
579
+ ut = type.underlying_type
580
+ return nil unless ut.is_a?(Model::DataTypes::Enumeration) && ut.items
581
+
582
+ ut.items.each { |item| return item if item.id == tag }
463
583
  nil
464
584
  end
465
585
 
466
- # Expression and statement child attributes for QueryExpression search.
467
- # Targeted traversal prevents over-matching on unrelated model attributes.
468
- EXPRESSION_CHILDREN = {
469
- Model::Expressions::BinaryExpression => %i[operand1 operand2],
470
- Model::Expressions::UnaryExpression => %i[operand],
471
- Model::Expressions::QueryExpression => %i[expression aggregate_source],
472
- Model::Expressions::AggregateInitializerItem => %i[expression
473
- repetition],
474
- Model::Expressions::Interval => %i[low item high],
475
- Model::Expressions::FunctionCall => %i[parameters],
476
- Model::Expressions::EntityConstructor => %i[parameters],
477
- Model::Expressions::AggregateInitializer => %i[items],
478
- Model::Statements::Assignment => %i[expression],
479
- Model::Statements::If => %i[expression],
480
- Model::Statements::Case => %i[expression],
481
- Model::Statements::CaseAction => %i[expression],
482
- Model::Statements::Repeat => %i[while_expression until_expression],
483
- }.freeze
484
-
485
586
  def find_query_in_expression(node, tag, visited = Set.new)
486
587
  return nil unless node
487
588
  return nil unless node.is_a?(Model::ModelElement)
@@ -489,9 +590,7 @@ module Expressir
489
590
 
490
591
  visited.add(node.object_id)
491
592
 
492
- if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
493
- return node
494
- end
593
+ return node if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
495
594
 
496
595
  attrs = EXPRESSION_CHILDREN[node.class]
497
596
  return nil unless attrs
@@ -528,7 +627,6 @@ module Expressir
528
627
  prefix, id = tag.split(":")
529
628
  return nil unless id
530
629
 
531
- # Determine collection based on prefix
532
630
  collection_attr = case prefix.downcase
533
631
  when "wr" then :where_rules
534
632
  when "ip" then :informal_propositions
@@ -536,14 +634,12 @@ module Expressir
536
634
  end
537
635
  return nil unless collection_attr
538
636
 
539
- # First try to find in containing scope
540
637
  collection = get_collection(containing_scope, collection_attr)
541
638
  if collection
542
639
  found = collection.find { |item| item.is_a?(Model::ModelElement) && item.id == id }
543
640
  return found if found
544
641
  end
545
642
 
546
- # Fallback: try to find by full path
547
643
  schema_ids.each do |schema_id|
548
644
  full_path = "#{schema_id}.#{tag.tr(':', '.')}"
549
645
  found = safe_find(model, full_path)
@@ -553,30 +649,25 @@ module Expressir
553
649
  nil
554
650
  end
555
651
 
556
- # Find target for remarks inside WHERE clauses
652
+ # Find target for remarks inside WHERE clauses by scanning source lines
653
+ # for `WHERE <id>:` patterns. Lives here (not in ScopeResolver) because
654
+ # it's about WHERE-rule membership, not scope membership.
557
655
  def find_target_in_where_clause(scope, tag, remark_line)
558
656
  return nil unless supports_where_rules?(scope)
559
657
 
560
658
  where_rules = get_collection(scope, :where_rules)
561
659
  return nil unless where_rules&.any?
562
660
 
563
- # Search source text for WHERE clause containing this remark
564
- lines = source_lines
661
+ lines = source_lines_for_where_clause
565
662
 
566
663
  where_rules.each do |wr|
567
664
  next unless wr.id
568
665
 
569
- # Find the WHERE rule declaration
570
666
  lines.each_with_index do |line, idx|
571
667
  line_num = idx + 1
572
668
  next unless line_num < remark_line
573
669
 
574
- # Look for "WHERE {id}:" pattern
575
- # Check if remark is within a few lines after this WHERE declaration
576
- if (line =~ /^\s*WHERE\s+#{Regexp.escape(wr.id)}\s*:/i) && remark_line.between?(
577
- line_num, line_num + 5
578
- )
579
- # Found the WHERE rule - create remark item inside it
670
+ if (line =~ /^\s*WHERE\s+#{Regexp.escape(wr.id)}\s*:/i) && remark_line.between?(line_num, line_num + 5)
580
671
  return create_remark_item(wr, tag)
581
672
  end
582
673
  end
@@ -585,6 +676,11 @@ module Expressir
585
676
  nil
586
677
  end
587
678
 
679
+ def source_lines_for_where_clause
680
+ # @source is set for the duration of `attach`; freed at the end.
681
+ @source.lines
682
+ end
683
+
588
684
  def find_node_in_statement(stmt, tag)
589
685
  case stmt
590
686
  when Model::Statements::Alias
@@ -597,15 +693,6 @@ module Expressir
597
693
  nil
598
694
  end
599
695
 
600
- def find_containing_scope(remark_line, nodes_with_positions)
601
- # First try scope map (O(1) once built)
602
- scope = find_containing_scope_by_name(remark_line)
603
- return scope if scope
604
-
605
- # Fallback to position-based detection
606
- find_containing_scope_position(remark_line, nodes_with_positions)
607
- end
608
-
609
696
  def build_scope_path(node)
610
697
  return nil unless node
611
698
 
@@ -617,7 +704,6 @@ module Expressir
617
704
  parts.unshift(current.id)
618
705
  end
619
706
 
620
- # Stop at schema level
621
707
  break if current.is_a?(Model::Declarations::Schema)
622
708
 
623
709
  current = current.parent
@@ -626,113 +712,33 @@ module Expressir
626
712
  parts.empty? ? nil : parts.join(".")
627
713
  end
628
714
 
629
- def find_scope_by_source_text(remark_line)
630
- # Search backwards from remark_line for containing scope
631
- lines = source_lines
632
-
633
- # Find the entity/type/rule that contains this line
634
- entity_start = nil
635
- type_start = nil
636
- rule_start = nil
637
- current_entity = nil
638
- current_type = nil
639
- current_rule = nil
640
-
641
- lines.each_with_index do |line, idx|
642
- line_num = idx + 1
643
-
644
- case line
645
- when /^\s*ENTITY\s+(\w+)/i
646
- entity_start = line_num
647
- current_entity = $1
648
- when /^\s*END_ENTITY/i
649
- if entity_start && remark_line >= entity_start && remark_line <= line_num
650
- # Found containing entity
651
- return find_node_by_type_and_name(Model::Declarations::Entity,
652
- current_entity)
653
- end
654
-
655
- entity_start = nil
656
- current_entity = nil
657
- when /^\s*TYPE\s+(\w+)/i
658
- type_start = line_num
659
- current_type = $1
660
- when /^\s*END_TYPE/i
661
- if type_start && remark_line >= type_start && remark_line <= line_num
662
- # Found containing type
663
- return find_node_by_type_and_name(Model::Declarations::Type,
664
- current_type)
665
- end
666
-
667
- type_start = nil
668
- current_type = nil
669
- when /^\s*RULE\s+(\w+)/i
670
- rule_start = line_num
671
- current_rule = $1
672
- when /^\s*END_RULE/i
673
- if rule_start && remark_line >= rule_start && remark_line <= line_num
674
- # Found containing rule
675
- return find_node_by_type_and_name(Model::Declarations::Rule,
676
- current_rule)
677
- end
678
-
679
- rule_start = nil
680
- current_rule = nil
681
- end
682
- end
683
-
684
- nil
685
- end
686
-
687
- COLLECTION_ACCESSOR = {
688
- Expressir::Model::Declarations::Entity => lambda(&:entities),
689
- Expressir::Model::Declarations::Type => lambda(&:types),
690
- Expressir::Model::Declarations::Rule => lambda(&:rules),
691
- }.freeze
692
-
693
- def find_node_by_type_and_name(node_class, name)
694
- return nil unless @model && name
695
-
696
- accessor = COLLECTION_ACCESSOR[node_class]
697
- return nil unless accessor
698
-
699
- @model.schemas.each do |schema|
700
- found = accessor.call(schema)&.find { |n| n.id == name }
701
- return found if found
702
- end
703
-
704
- nil
705
- end
715
+ # ----- Path-based lookup -----
706
716
 
707
717
  def find_by_exact_path(model, path)
708
718
  return nil unless path
709
-
710
- # Only Repository and ExpFile support path-based find
711
719
  return nil unless repository?(model) || exp_file?(model)
712
720
 
713
- # Try original path
714
721
  result = safe_find(model, path)
715
722
  return result if result
716
723
 
717
- # Try with colon converted to dot
718
724
  normalized = path.tr(":", ".")
719
725
  normalized == path ? nil : safe_find(model, normalized)
720
726
  end
721
727
 
728
+ # ----- Target creation -----
729
+
722
730
  def create_implicit_remark_item_at_schema(model, item_id, schema_id)
723
- # Only Repository and ExpFile support schema lookup
724
731
  return nil unless repository?(model) || exp_file?(model)
725
732
 
726
733
  schema = safe_find(model, schema_id)
727
734
  return nil unless schema.is_a?(Model::Declarations::Schema)
728
735
 
729
- # Handle informal propositions (IP\d+ pattern) - only if schema supports it
730
- # Note: Schema doesn't have informal_propositions, so this will create a remark_item instead
731
- if item_id.match?(/^IP\d+$/) && supports_informal_propositions?(schema)
732
- return create_or_find_informal_proposition(schema, item_id)
736
+ # Strip "wr:" prefix convention (e.g. "wr:IP1" "IP1")
737
+ ip_id = extract_ip_id(item_id)
738
+ if ip_id && supports_informal_propositions?(schema)
739
+ return create_or_find_informal_proposition(schema, ip_id)
733
740
  end
734
741
 
735
- # Handle remark items
736
742
  return nil unless supports_remark_items?(schema)
737
743
 
738
744
  existing = schema.remark_items&.find { |ri| ri.id == item_id }
@@ -744,19 +750,35 @@ module Expressir
744
750
  def create_implicit_remark_item(model, path, schema_ids = [])
745
751
  return nil unless repository?(model) || exp_file?(model)
746
752
 
747
- # Normalize path (handle "ip:IP1" format)
753
+ # Handle "wr:IP1" convention: extract parent path and IP id directly
754
+ # so normalize_path doesn't split "wr:IP1" into "wr"."IP1" segments.
755
+ ip_prefix_match = path.match(/\A(.+)\.(\w+):(IP\d+)\z/)
756
+ if ip_prefix_match
757
+ parent_path = ip_prefix_match[1]
758
+ ip_id = ip_prefix_match[3]
759
+ parent = safe_find(model, parent_path)
760
+ if parent.nil? && schema_ids.any?
761
+ schema_ids.each do |schema_id|
762
+ parent = safe_find(model, "#{schema_id}.#{parent_path}")
763
+ break if parent
764
+ end
765
+ end
766
+
767
+ if parent && supports_informal_propositions?(parent)
768
+ return create_or_find_informal_proposition(parent, ip_id)
769
+ end
770
+ end
771
+
748
772
  clean_path = normalize_path(path)
749
773
  parts = clean_path.split(".")
750
774
  return nil if parts.length < 2
751
775
 
752
- # Find the deepest existing parent and create item there
753
776
  (parts.length - 1).downto(1) do |i|
754
777
  parent_path = parts[0...i].join(".")
755
778
  item_id = parts[i]
756
779
 
757
780
  parent = safe_find(model, parent_path)
758
781
 
759
- # Try with schema prefix if not found
760
782
  if parent.nil? && schema_ids.any?
761
783
  schema_ids.each do |schema_id|
762
784
  parent = safe_find(model, "#{schema_id}.#{parent_path}")
@@ -780,12 +802,12 @@ module Expressir
780
802
  end
781
803
 
782
804
  def create_item_at_parent(parent, item_id)
783
- # Handle informal propositions
784
- if item_id.match?(/^IP\d+$/) && supports_informal_propositions?(parent)
785
- return create_or_find_informal_proposition(parent, item_id)
805
+ # Strip "wr:" prefix convention (e.g. "wr:IP1" → "IP1")
806
+ ip_id = extract_ip_id(item_id)
807
+ if ip_id && supports_informal_propositions?(parent)
808
+ return create_or_find_informal_proposition(parent, ip_id)
786
809
  end
787
810
 
788
- # Handle remark items
789
811
  return nil unless supports_remark_items?(parent)
790
812
 
791
813
  existing = parent.remark_items&.find { |ri| ri.id == item_id }
@@ -795,7 +817,6 @@ module Expressir
795
817
  end
796
818
 
797
819
  def create_or_find_informal_proposition(parent, id)
798
- # Only Entity, Rule, Type, and InformalPropositionRule have informal_propositions
799
820
  return nil unless supports_informal_propositions?(parent)
800
821
 
801
822
  existing = parent.informal_propositions&.find { |ip| ip.id == id }
@@ -807,15 +828,12 @@ module Expressir
807
828
  parent.informal_propositions << ip
808
829
  safe_reset_children_by_id(parent)
809
830
 
810
- # Also create a RemarkItem inside the InformalPropositionRule
811
- # This is the expected structure for informal proposition remarks
812
831
  remark_item = Model::Declarations::RemarkItem.new(id: id)
813
832
  remark_item.parent = ip
814
833
  ip.remark_items ||= []
815
834
  ip.remark_items << remark_item
816
835
  safe_reset_children_by_id(ip)
817
836
 
818
- # Return the remark_item so remarks are added to it
819
837
  remark_item
820
838
  end
821
839
 
@@ -828,94 +846,32 @@ module Expressir
828
846
  item
829
847
  end
830
848
 
831
- def attach_untagged_remarks(remarks, nodes_with_positions)
832
- untagged = remarks.reject { |r| r[:tag] }
833
- return unless untagged.any?
834
-
835
- untagged.each do |remark|
836
- next if @attached_spans.include?(remark[:position])
837
-
838
- if end_scope_line?(remark[:line])
839
- matched_node = find_node_for_end_scope_remark(remark,
840
- nodes_with_positions)
841
- if matched_node
842
- add_remark(matched_node, remark[:text], format: remark[:format],
843
- tag: nil)
844
- @attached_spans << remark[:position]
845
- next
846
- end
847
- end
848
-
849
- matched_node = find_nearest_node(remark, nodes_with_positions)
850
- if matched_node
851
- add_remark(matched_node, remark[:text], format: remark[:format],
852
- tag: nil)
853
- @attached_spans << remark[:position]
854
- end
855
- end
856
- end
857
-
858
- def end_scope_line?(line_num)
859
- line = get_line_content(line_num)
860
- line =~ /END_(SCHEMA|ENTITY|TYPE|FUNCTION|PROCEDURE|RULE)/i
861
- end
862
-
863
- def get_line_content(line_num)
864
- lines = source_lines
865
- return "" if line_num < 1 || line_num > lines.length
866
-
867
- lines[line_num - 1]
868
- end
869
-
870
- def find_node_for_end_scope_remark(remark, nodes)
871
- line_content = get_line_content(remark[:line])
872
-
873
- node_type = case line_content
874
- when /END_SCHEMA/i then Model::Declarations::Schema
875
- when /END_ENTITY/i then Model::Declarations::Entity
876
- when /END_TYPE/i then Model::Declarations::Type
877
- when /END_FUNCTION/i then Model::Declarations::Function
878
- when /END_PROCEDURE/i then Model::Declarations::Procedure
879
- when /END_RULE/i then Model::Declarations::Rule
880
- end
881
-
882
- return nil unless node_type
883
-
884
- matching_nodes = nodes.select do |n|
885
- n[:node].is_a?(node_type) &&
886
- (n[:end_line] == remark[:line] ||
887
- (n[:end_line] && n[:end_line] <= remark[:line] && n[:end_line] >= remark[:line] - 2))
888
- end
889
-
890
- matching_nodes.first&.dig(:node) || find_node_by_type(nodes, node_type)
891
- end
892
-
893
- def find_node_by_type(nodes, node_type)
894
- nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
895
- end
849
+ # ----- Remark storage -----
896
850
 
897
- def add_remark(node, text, format: "tail", tag: nil)
851
+ def add_remark(node, text, format: Model::RemarkFormat::TAIL, tag: nil,
852
+ placement: nil, region: nil, source_offset: nil)
898
853
  return unless node
899
854
  return unless node.is_a?(Model::ModelElement)
900
855
 
901
- # Only add remarks to nodes that support them
902
856
  if supports_remarks?(node)
903
- # Always add to remarks attribute (for types that have it)
904
857
  if node_has_remarks?(node)
905
858
  node.remarks ||= []
906
859
  node.remarks << text
907
860
  end
908
861
 
909
862
  if tag.nil?
910
- # Untagged remark: store in untagged_remarks
911
- remark_info = Model::RemarkInfo.new(text: text, format: format)
863
+ remark_info = Model::RemarkInfo.new(text: text, format: format,
864
+ placement: placement,
865
+ region: region,
866
+ source_offset: source_offset)
912
867
  node.untagged_remarks ||= []
913
868
  node.untagged_remarks << remark_info
914
869
  end
915
870
  end
916
871
  end
917
872
 
918
- # All ModelElement subclasses have untagged_remarks from ModelElement
873
+ # ----- Type predicates -----
874
+
919
875
  def supports_remarks?(obj)
920
876
  obj.is_a?(Model::ModelElement)
921
877
  end
@@ -924,180 +880,23 @@ module Expressir
924
880
  obj.is_a?(Model::HasRemarks)
925
881
  end
926
882
 
927
- # Types that include HasRemarkItems can have remark_items
928
883
  def supports_remark_items?(obj)
929
884
  obj.is_a?(Model::HasRemarkItems)
930
885
  end
931
886
 
932
- def collect_nodes_with_positions(node, result, visited = Set.new)
933
- return unless node
934
- return if visited.include?(node.object_id)
935
-
936
- visited.add(node.object_id)
937
-
938
- if node.is_a?(Model::ModelElement) && node.source
939
- # Use stored source_offset from parser
940
- # The parser always provides this via Slice#offset
941
- if node.source_offset
942
- pos = node.source_offset
943
- # Validate offset: native parser returns 0 for leaf nodes (WhereRule)
944
- # where it can't determine the actual position. These have short
945
- # expression-like source ("TRUE;") that doesn't appear at file start.
946
- # Container nodes (Schema, Entity, Type) have declaration-like source
947
- # that either starts at position 0 legitimately or is clearly valid.
948
- valid = pos.positive?
949
- if !valid && pos.zero? && node.source
950
- src = node.source.to_s
951
- # Accept position=0 if source is a declaration keyword line
952
- valid = src.start_with?("SCHEMA", "ENTITY", "TYPE", "FUNCTION",
953
- "PROCEDURE", "RULE", "CONSTANT", "VARIABLE",
954
- "USE", "REFERENCE", "END_SCHEMA", "END_ENTITY",
955
- "END_TYPE", "END_FUNCTION", "END_PROCEDURE",
956
- "END_RULE", "END_CONSTANT", "END_VARIABLE")
957
- end
958
- if valid
959
- line = get_line_number(pos)
960
- source_end_line = get_line_number(pos + node.source.length)
961
-
962
- # For container nodes, use the maximum end_line from children
963
- # This is needed because source.length only covers the declaration, not the body
964
- children_end_line = calculate_children_end_line(node)
965
- end_line = [source_end_line,
966
- children_end_line].compact.max || source_end_line
967
-
968
- result << {
969
- node: node,
970
- position: pos,
971
- line: line,
972
- end_line: end_line,
973
- }
974
- else
975
- # Invalid offset — treat as unknown position
976
- result << { node: node, position: nil, line: nil, end_line: nil }
977
- end
978
- else
979
- # No source_offset available - should not happen if parser provides Slice
980
- result << { node: node, position: nil, line: nil, end_line: nil }
981
- end
982
- else
983
- result << { node: node, position: nil, line: nil, end_line: nil }
984
- end
985
-
986
- collect_children(node, result, visited)
987
- end
988
-
989
- # Calculate the end line from all children of a node
990
- # This is needed for container nodes like schemas, entities, etc.
991
- # where source.length only covers the declaration, not the body
992
- def calculate_children_end_line(node)
993
- children_end_lines = []
994
-
995
- # Check computed children (Schema, ExpFile have a children method)
996
- if node.is_a?(Model::Declarations::Schema)
997
- Array(node.children).each do |child|
998
- if child.is_a?(Model::ModelElement) && child.source_offset && child.source
999
- children_end_lines << get_line_number(child.source_offset + child.source.length)
1000
- end
1001
- end
1002
- end
1003
-
1004
- # Visit declared collections from type registry
1005
- collections_on(node).each do |collection|
1006
- collection.each do |child|
1007
- if child.is_a?(Model::ModelElement) && child.source_offset && child.source
1008
- children_end_lines << get_line_number(child.source_offset + child.source.length)
1009
- end
1010
- end
1011
- end
1012
-
1013
- children_end_lines.max
1014
- end
1015
-
1016
- def collect_children(node, result, visited)
1017
- if node.is_a?(Model::Declarations::Schema)
1018
- Array(node.children).each do |child|
1019
- collect_nodes_with_positions(child, result, visited)
1020
- end
1021
- end
1022
-
1023
- collections_on(node).each do |collection|
1024
- collection.each do |item|
1025
- collect_nodes_with_positions(item, result, visited)
1026
- end
1027
- end
1028
- end
1029
-
1030
- # Build sorted nodes_with_positions ONCE for both tagged and untagged remark passes.
1031
- # This merges the two separate tree walks into one, cutting node visits in half.
1032
- def build_sorted_nodes_with_positions(model)
1033
- nodes_with_positions = []
1034
- collect_nodes_with_positions(model, nodes_with_positions)
1035
- # Stable sort: nil positions last, ties broken by insertion order
1036
- nodes_with_positions.sort_by!.with_index { |n, i| [n[:position] || Float::INFINITY, i] }
1037
- nodes_with_positions
887
+ def supports_informal_propositions?(obj)
888
+ obj.is_a?(Model::HasInformalPropositions)
1038
889
  end
1039
890
 
1040
- def find_nearest_node(remark, nodes)
1041
- remark_line = remark[:line]
1042
-
1043
- # For tail remarks, prefer nodes that START on the same line
1044
- # This handles cases like: "attr : STRING; -- tail remark"
1045
- # Exclude Repository and Cache as they are not semantic scopes
1046
- same_start_line = nodes.select do |n|
1047
- n[:line] == remark_line &&
1048
- !repository?(n[:node]) && !cache?(n[:node])
1049
- end
1050
- return same_start_line.last[:node] if same_start_line.any?
1051
-
1052
- # Also check nodes that END on the same line
1053
- same_end_line = nodes.select do |n|
1054
- n[:end_line] == remark_line &&
1055
- !repository?(n[:node]) && !cache?(n[:node])
1056
- end
1057
- return same_end_line.last[:node] if same_end_line.any?
1058
-
1059
- # Find the node that CONTAINS this remark line
1060
- # This handles preamble remarks and embedded remarks
1061
- # Exclude Repository and Cache as they are not semantic scopes
1062
- # But include ExpFile for file-level preamble remarks
1063
- containing = nodes.select do |n|
1064
- n[:line] && n[:end_line] && n[:line] <= remark_line && n[:end_line] >= remark_line &&
1065
- !repository?(n[:node]) && !cache?(n[:node])
1066
- end
1067
-
1068
- if containing.any?
1069
- # Prefer ExpFile for preamble remarks (before first schema)
1070
- # Otherwise return the most specific (smallest) containing node
1071
- exp_file_node = containing.find { |n| exp_file?(n[:node]) }
1072
- # If this is a preamble remark (before first schema line), use ExpFile
1073
- if exp_file_node
1074
- first_schema_line = exp_file_node[:node].schemas&.first&.source_offset
1075
- if first_schema_line && remark_line < get_line_number(first_schema_line)
1076
- return exp_file_node[:node]
1077
- end
1078
- end
1079
- # Sort by span size and return the smallest
1080
- containing.min_by { |n| n[:end_line] - n[:line] }[:node]
1081
- else
1082
- # Fallback: find the last node that ends before this line
1083
- before = nodes.select do |n|
1084
- n[:end_line] && n[:end_line] < remark_line &&
1085
- !repository?(n[:node]) && !cache?(n[:node])
1086
- end
1087
- before.max_by { |n| n[:end_line] }[:node] if before.any?
1088
- end
891
+ # Extracts an IP id from a potentially prefixed item_id.
892
+ # "wr:IP1" → "IP1", "IP1" → "IP1", "WR1" → nil
893
+ def extract_ip_id(item_id)
894
+ bare = item_id.sub(/\A\w+:/, "")
895
+ bare.match?(/^IP\d+$/) ? bare : nil
1089
896
  end
1090
897
 
1091
- # Type checking helper methods
1092
-
1093
- def get_schema_ids(model)
1094
- if repository?(model)
1095
- model.schemas.filter_map(&:id)
1096
- elsif exp_file?(model)
1097
- model.schemas.filter_map(&:id)
1098
- else
1099
- []
1100
- end
898
+ def supports_where_rules?(obj)
899
+ obj.is_a?(Model::HasWhereRules)
1101
900
  end
1102
901
 
1103
902
  def repository?(obj)
@@ -1112,13 +911,15 @@ module Expressir
1112
911
  obj.is_a?(Model::Cache)
1113
912
  end
1114
913
 
1115
- def supports_informal_propositions?(obj)
1116
- obj.is_a?(Model::HasInformalPropositions)
914
+ def get_schema_ids(model)
915
+ if repository?(model) || exp_file?(model)
916
+ model.schemas.filter_map(&:id)
917
+ else
918
+ []
919
+ end
1117
920
  end
1118
921
 
1119
- def supports_where_rules?(obj)
1120
- obj.is_a?(Model::HasWhereRules)
1121
- end
922
+ # ----- Collection access -----
1122
923
 
1123
924
  # Type-driven collection access — returns all collections for a node's type.
1124
925
  def collections_on(node)
@@ -1139,6 +940,8 @@ module Expressir
1139
940
  collection if collection.is_a?(Array)
1140
941
  end
1141
942
 
943
+ # ----- Helpers -----
944
+
1142
945
  def safe_find(model, path)
1143
946
  return nil unless model
1144
947
 
@@ -1153,6 +956,17 @@ module Expressir
1153
956
 
1154
957
  obj.reset_children_by_id
1155
958
  end
959
+
960
+ def line_content_for(line_num)
961
+ lines = source_lines_for_where_clause
962
+ return "" if line_num < 1 || line_num > lines.length
963
+
964
+ lines[line_num - 1]
965
+ end
966
+
967
+ def source_line_count
968
+ source_lines_for_where_clause.length
969
+ end
1156
970
  end
1157
971
  end
1158
972
  end