expressir 2.4.0 → 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 (109) 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/node_position_index.rb +215 -0
  61. data/lib/expressir/express/parser.rb +114 -884
  62. data/lib/expressir/express/pretty_formatter.rb +23 -5
  63. data/lib/expressir/express/remark_attacher.rb +475 -577
  64. data/lib/expressir/express/remark_scanner.rb +84 -19
  65. data/lib/expressir/express/schema_block_scanner.rb +136 -0
  66. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  67. data/lib/expressir/express/scope_resolver.rb +194 -0
  68. data/lib/expressir/express/source_formatter.rb +15 -0
  69. data/lib/expressir/express/streaming_builder.rb +147 -176
  70. data/lib/expressir/express.rb +8 -6
  71. data/lib/expressir/model/concerns.rb +3 -0
  72. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  73. data/lib/expressir/model/declarations/entity.rb +5 -0
  74. data/lib/expressir/model/declarations/function.rb +5 -0
  75. data/lib/expressir/model/declarations/procedure.rb +5 -0
  76. data/lib/expressir/model/declarations/rule.rb +6 -0
  77. data/lib/expressir/model/declarations/schema.rb +21 -8
  78. data/lib/expressir/model/declarations/type.rb +3 -0
  79. data/lib/expressir/model/exp_file.rb +2 -0
  80. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  81. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  82. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  83. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  84. data/lib/expressir/model/expressions/function_call.rb +1 -0
  85. data/lib/expressir/model/expressions/interval.rb +1 -0
  86. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  87. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  88. data/lib/expressir/model/model_element.rb +128 -14
  89. data/lib/expressir/model/remark_format.rb +17 -0
  90. data/lib/expressir/model/remark_info.rb +31 -3
  91. data/lib/expressir/model/remark_placement.rb +29 -0
  92. data/lib/expressir/model/statements/alias.rb +2 -0
  93. data/lib/expressir/model/statements/assignment.rb +4 -0
  94. data/lib/expressir/model/statements/case.rb +21 -0
  95. data/lib/expressir/model/statements/case_action.rb +1 -0
  96. data/lib/expressir/model/statements/compound.rb +4 -0
  97. data/lib/expressir/model/statements/escape.rb +3 -0
  98. data/lib/expressir/model/statements/if.rb +5 -0
  99. data/lib/expressir/model/statements/null.rb +3 -0
  100. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  101. data/lib/expressir/model/statements/repeat.rb +3 -0
  102. data/lib/expressir/model/statements/return.rb +3 -0
  103. data/lib/expressir/model/statements/skip.rb +3 -0
  104. data/lib/expressir/model.rb +12 -3
  105. data/lib/expressir/package/builder.rb +2 -2
  106. data/lib/expressir/version.rb +6 -1
  107. data/lib/expressir.rb +38 -3
  108. metadata +63 -5
  109. 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,111 +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
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
51
  remarks = RemarkScanner.new(@source).scan
60
52
 
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)
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
+ )
65
59
 
66
- attach_tagged_remarks(model, remarks, nodes_with_positions)
67
- attach_untagged_remarks(remarks, nodes_with_positions)
60
+ attach_tagged_remarks(remarks)
61
+ attach_untagged_remarks(remarks)
68
62
 
69
- # Free expensive data structures after attachment is complete.
70
- # These are only needed during the attach process.
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.
71
68
  @source = nil
72
- @source_lines = nil
73
- @scope_map = nil
69
+ @scope_resolver = nil
70
+ @node_index = nil
74
71
  @line_map = nil
75
-
76
- model
72
+ @owner_map = nil
73
+ @active_scope_map = nil
77
74
  end
78
75
 
79
76
  private
80
77
 
81
- # Remark extraction lives in {RemarkScanner}; this class only attaches.
82
- # Line-number lookup is delegated to {LineMap} for O(log n) queries.
78
+ # ----- Tagged remark attachment -----
83
79
 
84
- def source_lines
85
- @source_lines ||= @source.lines
86
- end
87
-
88
- def get_line_number(position)
89
- @line_map.line_number(position)
90
- end
91
-
92
- def attach_tagged_remarks(model, remarks, nodes_with_positions)
93
- tagged = remarks.select { |r| r[:tag] }
80
+ def attach_tagged_remarks(remarks)
81
+ tagged = remarks.select(&:tag)
94
82
  return if tagged.empty?
95
83
 
96
- @model = model
97
-
98
- # Build scope map ONCE: O(file_lines) scan instead of O(n*file_lines) for n remarks
99
- # This is the key optimization that makes scope lookup O(1) per remark
100
- @scope_map ||= build_scope_map
101
-
102
- tagged.sort_by(&:position).each do |remark|
84
+ tagged.each do |remark|
103
85
  next if @attached_spans.include?(remark.position)
104
86
 
105
87
  tag = remark.tag
106
88
  target = nil
107
89
 
108
- # Find containing scope using pre-computed scope map (O(1))
109
- # Falls back to position-based lookup if scope map doesn't have the line
110
- containing_scope = find_containing_scope_by_name(remark.line)
111
- containing_scope ||= find_containing_scope_position(remark.line,
112
- nodes_with_positions)
90
+ containing_scope = @scope_resolver.containing_scope_for(remark.line)
113
91
 
114
92
  # Check if this is an informal proposition tag (IP\d+)
115
93
  if tag.match?(/^IP\d+$/)
116
- scope = containing_scope
117
- if scope.nil?
118
- scope = find_scope_by_source_text(remark.line)
119
- end
94
+ scope = containing_scope || @scope_resolver.find_by_source_text(remark.line)
120
95
  if scope && supports_informal_propositions?(scope)
121
96
  target = create_or_find_informal_proposition(scope, tag)
122
97
  end
@@ -126,31 +101,28 @@ module Expressir
126
101
  if target.nil?
127
102
  # Handle prefixed tags like wr:WR1, ip:IP1, ur:UR1
128
103
  if tag.include?(":") && !tag.include?(".")
129
- target = handle_prefixed_tag(tag, containing_scope, model,
130
- get_schema_ids(model))
104
+ target = handle_prefixed_tag(tag, containing_scope, @model,
105
+ get_schema_ids(@model))
131
106
  end
132
107
 
133
108
  # Strategy 1: Try exact path lookup
134
109
  if target.nil?
135
- target = find_by_exact_path(model, tag)
110
+ target = find_by_exact_path(@model, tag)
136
111
  end
137
112
 
138
113
  # Strategy 1b: For paths with dots, try with scope path prefix first
139
114
  if target.nil? && tag.include?(".")
140
- # First, try building full path from containing scope
141
115
  if containing_scope && function_rule_procedure?(containing_scope)
142
116
  scope_path = build_scope_path(containing_scope)
143
117
  if scope_path
144
- full_path = "#{scope_path}.#{tag}"
145
- target = find_by_exact_path(model, full_path)
118
+ target = find_by_exact_path(@model, "#{scope_path}.#{tag}")
146
119
  end
147
120
  end
148
121
 
149
- # Then try schema prefix
150
122
  if target.nil?
151
- schema_ids = get_schema_ids(model)
123
+ schema_ids = get_schema_ids(@model)
152
124
  schema_ids.each do |schema_id|
153
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
125
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
154
126
  break if target
155
127
  end
156
128
  end
@@ -159,28 +131,24 @@ module Expressir
159
131
  # Strategy 2: For simple tags, find in containing scope first
160
132
  if target.nil? && !tag.include?(".")
161
133
  if containing_scope
162
- # Search within the containing scope
163
134
  target = find_node_in_scope(containing_scope, tag)
164
135
 
165
- # Special handling for remarks inside WHERE clauses
166
136
  if target.nil? && supports_where_rules?(containing_scope)
167
137
  target = find_target_in_where_clause(containing_scope, tag,
168
138
  remark.line)
169
139
  end
170
140
 
171
- # Only fall back to schema prefix if NOT inside a function/rule/procedure
172
141
  if target.nil? && !function_rule_procedure?(containing_scope)
173
- schema_ids = get_schema_ids(model)
142
+ schema_ids = get_schema_ids(@model)
174
143
  schema_ids.each do |schema_id|
175
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
144
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
176
145
  break if target
177
146
  end
178
147
  end
179
148
  else
180
- # No containing scope, try with schema prefix
181
- schema_ids = get_schema_ids(model)
149
+ schema_ids = get_schema_ids(@model)
182
150
  schema_ids.each do |schema_id|
183
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
151
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
184
152
  break if target
185
153
  end
186
154
  end
@@ -188,146 +156,390 @@ module Expressir
188
156
 
189
157
  # Strategy 3: Create implicit item for qualified paths only
190
158
  if target.nil? && tag.include?(".")
191
- # Try with scope path first
192
159
  if containing_scope && function_rule_procedure?(containing_scope)
193
160
  scope_path = build_scope_path(containing_scope)
194
161
  if scope_path
195
- full_path = "#{scope_path}.#{tag}"
196
- target = create_implicit_remark_item(model, full_path,
197
- get_schema_ids(model))
162
+ target = create_implicit_remark_item(@model, "#{scope_path}.#{tag}",
163
+ get_schema_ids(@model))
198
164
  end
199
165
  end
200
- # Fall back to schema prefix
201
166
  if target.nil?
202
- target = create_implicit_remark_item(model, tag,
203
- get_schema_ids(model))
167
+ target = create_implicit_remark_item(@model, tag,
168
+ get_schema_ids(@model))
204
169
  end
205
170
  end
206
171
 
207
172
  # Strategy 4: For simple tags at schema level, create implicit item
208
173
  if target.nil? && !tag.include?(".")
209
- schema_ids = get_schema_ids(model)
174
+ schema_ids = get_schema_ids(@model)
210
175
  if schema_ids.any?
211
- target = create_implicit_remark_item_at_schema(model, tag,
176
+ target = create_implicit_remark_item_at_schema(@model, tag,
212
177
  schema_ids.first)
213
178
  end
214
179
  end
215
180
  end
216
181
 
217
182
  if target
218
- add_remark(target, remark.text, format: remark.format, tag: remark.tag)
183
+ add_remark(target, remark.text, format: remark.format, tag: remark.tag,
184
+ source_offset: remark.position)
219
185
  @attached_spans << remark.position
220
186
  end
221
187
  end
222
188
  end
223
189
 
224
- # Position-based fallback for finding containing scope.
225
- # Used when scope map lookup returns nil (e.g., for remarks at lines
226
- # outside any declared scope's end_line, or for non-scope-containers).
227
- def find_containing_scope_position(remark_line, nodes_with_positions)
228
- containing_nodes = nodes_with_positions.select do |n|
229
- n[:line] && n[:end_line] && remark_line >= n[:line] && remark_line <= n[:end_line] &&
230
- !repository?(n[:node]) && !cache?(n[:node])
231
- end
190
+ # ----- Untagged remark attachment -----
191
+
192
+ def attach_untagged_remarks(remarks)
193
+ untagged = remarks.reject(&:tag)
194
+ return unless untagged.any?
195
+
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
232
209
 
233
- containing_nodes.reverse_each do |n|
234
- node = n[:node]
235
- return node if node.is_a?(Model::ScopeContainer)
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
236
224
  end
225
+ end
237
226
 
238
- nil
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]
239
359
  end
240
360
 
241
- # Done once per RemarkAttacher instance (O(file_lines)).
242
- # Each find_containing_scope call then becomes O(1).
243
- def build_scope_map
244
- lines = source_lines
245
- scope_map = {}
246
- 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
247
377
 
248
- # Track nested scopes by scanning all lines once
249
- 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
250
379
 
251
- lines.each_with_index do |line, idx|
252
- 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
253
388
 
254
- # Check for START keywords first
255
- if line =~ /^\s*SCHEMA\s+(\w+)/i
256
- scope_stack << { type: :schema, name: $1, line: line_num }
257
- 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
258
394
 
259
- if line =~ /^\s*FUNCTION\s+(\w+)/i
260
- scope_stack << { type: :function, name: $1, line: line_num }
261
- end
395
+ active[1]
396
+ end
262
397
 
263
- if line =~ /^\s*PROCEDURE\s+(\w+)/i
264
- scope_stack << { type: :procedure, name: $1, line: line_num }
265
- 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
266
405
 
267
- if line =~ /^\s*RULE\s+(\w+)/i
268
- scope_stack << { type: :rule, name: $1, line: line_num }
269
- 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?("--")
270
413
 
271
- if line =~ /^\s*ENTITY\s+(\w+)/i
272
- 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
273
416
  end
417
+ end
418
+ map
419
+ end
274
420
 
275
- if line =~ /^\s*TYPE\s+(\w+)/i
276
- 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]
277
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
278
437
 
279
- # Check for END keywords (inline closures on same line handled here)
280
- if (line =~ /END_TYPE/i) && (scope_stack.last&.dig(:type) == :type)
281
- scope_stack.pop
282
- end
283
- if (line =~ /END_FUNCTION/i) && (scope_stack.last&.dig(:type) == :function)
284
- scope_stack.pop
285
- end
286
- if (line =~ /END_PROCEDURE/i) && (scope_stack.last&.dig(:type) == :procedure)
287
- scope_stack.pop
288
- end
289
- if (line =~ /END_RULE/i) && (scope_stack.last&.dig(:type) == :rule)
290
- scope_stack.pop
291
- end
292
- if (line =~ /END_ENTITY/i) && (scope_stack.last&.dig(:type) == :entity)
293
- scope_stack.pop
294
- end
295
- if (line =~ /END_SCHEMA/i) && (scope_stack.last&.dig(:type) == :schema)
296
- scope_stack.pop
297
- 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?("--")
298
445
 
299
- # Record the innermost scope for this line
300
- scope_map[line_num] = scope_stack.last&.dig(:name)
446
+ probe += 1
301
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
302
454
 
303
- scope_map
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
462
+
463
+ children = nodes.select do |n|
464
+ n[:owner].equal?(enclosing[:node]) &&
465
+ STATEMENT_REGIONS.include?(n[:collection]) && n[:line]
466
+ end
467
+ return [enclosing, nil, nil] if children.empty?
468
+
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]
304
475
  end
305
476
 
306
- # O(1) scope lookup using pre-computed scope map
307
- def find_containing_scope_by_name(remark_line)
308
- 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
309
484
 
310
- scope_name = @scope_map[remark_line]
311
- 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
312
494
 
313
- # Find the model node for this scope
314
- 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
498
+
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
315
510
 
316
- @model.schemas.each do |schema|
317
- return schema if schema.id == scope_name
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
318
518
 
319
- %i[functions procedures rules entities types].each do |decl_type|
320
- collection = schema.public_send(decl_type)
321
- 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
322
527
 
323
- found = collection.find { |n| n.id == scope_name }
324
- 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)
325
534
  end
535
+ return following_attr if opener_line && line > opener_line
326
536
  end
327
537
 
328
- nil
538
+ preceding[:collection]
329
539
  end
330
540
 
541
+ # ----- Tag resolution (within a scope) -----
542
+
331
543
  def find_node_in_scope(scope, tag)
332
544
  return nil unless scope
333
545
 
@@ -338,20 +550,17 @@ module Expressir
338
550
  end
339
551
  end
340
552
 
341
- # Search inside types for enumeration items
342
553
  types = get_collection(scope, :types)
343
554
  types&.each do |type|
344
555
  result = find_enumeration_item_in_type(type, tag)
345
556
  return result if result
346
557
  end
347
558
 
348
- # Search inside statements for nested items (alias, repeat, query)
349
559
  statements = get_collection(scope, :statements)
350
560
  statements&.each do |stmt|
351
561
  result = find_node_in_statement(stmt, tag)
352
562
  return result if result
353
563
 
354
- # Search inside expressions for QueryExpression (nested in assignments, etc.)
355
564
  result = find_query_in_expression(stmt, tag)
356
565
  return result if result
357
566
  end
@@ -361,45 +570,19 @@ module Expressir
361
570
 
362
571
  def find_enumeration_item_in_type(type, tag)
363
572
  return nil unless type
573
+ return nil unless type.is_a?(Model::Declarations::Type)
364
574
 
365
- # Check if type is a Type declaration with enumeration
366
- if type.is_a?(Model::Declarations::Type)
367
- # Check enumeration_items on the type itself
368
- type.enumeration_items&.each do |item|
369
- return item if item.id == tag
370
- end
371
-
372
- # Also check underlying_type if it's an enumeration
373
- ut = type.underlying_type
374
- if ut.is_a?(Model::DataTypes::Enumeration) && ut.items
375
- ut.items.each do |item|
376
- return item if item.id == tag
377
- end
378
- end
575
+ type.enumeration_items&.each do |item|
576
+ return item if item.id == tag
379
577
  end
380
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 }
381
583
  nil
382
584
  end
383
585
 
384
- # Expression and statement child attributes for QueryExpression search.
385
- # Targeted traversal prevents over-matching on unrelated model attributes.
386
- EXPRESSION_CHILDREN = {
387
- Model::Expressions::BinaryExpression => %i[operand1 operand2],
388
- Model::Expressions::UnaryExpression => %i[operand],
389
- Model::Expressions::QueryExpression => %i[expression aggregate_source],
390
- Model::Expressions::AggregateInitializerItem => %i[expression
391
- repetition],
392
- Model::Expressions::Interval => %i[low item high],
393
- Model::Expressions::FunctionCall => %i[parameters],
394
- Model::Expressions::EntityConstructor => %i[parameters],
395
- Model::Expressions::AggregateInitializer => %i[items],
396
- Model::Statements::Assignment => %i[expression],
397
- Model::Statements::If => %i[expression],
398
- Model::Statements::Case => %i[expression],
399
- Model::Statements::CaseAction => %i[expression],
400
- Model::Statements::Repeat => %i[while_expression until_expression],
401
- }.freeze
402
-
403
586
  def find_query_in_expression(node, tag, visited = Set.new)
404
587
  return nil unless node
405
588
  return nil unless node.is_a?(Model::ModelElement)
@@ -407,9 +590,7 @@ module Expressir
407
590
 
408
591
  visited.add(node.object_id)
409
592
 
410
- if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
411
- return node
412
- end
593
+ return node if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
413
594
 
414
595
  attrs = EXPRESSION_CHILDREN[node.class]
415
596
  return nil unless attrs
@@ -446,7 +627,6 @@ module Expressir
446
627
  prefix, id = tag.split(":")
447
628
  return nil unless id
448
629
 
449
- # Determine collection based on prefix
450
630
  collection_attr = case prefix.downcase
451
631
  when "wr" then :where_rules
452
632
  when "ip" then :informal_propositions
@@ -454,14 +634,12 @@ module Expressir
454
634
  end
455
635
  return nil unless collection_attr
456
636
 
457
- # First try to find in containing scope
458
637
  collection = get_collection(containing_scope, collection_attr)
459
638
  if collection
460
639
  found = collection.find { |item| item.is_a?(Model::ModelElement) && item.id == id }
461
640
  return found if found
462
641
  end
463
642
 
464
- # Fallback: try to find by full path
465
643
  schema_ids.each do |schema_id|
466
644
  full_path = "#{schema_id}.#{tag.tr(':', '.')}"
467
645
  found = safe_find(model, full_path)
@@ -471,30 +649,25 @@ module Expressir
471
649
  nil
472
650
  end
473
651
 
474
- # 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.
475
655
  def find_target_in_where_clause(scope, tag, remark_line)
476
656
  return nil unless supports_where_rules?(scope)
477
657
 
478
658
  where_rules = get_collection(scope, :where_rules)
479
659
  return nil unless where_rules&.any?
480
660
 
481
- # Search source text for WHERE clause containing this remark
482
- lines = source_lines
661
+ lines = source_lines_for_where_clause
483
662
 
484
663
  where_rules.each do |wr|
485
664
  next unless wr.id
486
665
 
487
- # Find the WHERE rule declaration
488
666
  lines.each_with_index do |line, idx|
489
667
  line_num = idx + 1
490
668
  next unless line_num < remark_line
491
669
 
492
- # Look for "WHERE {id}:" pattern
493
- # Check if remark is within a few lines after this WHERE declaration
494
- if (line =~ /^\s*WHERE\s+#{Regexp.escape(wr.id)}\s*:/i) && remark_line.between?(
495
- line_num, line_num + 5
496
- )
497
- # 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)
498
671
  return create_remark_item(wr, tag)
499
672
  end
500
673
  end
@@ -503,6 +676,11 @@ module Expressir
503
676
  nil
504
677
  end
505
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
+
506
684
  def find_node_in_statement(stmt, tag)
507
685
  case stmt
508
686
  when Model::Statements::Alias
@@ -515,15 +693,6 @@ module Expressir
515
693
  nil
516
694
  end
517
695
 
518
- def find_containing_scope(remark_line, nodes_with_positions)
519
- # First try scope map (O(1) once built)
520
- scope = find_containing_scope_by_name(remark_line)
521
- return scope if scope
522
-
523
- # Fallback to position-based detection
524
- find_containing_scope_position(remark_line, nodes_with_positions)
525
- end
526
-
527
696
  def build_scope_path(node)
528
697
  return nil unless node
529
698
 
@@ -535,7 +704,6 @@ module Expressir
535
704
  parts.unshift(current.id)
536
705
  end
537
706
 
538
- # Stop at schema level
539
707
  break if current.is_a?(Model::Declarations::Schema)
540
708
 
541
709
  current = current.parent
@@ -544,113 +712,33 @@ module Expressir
544
712
  parts.empty? ? nil : parts.join(".")
545
713
  end
546
714
 
547
- def find_scope_by_source_text(remark_line)
548
- # Search backwards from remark_line for containing scope
549
- lines = source_lines
550
-
551
- # Find the entity/type/rule that contains this line
552
- entity_start = nil
553
- type_start = nil
554
- rule_start = nil
555
- current_entity = nil
556
- current_type = nil
557
- current_rule = nil
558
-
559
- lines.each_with_index do |line, idx|
560
- line_num = idx + 1
561
-
562
- case line
563
- when /^\s*ENTITY\s+(\w+)/i
564
- entity_start = line_num
565
- current_entity = $1
566
- when /^\s*END_ENTITY/i
567
- if entity_start && remark_line >= entity_start && remark_line <= line_num
568
- # Found containing entity
569
- return find_node_by_type_and_name(Model::Declarations::Entity,
570
- current_entity)
571
- end
572
-
573
- entity_start = nil
574
- current_entity = nil
575
- when /^\s*TYPE\s+(\w+)/i
576
- type_start = line_num
577
- current_type = $1
578
- when /^\s*END_TYPE/i
579
- if type_start && remark_line >= type_start && remark_line <= line_num
580
- # Found containing type
581
- return find_node_by_type_and_name(Model::Declarations::Type,
582
- current_type)
583
- end
584
-
585
- type_start = nil
586
- current_type = nil
587
- when /^\s*RULE\s+(\w+)/i
588
- rule_start = line_num
589
- current_rule = $1
590
- when /^\s*END_RULE/i
591
- if rule_start && remark_line >= rule_start && remark_line <= line_num
592
- # Found containing rule
593
- return find_node_by_type_and_name(Model::Declarations::Rule,
594
- current_rule)
595
- end
596
-
597
- rule_start = nil
598
- current_rule = nil
599
- end
600
- end
601
-
602
- nil
603
- end
604
-
605
- COLLECTION_ACCESSOR = {
606
- Expressir::Model::Declarations::Entity => lambda(&:entities),
607
- Expressir::Model::Declarations::Type => lambda(&:types),
608
- Expressir::Model::Declarations::Rule => lambda(&:rules),
609
- }.freeze
610
-
611
- def find_node_by_type_and_name(node_class, name)
612
- return nil unless @model && name
613
-
614
- accessor = COLLECTION_ACCESSOR[node_class]
615
- return nil unless accessor
616
-
617
- @model.schemas.each do |schema|
618
- found = accessor.call(schema)&.find { |n| n.id == name }
619
- return found if found
620
- end
621
-
622
- nil
623
- end
715
+ # ----- Path-based lookup -----
624
716
 
625
717
  def find_by_exact_path(model, path)
626
718
  return nil unless path
627
-
628
- # Only Repository and ExpFile support path-based find
629
719
  return nil unless repository?(model) || exp_file?(model)
630
720
 
631
- # Try original path
632
721
  result = safe_find(model, path)
633
722
  return result if result
634
723
 
635
- # Try with colon converted to dot
636
724
  normalized = path.tr(":", ".")
637
725
  normalized == path ? nil : safe_find(model, normalized)
638
726
  end
639
727
 
728
+ # ----- Target creation -----
729
+
640
730
  def create_implicit_remark_item_at_schema(model, item_id, schema_id)
641
- # Only Repository and ExpFile support schema lookup
642
731
  return nil unless repository?(model) || exp_file?(model)
643
732
 
644
733
  schema = safe_find(model, schema_id)
645
734
  return nil unless schema.is_a?(Model::Declarations::Schema)
646
735
 
647
- # Handle informal propositions (IP\d+ pattern) - only if schema supports it
648
- # Note: Schema doesn't have informal_propositions, so this will create a remark_item instead
649
- if item_id.match?(/^IP\d+$/) && supports_informal_propositions?(schema)
650
- 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)
651
740
  end
652
741
 
653
- # Handle remark items
654
742
  return nil unless supports_remark_items?(schema)
655
743
 
656
744
  existing = schema.remark_items&.find { |ri| ri.id == item_id }
@@ -662,19 +750,35 @@ module Expressir
662
750
  def create_implicit_remark_item(model, path, schema_ids = [])
663
751
  return nil unless repository?(model) || exp_file?(model)
664
752
 
665
- # 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
+
666
772
  clean_path = normalize_path(path)
667
773
  parts = clean_path.split(".")
668
774
  return nil if parts.length < 2
669
775
 
670
- # Find the deepest existing parent and create item there
671
776
  (parts.length - 1).downto(1) do |i|
672
777
  parent_path = parts[0...i].join(".")
673
778
  item_id = parts[i]
674
779
 
675
780
  parent = safe_find(model, parent_path)
676
781
 
677
- # Try with schema prefix if not found
678
782
  if parent.nil? && schema_ids.any?
679
783
  schema_ids.each do |schema_id|
680
784
  parent = safe_find(model, "#{schema_id}.#{parent_path}")
@@ -698,12 +802,12 @@ module Expressir
698
802
  end
699
803
 
700
804
  def create_item_at_parent(parent, item_id)
701
- # Handle informal propositions
702
- if item_id.match?(/^IP\d+$/) && supports_informal_propositions?(parent)
703
- 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)
704
809
  end
705
810
 
706
- # Handle remark items
707
811
  return nil unless supports_remark_items?(parent)
708
812
 
709
813
  existing = parent.remark_items&.find { |ri| ri.id == item_id }
@@ -713,7 +817,6 @@ module Expressir
713
817
  end
714
818
 
715
819
  def create_or_find_informal_proposition(parent, id)
716
- # Only Entity, Rule, Type, and InformalPropositionRule have informal_propositions
717
820
  return nil unless supports_informal_propositions?(parent)
718
821
 
719
822
  existing = parent.informal_propositions&.find { |ip| ip.id == id }
@@ -725,15 +828,12 @@ module Expressir
725
828
  parent.informal_propositions << ip
726
829
  safe_reset_children_by_id(parent)
727
830
 
728
- # Also create a RemarkItem inside the InformalPropositionRule
729
- # This is the expected structure for informal proposition remarks
730
831
  remark_item = Model::Declarations::RemarkItem.new(id: id)
731
832
  remark_item.parent = ip
732
833
  ip.remark_items ||= []
733
834
  ip.remark_items << remark_item
734
835
  safe_reset_children_by_id(ip)
735
836
 
736
- # Return the remark_item so remarks are added to it
737
837
  remark_item
738
838
  end
739
839
 
@@ -746,92 +846,32 @@ module Expressir
746
846
  item
747
847
  end
748
848
 
749
- def attach_untagged_remarks(remarks, nodes_with_positions)
750
- untagged = remarks.reject(&:tag)
751
- return unless untagged.any?
752
-
753
- untagged.each do |remark|
754
- next if @attached_spans.include?(remark.position)
755
-
756
- if end_scope_line?(remark.line)
757
- matched_node = find_node_for_end_scope_remark(remark,
758
- nodes_with_positions)
759
- if matched_node
760
- add_remark(matched_node, remark.text, format: remark.format, tag: nil)
761
- @attached_spans << remark.position
762
- next
763
- end
764
- end
765
-
766
- matched_node = find_nearest_node(remark, nodes_with_positions)
767
- if matched_node
768
- add_remark(matched_node, remark.text, format: remark.format, tag: nil)
769
- @attached_spans << remark.position
770
- end
771
- end
772
- end
773
-
774
- def end_scope_line?(line_num)
775
- line = get_line_content(line_num)
776
- line =~ /END_(SCHEMA|ENTITY|TYPE|FUNCTION|PROCEDURE|RULE)/i
777
- end
778
-
779
- def get_line_content(line_num)
780
- lines = source_lines
781
- return "" if line_num < 1 || line_num > lines.length
782
-
783
- lines[line_num - 1]
784
- end
785
-
786
- def find_node_for_end_scope_remark(remark, nodes)
787
- line_content = get_line_content(remark.line)
788
-
789
- node_type = case line_content
790
- when /END_SCHEMA/i then Model::Declarations::Schema
791
- when /END_ENTITY/i then Model::Declarations::Entity
792
- when /END_TYPE/i then Model::Declarations::Type
793
- when /END_FUNCTION/i then Model::Declarations::Function
794
- when /END_PROCEDURE/i then Model::Declarations::Procedure
795
- when /END_RULE/i then Model::Declarations::Rule
796
- end
797
-
798
- return nil unless node_type
799
-
800
- matching_nodes = nodes.select do |n|
801
- n[:node].is_a?(node_type) &&
802
- (n[:end_line] == remark.line ||
803
- (n[:end_line] && n[:end_line] <= remark.line && n[:end_line] >= remark.line - 2))
804
- end
805
-
806
- matching_nodes.first&.dig(:node) || find_node_by_type(nodes, node_type)
807
- end
808
-
809
- def find_node_by_type(nodes, node_type)
810
- nodes.find { |n| n[:node].is_a?(node_type) }&.dig(:node)
811
- end
849
+ # ----- Remark storage -----
812
850
 
813
- 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)
814
853
  return unless node
815
854
  return unless node.is_a?(Model::ModelElement)
816
855
 
817
- # Only add remarks to nodes that support them
818
856
  if supports_remarks?(node)
819
- # Always add to remarks attribute (for types that have it)
820
857
  if node_has_remarks?(node)
821
858
  node.remarks ||= []
822
859
  node.remarks << text
823
860
  end
824
861
 
825
862
  if tag.nil?
826
- # Untagged remark: store in untagged_remarks
827
- 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)
828
867
  node.untagged_remarks ||= []
829
868
  node.untagged_remarks << remark_info
830
869
  end
831
870
  end
832
871
  end
833
872
 
834
- # All ModelElement subclasses have untagged_remarks from ModelElement
873
+ # ----- Type predicates -----
874
+
835
875
  def supports_remarks?(obj)
836
876
  obj.is_a?(Model::ModelElement)
837
877
  end
@@ -840,180 +880,23 @@ module Expressir
840
880
  obj.is_a?(Model::HasRemarks)
841
881
  end
842
882
 
843
- # Types that include HasRemarkItems can have remark_items
844
883
  def supports_remark_items?(obj)
845
884
  obj.is_a?(Model::HasRemarkItems)
846
885
  end
847
886
 
848
- def collect_nodes_with_positions(node, result, visited = Set.new)
849
- return unless node
850
- return if visited.include?(node.object_id)
851
-
852
- visited.add(node.object_id)
853
-
854
- if node.is_a?(Model::ModelElement) && node.source
855
- # Use stored source_offset from parser
856
- # The parser always provides this via Slice#offset
857
- if node.source_offset
858
- pos = node.source_offset
859
- # Validate offset: native parser returns 0 for leaf nodes (WhereRule)
860
- # where it can't determine the actual position. These have short
861
- # expression-like source ("TRUE;") that doesn't appear at file start.
862
- # Container nodes (Schema, Entity, Type) have declaration-like source
863
- # that either starts at position 0 legitimately or is clearly valid.
864
- valid = pos.positive?
865
- if !valid && pos.zero? && node.source
866
- src = node.source.to_s
867
- # Accept position=0 if source is a declaration keyword line
868
- valid = src.start_with?("SCHEMA", "ENTITY", "TYPE", "FUNCTION",
869
- "PROCEDURE", "RULE", "CONSTANT", "VARIABLE",
870
- "USE", "REFERENCE", "END_SCHEMA", "END_ENTITY",
871
- "END_TYPE", "END_FUNCTION", "END_PROCEDURE",
872
- "END_RULE", "END_CONSTANT", "END_VARIABLE")
873
- end
874
- if valid
875
- line = get_line_number(pos)
876
- source_end_line = get_line_number(pos + node.source.length)
877
-
878
- # For container nodes, use the maximum end_line from children
879
- # This is needed because source.length only covers the declaration, not the body
880
- children_end_line = calculate_children_end_line(node)
881
- end_line = [source_end_line,
882
- children_end_line].compact.max || source_end_line
883
-
884
- result << {
885
- node: node,
886
- position: pos,
887
- line: line,
888
- end_line: end_line,
889
- }
890
- else
891
- # Invalid offset — treat as unknown position
892
- result << { node: node, position: nil, line: nil, end_line: nil }
893
- end
894
- else
895
- # No source_offset available - should not happen if parser provides Slice
896
- result << { node: node, position: nil, line: nil, end_line: nil }
897
- end
898
- else
899
- result << { node: node, position: nil, line: nil, end_line: nil }
900
- end
901
-
902
- collect_children(node, result, visited)
903
- end
904
-
905
- # Calculate the end line from all children of a node
906
- # This is needed for container nodes like schemas, entities, etc.
907
- # where source.length only covers the declaration, not the body
908
- def calculate_children_end_line(node)
909
- children_end_lines = []
910
-
911
- # Check computed children (Schema, ExpFile have a children method)
912
- if node.is_a?(Model::Declarations::Schema)
913
- Array(node.children).each do |child|
914
- if child.is_a?(Model::ModelElement) && child.source_offset && child.source
915
- children_end_lines << get_line_number(child.source_offset + child.source.length)
916
- end
917
- end
918
- end
919
-
920
- # Visit declared collections from type registry
921
- collections_on(node).each do |collection|
922
- collection.each do |child|
923
- if child.is_a?(Model::ModelElement) && child.source_offset && child.source
924
- children_end_lines << get_line_number(child.source_offset + child.source.length)
925
- end
926
- end
927
- end
928
-
929
- children_end_lines.max
930
- end
931
-
932
- def collect_children(node, result, visited)
933
- if node.is_a?(Model::Declarations::Schema)
934
- Array(node.children).each do |child|
935
- collect_nodes_with_positions(child, result, visited)
936
- end
937
- end
938
-
939
- collections_on(node).each do |collection|
940
- collection.each do |item|
941
- collect_nodes_with_positions(item, result, visited)
942
- end
943
- end
887
+ def supports_informal_propositions?(obj)
888
+ obj.is_a?(Model::HasInformalPropositions)
944
889
  end
945
890
 
946
- # Build sorted nodes_with_positions ONCE for both tagged and untagged remark passes.
947
- # This merges the two separate tree walks into one, cutting node visits in half.
948
- def build_sorted_nodes_with_positions(model)
949
- nodes_with_positions = []
950
- collect_nodes_with_positions(model, nodes_with_positions)
951
- # Stable sort: nil positions last, ties broken by insertion order
952
- nodes_with_positions.sort_by!.with_index { |n, i| [n[:position] || Float::INFINITY, i] }
953
- nodes_with_positions
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
954
896
  end
955
897
 
956
- def find_nearest_node(remark, nodes)
957
- remark_line = remark.line
958
-
959
- # For tail remarks, prefer nodes that START on the same line
960
- # This handles cases like: "attr : STRING; -- tail remark"
961
- # Exclude Repository and Cache as they are not semantic scopes
962
- same_start_line = nodes.select do |n|
963
- n[:line] == remark_line &&
964
- !repository?(n[:node]) && !cache?(n[:node])
965
- end
966
- return same_start_line.last[:node] if same_start_line.any?
967
-
968
- # Also check nodes that END on the same line
969
- same_end_line = nodes.select do |n|
970
- n[:end_line] == remark_line &&
971
- !repository?(n[:node]) && !cache?(n[:node])
972
- end
973
- return same_end_line.last[:node] if same_end_line.any?
974
-
975
- # Find the node that CONTAINS this remark line
976
- # This handles preamble remarks and embedded remarks
977
- # Exclude Repository and Cache as they are not semantic scopes
978
- # But include ExpFile for file-level preamble remarks
979
- containing = nodes.select do |n|
980
- n[:line] && n[:end_line] && n[:line] <= remark_line && n[:end_line] >= remark_line &&
981
- !repository?(n[:node]) && !cache?(n[:node])
982
- end
983
-
984
- if containing.any?
985
- # Prefer ExpFile for preamble remarks (before first schema)
986
- # Otherwise return the most specific (smallest) containing node
987
- exp_file_node = containing.find { |n| exp_file?(n[:node]) }
988
- # If this is a preamble remark (before first schema line), use ExpFile
989
- if exp_file_node
990
- first_schema_line = exp_file_node[:node].schemas&.first&.source_offset
991
- if first_schema_line && remark_line < get_line_number(first_schema_line)
992
- return exp_file_node[:node]
993
- end
994
- end
995
- # Sort by span size and return the smallest
996
- containing.min_by { |n| n[:end_line] - n[:line] }[:node]
997
- else
998
- # Fallback: find the last node that ends before this line
999
- before = nodes.select do |n|
1000
- n[:end_line] && n[:end_line] < remark_line &&
1001
- !repository?(n[:node]) && !cache?(n[:node])
1002
- end
1003
- before.max_by { |n| n[:end_line] }[:node] if before.any?
1004
- end
1005
- end
1006
-
1007
- # Type checking helper methods
1008
-
1009
- def get_schema_ids(model)
1010
- if repository?(model)
1011
- model.schemas.filter_map(&:id)
1012
- elsif exp_file?(model)
1013
- model.schemas.filter_map(&:id)
1014
- else
1015
- []
1016
- end
898
+ def supports_where_rules?(obj)
899
+ obj.is_a?(Model::HasWhereRules)
1017
900
  end
1018
901
 
1019
902
  def repository?(obj)
@@ -1028,13 +911,15 @@ module Expressir
1028
911
  obj.is_a?(Model::Cache)
1029
912
  end
1030
913
 
1031
- def supports_informal_propositions?(obj)
1032
- 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
1033
920
  end
1034
921
 
1035
- def supports_where_rules?(obj)
1036
- obj.is_a?(Model::HasWhereRules)
1037
- end
922
+ # ----- Collection access -----
1038
923
 
1039
924
  # Type-driven collection access — returns all collections for a node's type.
1040
925
  def collections_on(node)
@@ -1055,6 +940,8 @@ module Expressir
1055
940
  collection if collection.is_a?(Array)
1056
941
  end
1057
942
 
943
+ # ----- Helpers -----
944
+
1058
945
  def safe_find(model, path)
1059
946
  return nil unless model
1060
947
 
@@ -1069,6 +956,17 @@ module Expressir
1069
956
 
1070
957
  obj.reset_children_by_id
1071
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
1072
970
  end
1073
971
  end
1074
972
  end