expressir 2.4.0 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) 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/TODO.max-perf/01-restore-ci-green.md +29 -0
  34. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  35. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  36. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  37. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  38. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  39. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  40. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  41. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  42. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  43. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  44. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  45. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  46. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  47. data/benchmark/srl_benchmark.rb +76 -17
  48. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  49. data/docs/_tutorials/index.adoc +10 -0
  50. data/docs/lychee.toml +3 -0
  51. data/expressir.gemspec +4 -2
  52. data/lib/expressir/cli.rb +3 -0
  53. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  54. data/lib/expressir/commands/changes_validate.rb +0 -2
  55. data/lib/expressir/commands/clean.rb +1 -1
  56. data/lib/expressir/commands/coverage.rb +6 -2
  57. data/lib/expressir/commands/file_violations.rb +70 -0
  58. data/lib/expressir/commands/format.rb +1 -1
  59. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  60. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  61. data/lib/expressir/commands/package.rb +143 -187
  62. data/lib/expressir/commands/validate_ascii.rb +0 -510
  63. data/lib/expressir/commands/version.rb +1 -1
  64. data/lib/expressir/commands.rb +3 -0
  65. data/lib/expressir/coverage.rb +49 -117
  66. data/lib/expressir/express/ast_key_converter.rb +114 -0
  67. data/lib/expressir/express/builder.rb +71 -194
  68. data/lib/expressir/express/builder_context.rb +22 -0
  69. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  70. data/lib/expressir/express/cache.rb +35 -8
  71. data/lib/expressir/express/error.rb +24 -0
  72. data/lib/expressir/express/formatter.rb +33 -18
  73. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  74. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  75. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  76. data/lib/expressir/express/grammar/parser.rb +705 -0
  77. data/lib/expressir/express/grammar.rb +11 -0
  78. data/lib/expressir/express/node_position_index.rb +215 -0
  79. data/lib/expressir/express/parallel_files.rb +229 -0
  80. data/lib/expressir/express/parser.rb +138 -950
  81. data/lib/expressir/express/pretty_formatter.rb +23 -5
  82. data/lib/expressir/express/remark_attacher.rb +497 -581
  83. data/lib/expressir/express/remark_scanner.rb +84 -19
  84. data/lib/expressir/express/schema_block_scanner.rb +137 -0
  85. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  86. data/lib/expressir/express/scope_resolver.rb +223 -0
  87. data/lib/expressir/express/source_formatter.rb +15 -0
  88. data/lib/expressir/express/streaming_builder.rb +147 -176
  89. data/lib/expressir/express.rb +10 -6
  90. data/lib/expressir/model/concerns.rb +3 -0
  91. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  92. data/lib/expressir/model/declarations/entity.rb +5 -0
  93. data/lib/expressir/model/declarations/function.rb +5 -0
  94. data/lib/expressir/model/declarations/procedure.rb +5 -0
  95. data/lib/expressir/model/declarations/rule.rb +6 -0
  96. data/lib/expressir/model/declarations/schema.rb +21 -8
  97. data/lib/expressir/model/declarations/type.rb +3 -0
  98. data/lib/expressir/model/exp_file.rb +2 -0
  99. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  100. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  101. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  102. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  103. data/lib/expressir/model/expressions/function_call.rb +1 -0
  104. data/lib/expressir/model/expressions/interval.rb +1 -0
  105. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  106. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  107. data/lib/expressir/model/model_element.rb +133 -14
  108. data/lib/expressir/model/remark_format.rb +17 -0
  109. data/lib/expressir/model/remark_info.rb +31 -3
  110. data/lib/expressir/model/remark_placement.rb +29 -0
  111. data/lib/expressir/model/repository.rb +18 -5
  112. data/lib/expressir/model/statements/alias.rb +2 -0
  113. data/lib/expressir/model/statements/assignment.rb +4 -0
  114. data/lib/expressir/model/statements/case.rb +21 -0
  115. data/lib/expressir/model/statements/case_action.rb +1 -0
  116. data/lib/expressir/model/statements/compound.rb +4 -0
  117. data/lib/expressir/model/statements/escape.rb +3 -0
  118. data/lib/expressir/model/statements/if.rb +5 -0
  119. data/lib/expressir/model/statements/null.rb +3 -0
  120. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  121. data/lib/expressir/model/statements/repeat.rb +3 -0
  122. data/lib/expressir/model/statements/return.rb +3 -0
  123. data/lib/expressir/model/statements/skip.rb +3 -0
  124. data/lib/expressir/model.rb +12 -3
  125. data/lib/expressir/package/builder.rb +2 -2
  126. data/lib/expressir/version.rb +6 -1
  127. data/lib/expressir.rb +56 -3
  128. metadata +81 -7
  129. 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,84 @@ 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
37
+
38
+ # `WHERE <label> :` clause headers; the captured label maps to the
39
+ # 1-based line number(s) it appears on.
40
+ WHERE_CLAUSE_PATTERN = /\A\s*WHERE\s+(\w+)\s*:/i
47
41
 
48
42
  def initialize(source)
49
43
  @source = source
50
44
  @attached_spans = Set.new
51
45
  @line_map = LineMap.new(source.b)
52
46
  @model = nil
53
- @source_lines = nil # cached @source.lines
54
- @scope_map = nil # cached scope at each line number
47
+ @scope_resolver = nil
48
+ @node_index = nil
49
+ @owner_map = nil
50
+ @active_scope_map = nil
55
51
  end
56
52
 
57
53
  def attach(model)
58
54
  @model = model
59
55
  remarks = RemarkScanner.new(@source).scan
60
56
 
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)
57
+ @node_index = NodePositionIndex.new(model, @line_map)
58
+ @scope_resolver = ScopeResolver.new(
59
+ source: @source,
60
+ model: model,
61
+ nodes_with_positions: @node_index.nodes,
62
+ )
65
63
 
66
- attach_tagged_remarks(model, remarks, nodes_with_positions)
67
- attach_untagged_remarks(remarks, nodes_with_positions)
64
+ attach_tagged_remarks(remarks)
65
+ attach_untagged_remarks(remarks)
68
66
 
69
- # Free expensive data structures after attachment is complete.
70
- # These are only needed during the attach process.
67
+ model
68
+ ensure
69
+ # Free expensive data structures once attachment is over. On the
70
+ # raising path this also drops the memoized ownership map, which
71
+ # would otherwise outlive the node index it was derived from.
71
72
  @source = nil
72
- @source_lines = nil
73
- @scope_map = nil
73
+ @scope_resolver = nil
74
+ @node_index = nil
74
75
  @line_map = nil
75
-
76
- model
76
+ @owner_map = nil
77
+ @active_scope_map = nil
78
+ @source_lines_for_where_clause = nil
79
+ @where_clause_line_index = nil
77
80
  end
78
81
 
79
82
  private
80
83
 
81
- # Remark extraction lives in {RemarkScanner}; this class only attaches.
82
- # Line-number lookup is delegated to {LineMap} for O(log n) queries.
83
-
84
- def source_lines
85
- @source_lines ||= @source.lines
86
- end
84
+ # ----- Tagged remark attachment -----
87
85
 
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] }
86
+ def attach_tagged_remarks(remarks)
87
+ tagged = remarks.select(&:tag)
94
88
  return if tagged.empty?
95
89
 
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|
90
+ tagged.each do |remark|
103
91
  next if @attached_spans.include?(remark.position)
104
92
 
105
93
  tag = remark.tag
106
94
  target = nil
107
95
 
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)
96
+ containing_scope = @scope_resolver.containing_scope_for(remark.line)
113
97
 
114
98
  # Check if this is an informal proposition tag (IP\d+)
115
99
  if tag.match?(/^IP\d+$/)
116
- scope = containing_scope
117
- if scope.nil?
118
- scope = find_scope_by_source_text(remark.line)
119
- end
100
+ scope = containing_scope || @scope_resolver.find_by_source_text(remark.line)
120
101
  if scope && supports_informal_propositions?(scope)
121
102
  target = create_or_find_informal_proposition(scope, tag)
122
103
  end
@@ -126,31 +107,28 @@ module Expressir
126
107
  if target.nil?
127
108
  # Handle prefixed tags like wr:WR1, ip:IP1, ur:UR1
128
109
  if tag.include?(":") && !tag.include?(".")
129
- target = handle_prefixed_tag(tag, containing_scope, model,
130
- get_schema_ids(model))
110
+ target = handle_prefixed_tag(tag, containing_scope, @model,
111
+ get_schema_ids(@model))
131
112
  end
132
113
 
133
114
  # Strategy 1: Try exact path lookup
134
115
  if target.nil?
135
- target = find_by_exact_path(model, tag)
116
+ target = find_by_exact_path(@model, tag)
136
117
  end
137
118
 
138
119
  # Strategy 1b: For paths with dots, try with scope path prefix first
139
120
  if target.nil? && tag.include?(".")
140
- # First, try building full path from containing scope
141
121
  if containing_scope && function_rule_procedure?(containing_scope)
142
122
  scope_path = build_scope_path(containing_scope)
143
123
  if scope_path
144
- full_path = "#{scope_path}.#{tag}"
145
- target = find_by_exact_path(model, full_path)
124
+ target = find_by_exact_path(@model, "#{scope_path}.#{tag}")
146
125
  end
147
126
  end
148
127
 
149
- # Then try schema prefix
150
128
  if target.nil?
151
- schema_ids = get_schema_ids(model)
129
+ schema_ids = get_schema_ids(@model)
152
130
  schema_ids.each do |schema_id|
153
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
131
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
154
132
  break if target
155
133
  end
156
134
  end
@@ -159,28 +137,24 @@ module Expressir
159
137
  # Strategy 2: For simple tags, find in containing scope first
160
138
  if target.nil? && !tag.include?(".")
161
139
  if containing_scope
162
- # Search within the containing scope
163
140
  target = find_node_in_scope(containing_scope, tag)
164
141
 
165
- # Special handling for remarks inside WHERE clauses
166
142
  if target.nil? && supports_where_rules?(containing_scope)
167
143
  target = find_target_in_where_clause(containing_scope, tag,
168
144
  remark.line)
169
145
  end
170
146
 
171
- # Only fall back to schema prefix if NOT inside a function/rule/procedure
172
147
  if target.nil? && !function_rule_procedure?(containing_scope)
173
- schema_ids = get_schema_ids(model)
148
+ schema_ids = get_schema_ids(@model)
174
149
  schema_ids.each do |schema_id|
175
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
150
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
176
151
  break if target
177
152
  end
178
153
  end
179
154
  else
180
- # No containing scope, try with schema prefix
181
- schema_ids = get_schema_ids(model)
155
+ schema_ids = get_schema_ids(@model)
182
156
  schema_ids.each do |schema_id|
183
- target = find_by_exact_path(model, "#{schema_id}.#{tag}")
157
+ target = find_by_exact_path(@model, "#{schema_id}.#{tag}")
184
158
  break if target
185
159
  end
186
160
  end
@@ -188,146 +162,390 @@ module Expressir
188
162
 
189
163
  # Strategy 3: Create implicit item for qualified paths only
190
164
  if target.nil? && tag.include?(".")
191
- # Try with scope path first
192
165
  if containing_scope && function_rule_procedure?(containing_scope)
193
166
  scope_path = build_scope_path(containing_scope)
194
167
  if scope_path
195
- full_path = "#{scope_path}.#{tag}"
196
- target = create_implicit_remark_item(model, full_path,
197
- get_schema_ids(model))
168
+ target = create_implicit_remark_item(@model, "#{scope_path}.#{tag}",
169
+ get_schema_ids(@model))
198
170
  end
199
171
  end
200
- # Fall back to schema prefix
201
172
  if target.nil?
202
- target = create_implicit_remark_item(model, tag,
203
- get_schema_ids(model))
173
+ target = create_implicit_remark_item(@model, tag,
174
+ get_schema_ids(@model))
204
175
  end
205
176
  end
206
177
 
207
178
  # Strategy 4: For simple tags at schema level, create implicit item
208
179
  if target.nil? && !tag.include?(".")
209
- schema_ids = get_schema_ids(model)
180
+ schema_ids = get_schema_ids(@model)
210
181
  if schema_ids.any?
211
- target = create_implicit_remark_item_at_schema(model, tag,
182
+ target = create_implicit_remark_item_at_schema(@model, tag,
212
183
  schema_ids.first)
213
184
  end
214
185
  end
215
186
  end
216
187
 
217
188
  if target
218
- add_remark(target, remark.text, format: remark.format, tag: remark.tag)
189
+ add_remark(target, remark.text, format: remark.format, tag: remark.tag,
190
+ source_offset: remark.position)
219
191
  @attached_spans << remark.position
220
192
  end
221
193
  end
222
194
  end
223
195
 
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
196
+ # ----- Untagged remark attachment -----
197
+
198
+ def attach_untagged_remarks(remarks)
199
+ untagged = remarks.reject(&:tag)
200
+ return unless untagged.any?
201
+
202
+ untagged.each do |remark|
203
+ next if @attached_spans.include?(remark.position)
204
+
205
+ line_content = line_content_for(remark.line)
206
+ if end_scope_line?(line_content)
207
+ matched_node = @node_index.node_for_end_scope_at(remark.line, line_content)
208
+ if matched_node
209
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil,
210
+ source_offset: remark.position)
211
+ @attached_spans << remark.position
212
+ next
213
+ end
214
+ end
232
215
 
233
- containing_nodes.reverse_each do |n|
234
- node = n[:node]
235
- return node if node.is_a?(Model::ScopeContainer)
216
+ target, placement, region = find_body_comment_target(remark)
217
+ if target
218
+ add_remark(target, remark.text, format: remark.format, tag: nil,
219
+ placement: placement, region: region)
220
+ @attached_spans << remark.position
221
+ next
222
+ end
223
+
224
+ matched_node = @node_index.nearest_node_to(remark.line)
225
+ if matched_node
226
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil,
227
+ source_offset: remark.position)
228
+ @attached_spans << remark.position
229
+ end
236
230
  end
231
+ end
237
232
 
238
- nil
233
+ def end_scope_line?(line_content)
234
+ line_content =~ /END_(SCHEMA|ENTITY|TYPE|FUNCTION|PROCEDURE|RULE)/i
235
+ end
236
+
237
+ # Own-line body comments belong to the next statement in the same
238
+ # statement region (Function body, THEN branch, ELSE branch, loop body):
239
+ # attached there with LEADING placement. A comment with no following
240
+ # statement in its region closes that region, so it attaches to the
241
+ # region's owner with TRAILING placement and the region's name — an IF
242
+ # owns two bodies that close at different keywords.
243
+ #
244
+ # Returns [nil, nil, nil] — "use the legacy fallback" — when the remark
245
+ # shares a line with a node or sits outside any statement-bearing node.
246
+ def find_body_comment_target(remark)
247
+ line = remark.line
248
+ nodes = @node_index.nodes
249
+ # An own-line comment shares its line with no node. A node STARTING
250
+ # here means the remark is an inline tail (code; -- note). The
251
+ # end-line check is restricted to statements: container end_lines are
252
+ # child-derived approximations that can collide with comment lines.
253
+ return inline_target(remark, nodes) if inline_remark?(remark)
254
+
255
+ # A closing keyword on the next code line is decisive: the comment
256
+ # closes that body. Without this check the comment would instead be
257
+ # read as leading the next statement of an OUTER region, which is
258
+ # where it would wrongly render.
259
+ closing = closing_region_target(line, nodes)
260
+ return closing if closing.first
261
+
262
+ enclosing, region, = statement_region_for(line, nodes)
263
+ return [nil, nil, nil] unless region
264
+
265
+ following = region
266
+ .select { |n| n[:line] > line }
267
+ .min_by { |n| n[:position] }
268
+ if following
269
+ return [following[:node], Model::RemarkPlacement::LEADING, nil]
270
+ end
271
+
272
+ # No following statement and no closing keyword above: the comment is
273
+ # not demonstrably inside this body (it may sit after the whole
274
+ # declaration). Keep the legacy attachment rather than guessing.
275
+ [enclosing[:node], nil, nil]
276
+ end
277
+
278
+ # Whether the remark trails code on its own line. Decided from the
279
+ # source text before it, not from node positions: container end lines
280
+ # are child-derived approximations that collide with comment lines and
281
+ # would misread an own-line comment as a trailing one.
282
+ def inline_remark?(remark)
283
+ content = line_content_for(remark.line).to_s
284
+ opener = content.index("--")
285
+ return false unless opener
286
+
287
+ !content[0...opener].strip.empty?
288
+ end
289
+
290
+ # A comment trailing code on its line belongs to the statement that
291
+ # ends closest before it: `x := 1; -- why`. Only single-line statements
292
+ # qualify, because appending to a statement spanning several lines
293
+ # would move the remark down to its closing keyword.
294
+ def inline_target(remark, nodes)
295
+ owner = nodes
296
+ .select do |n|
297
+ n[:node].is_a?(Model::Statement) &&
298
+ n[:line] == remark.line && n[:end_line] == remark.line &&
299
+ n[:position] && n[:position] < remark.position
300
+ end
301
+ .max_by { |n| n[:position] + n[:node].source.to_s.length }
302
+ return [nil, nil, nil] unless owner
303
+
304
+ [owner[:node], Model::RemarkPlacement::INLINE, nil]
305
+ end
306
+
307
+ # Which closing keyword ends which region of which owner. A comment
308
+ # sitting between a body's last statement and one of these keywords
309
+ # closes that body.
310
+ CLOSING_KEYWORDS = {
311
+ /\AELSE\b/i => [Model::Statements::If, :statements],
312
+ /\AEND_IF\b/i => [Model::Statements::If, :else_statements],
313
+ /\AOTHERWISE\b/i => [Model::Statements::Case, :action_statements],
314
+ /\AEND_CASE\b/i => [Model::Statements::Case, :otherwise_statements],
315
+ /\AEND_REPEAT\b/i => [Model::Statements::Repeat, :statements],
316
+ /\AEND_ALIAS\b/i => [Model::Statements::Alias, :statements],
317
+ /\AEND\s*;/i => [Model::Statements::Compound, :statements],
318
+ # A RULE's executable body ends at WHERE, not at END_RULE.
319
+ /\AWHERE\b/i => [Model::Declarations::Rule, :statements],
320
+ /\AEND_FUNCTION\b/i => [Model::Declarations::Function, :statements],
321
+ /\AEND_PROCEDURE\b/i => [Model::Declarations::Procedure, :statements],
322
+ # END_RULE closes the WHERE section when the rule has one.
323
+ /\AEND_RULE\b/i => [Model::Declarations::Rule, :where_rules],
324
+ }.freeze
325
+
326
+ # Regions whose owner may not have that body, in which case the
327
+ # keyword closes the earlier region instead.
328
+ REGION_FALLBACKS = {
329
+ [Model::Statements::If, :else_statements] =>
330
+ [:statements, ->(n) { n.else_statements&.length&.positive? }],
331
+ [Model::Statements::Case, :otherwise_statements] =>
332
+ [:action_statements, ->(n) { !n.otherwise_statement.nil? }],
333
+ [Model::Declarations::Rule, :where_rules] =>
334
+ [:statements, ->(n) { n.where_rules&.length&.positive? }],
335
+ }.freeze
336
+
337
+ # A node's indexed span stops at its last child, so a comment written
338
+ # after that child but before the node's closing keyword sits outside
339
+ # every span and never reaches statement_region_for. Resolve it from
340
+ # the keyword that follows: it names both the owner type and the body
341
+ # being closed.
342
+ def closing_region_target(line, nodes)
343
+ keyword_owner, region, keyword_line = closing_keyword_after(line)
344
+ return [nil, nil, nil] unless keyword_owner
345
+
346
+ # The owner is the construct the keyword actually closes — the
347
+ # innermost one still open at that line. Picking the latest node of
348
+ # the right class instead would grab an already-closed inner block
349
+ # (nested IFs) or an unrelated earlier declaration (a RULE, when the
350
+ # WHERE really belongs to an ENTITY).
351
+ opener_line = active_opener_line(keyword_line, keyword_owner)
352
+ return [nil, nil, nil] unless opener_line
353
+
354
+ owner = nodes.find do |n|
355
+ n[:node].is_a?(keyword_owner) && n[:line] == opener_line
356
+ end
357
+ return [nil, nil, nil] unless owner
358
+
359
+ # END_IF closes the THEN body when there is no ELSE; END_CASE closes
360
+ # the last action when there is no OTHERWISE.
361
+ fallback, present = REGION_FALLBACKS[[keyword_owner, region]]
362
+ region = fallback if fallback && !present.call(owner[:node])
363
+
364
+ [owner[:node], Model::RemarkPlacement::TRAILING, region.to_s]
239
365
  end
240
366
 
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?
367
+ # The first non-blank, non-comment source line after `line`.
368
+ # Source keywords that open a nestable construct, paired with the class
369
+ # of node they produce. Used to find which construct a closing keyword
370
+ # actually belongs to.
371
+ OPENERS = [
372
+ [/\bIF\b.*?\bTHEN\b/i, Model::Statements::If],
373
+ [/\bCASE\b.*?\bOF\b/i, Model::Statements::Case],
374
+ [/\bREPEAT\b/i, Model::Statements::Repeat],
375
+ [/\bALIAS\b/i, Model::Statements::Alias],
376
+ [/\bBEGIN\b/i, Model::Statements::Compound],
377
+ [/\A\s*FUNCTION\b/i, Model::Declarations::Function],
378
+ [/\A\s*PROCEDURE\b/i, Model::Declarations::Procedure],
379
+ [/\A\s*RULE\b/i, Model::Declarations::Rule],
380
+ [/\A\s*ENTITY\b/i, :other],
381
+ [/\A\s*TYPE\b/i, :other],
382
+ ].freeze
247
383
 
248
- # Track nested scopes by scanning all lines once
249
- scope_stack = [] # array of {type:, name:, line:}
384
+ 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
385
 
251
- lines.each_with_index do |line, idx|
252
- line_num = idx + 1
386
+ # Strips what must not be scanned for keywords: string literals and a
387
+ # trailing `--` remark. Without this, `x := 'IF a THEN'` or a comment
388
+ # mentioning REPEAT would push a construct that never opened.
389
+ def keyword_scannable(content)
390
+ without_strings = content.gsub(/'[^']*'/, "''")
391
+ tail = without_strings.index("--")
392
+ tail ? without_strings[0...tail] : without_strings
393
+ end
253
394
 
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
395
+ # The opening line of the innermost construct still open at
396
+ # `keyword_line`, or nil when that construct is not of `expected_class`.
397
+ def active_opener_line(keyword_line, expected_class)
398
+ active = active_scope_map[keyword_line]
399
+ return nil unless active && active[0] == expected_class
258
400
 
259
- if line =~ /^\s*FUNCTION\s+(\w+)/i
260
- scope_stack << { type: :function, name: $1, line: line_num }
261
- end
401
+ active[1]
402
+ end
262
403
 
263
- if line =~ /^\s*PROCEDURE\s+(\w+)/i
264
- scope_stack << { type: :procedure, name: $1, line: line_num }
265
- end
404
+ # Line number => the construct open at the START of that line, as
405
+ # [class, opening_line]. Built once per source: rescanning from line 1
406
+ # for every trailing comment is quadratic, and on a comment-dense file
407
+ # that cost dominates parsing entirely.
408
+ def active_scope_map
409
+ @active_scope_map ||= build_active_scope_map
410
+ end
266
411
 
267
- if line =~ /^\s*RULE\s+(\w+)/i
268
- scope_stack << { type: :rule, name: $1, line: line_num }
269
- end
412
+ def build_active_scope_map
413
+ map = {}
414
+ stack = []
415
+ (1..source_line_count).each do |ln|
416
+ map[ln] = stack.last
417
+ content = keyword_scannable(line_content_for(ln).to_s.strip)
418
+ next if content.empty? || content.start_with?("--")
270
419
 
271
- if line =~ /^\s*ENTITY\s+(\w+)/i
272
- scope_stack << { type: :entity, name: $1, line: line_num }
420
+ line_events(content).each do |_offset, kind, klass|
421
+ kind == :open ? stack << [klass, ln] : stack.pop
273
422
  end
423
+ end
424
+ map
425
+ end
274
426
 
275
- if line =~ /^\s*TYPE\s+(\w+)/i
276
- scope_stack << { type: :type, name: $1, line: line_num }
427
+ # Opener/closer events on one line, ordered by where they appear.
428
+ # EVERY occurrence is collected, not just the first: a line holding two
429
+ # complete IF blocks contributes two openers and two closers, and
430
+ # recording only one opener would over-pop the enclosing construct.
431
+ def line_events(content)
432
+ events = []
433
+ OPENERS.each do |pattern, klass|
434
+ content.enum_for(:scan, pattern).each do
435
+ events << [Regexp.last_match.begin(0), :open, klass]
277
436
  end
437
+ end
438
+ content.enum_for(:scan, CLOSERS).each do
439
+ events << [Regexp.last_match.begin(0), :close, nil]
440
+ end
441
+ events.sort_by(&:first)
442
+ end
278
443
 
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
444
+ def closing_keyword_after(line)
445
+ probe = line + 1
446
+ # Skip further comment lines AND blank lines: a comment separated
447
+ # from its closing keyword by an empty line still closes that body.
448
+ while probe <= source_line_count
449
+ content = line_content_for(probe).to_s.strip
450
+ break unless content.empty? || content.start_with?("--")
298
451
 
299
- # Record the innermost scope for this line
300
- scope_map[line_num] = scope_stack.last&.dig(:name)
452
+ probe += 1
453
+ end
454
+ content = line_content_for(probe).to_s.strip
455
+ CLOSING_KEYWORDS.each do |pattern, owner_region|
456
+ return [*owner_region, probe] if content.match?(pattern)
301
457
  end
458
+ [nil, nil, nil]
459
+ end
302
460
 
303
- scope_map
461
+ def statement_region_for(line, nodes)
462
+ candidates = nodes.select do |n|
463
+ n[:line] && n[:end_line] && n[:line] <= line && n[:end_line] >= line &&
464
+ (n[:node].is_a?(Model::Statement) || function_rule_procedure?(n[:node]))
465
+ end
466
+ enclosing = innermost_candidate(candidates)
467
+ return [nil, nil, nil] unless enclosing
468
+
469
+ children = nodes.select do |n|
470
+ n[:owner].equal?(enclosing[:node]) &&
471
+ STATEMENT_REGIONS.include?(n[:collection]) && n[:line]
472
+ end
473
+ return [enclosing, nil, nil] if children.empty?
474
+
475
+ preceding = children.select { |n| n[:line] < line }.max_by { |n| n[:position] }
476
+ following = children.select { |n| n[:line] > line }.min_by { |n| n[:position] }
477
+ region_attr = region_attr_for(line, preceding, following)
478
+ return [enclosing, nil, nil] unless region_attr
479
+
480
+ [enclosing, children.select { |n| n[:collection] == region_attr }, region_attr]
304
481
  end
305
482
 
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
483
+ # Node end lines are child-derived approximations, so a parent's span
484
+ # can come out SMALLER than a child's and span size alone picks the
485
+ # wrong container. Ownership links are exact: drop every candidate
486
+ # that is an ancestor of another candidate, then pick the smallest
487
+ # span among the true leaves.
488
+ def innermost_candidate(candidates)
489
+ return candidates.first if candidates.length <= 1
490
+
491
+ owner_of = owner_map
492
+ ancestors = Set.new.compare_by_identity
493
+ candidates.each do |cand|
494
+ current = owner_of[cand[:node]]
495
+ while current
496
+ ancestors << current
497
+ current = owner_of[current]
498
+ end
499
+ end
309
500
 
310
- scope_name = @scope_map[remark_line]
311
- return nil unless scope_name
501
+ leaves = candidates.reject { |n| ancestors.include?(n[:node]) }
502
+ (leaves.empty? ? candidates : leaves).min_by { |n| n[:end_line] - n[:line] }
503
+ end
312
504
 
313
- # Find the model node for this scope
314
- return nil unless @model
505
+ # The node index is immutable during attachment, so its ownership map
506
+ # only needs to be built once for all body remarks.
507
+ def owner_map
508
+ # Identity comparison must be enabled BEFORE the hash is populated.
509
+ # Model elements compare by value, so two distinct-but-equal nodes
510
+ # would collapse into one entry during a plain build, and switching
511
+ # to identity afterwards cannot recover the lost entry.
512
+ @owner_map ||= @node_index.nodes.each_with_object(
513
+ {}.compare_by_identity,
514
+ ) { |n, map| map[n[:node]] = n[:owner] }
515
+ end
315
516
 
316
- @model.schemas.each do |schema|
317
- return schema if schema.id == scope_name
517
+ # The keyword that opens each region, for regions that follow another
518
+ # region of the same owner. A comment in the gap belongs to whichever
519
+ # side of this keyword it was written on.
520
+ REGION_OPENERS = {
521
+ else_statements: /\A(?:.*;)?\s*ELSE(?:\s*--.*)?\z/i,
522
+ otherwise_statements: /\A\s*OTHERWISE\b/i,
523
+ }.freeze
318
524
 
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)
525
+ # A comment between two regions of the same owner — between the THEN
526
+ # body and ELSE, or between the last CASE action and OTHERWISE — sits
527
+ # on one side of the keyword that opens the second region. The gap can
528
+ # hold only that keyword and comments, so scanning it is exact.
529
+ # Comment lines are skipped so prose mentioning the keyword cannot
530
+ # match.
531
+ def region_attr_for(line, preceding, following)
532
+ return following&.dig(:collection) unless preceding
322
533
 
323
- found = collection.find { |n| n.id == scope_name }
324
- return found if found
534
+ following_attr = following&.dig(:collection)
535
+ opener = REGION_OPENERS[following_attr]
536
+ if opener && following_attr != preceding[:collection]
537
+ opener_line = (preceding[:end_line]...following[:line]).find do |ln|
538
+ content = line_content_for(ln).strip
539
+ !content.start_with?("--") && opener.match?(content)
325
540
  end
541
+ return following_attr if opener_line && line > opener_line
326
542
  end
327
543
 
328
- nil
544
+ preceding[:collection]
329
545
  end
330
546
 
547
+ # ----- Tag resolution (within a scope) -----
548
+
331
549
  def find_node_in_scope(scope, tag)
332
550
  return nil unless scope
333
551
 
@@ -338,20 +556,17 @@ module Expressir
338
556
  end
339
557
  end
340
558
 
341
- # Search inside types for enumeration items
342
559
  types = get_collection(scope, :types)
343
560
  types&.each do |type|
344
561
  result = find_enumeration_item_in_type(type, tag)
345
562
  return result if result
346
563
  end
347
564
 
348
- # Search inside statements for nested items (alias, repeat, query)
349
565
  statements = get_collection(scope, :statements)
350
566
  statements&.each do |stmt|
351
567
  result = find_node_in_statement(stmt, tag)
352
568
  return result if result
353
569
 
354
- # Search inside expressions for QueryExpression (nested in assignments, etc.)
355
570
  result = find_query_in_expression(stmt, tag)
356
571
  return result if result
357
572
  end
@@ -361,45 +576,19 @@ module Expressir
361
576
 
362
577
  def find_enumeration_item_in_type(type, tag)
363
578
  return nil unless type
579
+ return nil unless type.is_a?(Model::Declarations::Type)
364
580
 
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
581
+ type.enumeration_items&.each do |item|
582
+ return item if item.id == tag
379
583
  end
380
584
 
585
+ ut = type.underlying_type
586
+ return nil unless ut.is_a?(Model::DataTypes::Enumeration) && ut.items
587
+
588
+ ut.items.each { |item| return item if item.id == tag }
381
589
  nil
382
590
  end
383
591
 
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
592
  def find_query_in_expression(node, tag, visited = Set.new)
404
593
  return nil unless node
405
594
  return nil unless node.is_a?(Model::ModelElement)
@@ -407,9 +596,7 @@ module Expressir
407
596
 
408
597
  visited.add(node.object_id)
409
598
 
410
- if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
411
- return node
412
- end
599
+ return node if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
413
600
 
414
601
  attrs = EXPRESSION_CHILDREN[node.class]
415
602
  return nil unless attrs
@@ -446,7 +633,6 @@ module Expressir
446
633
  prefix, id = tag.split(":")
447
634
  return nil unless id
448
635
 
449
- # Determine collection based on prefix
450
636
  collection_attr = case prefix.downcase
451
637
  when "wr" then :where_rules
452
638
  when "ip" then :informal_propositions
@@ -454,14 +640,12 @@ module Expressir
454
640
  end
455
641
  return nil unless collection_attr
456
642
 
457
- # First try to find in containing scope
458
643
  collection = get_collection(containing_scope, collection_attr)
459
644
  if collection
460
645
  found = collection.find { |item| item.is_a?(Model::ModelElement) && item.id == id }
461
646
  return found if found
462
647
  end
463
648
 
464
- # Fallback: try to find by full path
465
649
  schema_ids.each do |schema_id|
466
650
  full_path = "#{schema_id}.#{tag.tr(':', '.')}"
467
651
  found = safe_find(model, full_path)
@@ -471,38 +655,50 @@ module Expressir
471
655
  nil
472
656
  end
473
657
 
474
- # Find target for remarks inside WHERE clauses
658
+ # Find target for remarks inside WHERE clauses by scanning source lines
659
+ # for `WHERE <id>:` patterns. Lives here (not in ScopeResolver) because
660
+ # it's about WHERE-rule membership, not scope membership.
475
661
  def find_target_in_where_clause(scope, tag, remark_line)
476
662
  return nil unless supports_where_rules?(scope)
477
663
 
478
664
  where_rules = get_collection(scope, :where_rules)
479
665
  return nil unless where_rules&.any?
480
666
 
481
- # Search source text for WHERE clause containing this remark
482
- lines = source_lines
667
+ where_clause_lines = where_clause_line_index
483
668
 
484
669
  where_rules.each do |wr|
485
670
  next unless wr.id
486
671
 
487
- # Find the WHERE rule declaration
488
- lines.each_with_index do |line, idx|
489
- line_num = idx + 1
672
+ where_clause_lines.fetch(wr.id, []).each do |line_num|
490
673
  next unless line_num < remark_line
491
674
 
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
498
- return create_remark_item(wr, tag)
499
- end
675
+ return create_remark_item(wr, tag) if remark_line.between?(line_num, line_num + 5)
500
676
  end
501
677
  end
502
678
 
503
679
  nil
504
680
  end
505
681
 
682
+ # Single scan over the source lines mapping each `WHERE <id>:` label to
683
+ # its 1-based line number, so per-remark lookups stop re-testing every
684
+ # line against every WHERE rule's regex.
685
+ def where_clause_line_index
686
+ @where_clause_line_index ||= begin
687
+ index = Hash.new { |h, k| h[k] = [] }
688
+ source_lines_for_where_clause.each_with_index do |line, idx|
689
+ if (match = line.match(WHERE_CLAUSE_PATTERN))
690
+ index[match[1]] << (idx + 1)
691
+ end
692
+ end
693
+ index
694
+ end
695
+ end
696
+
697
+ def source_lines_for_where_clause
698
+ # @source is set for the duration of `attach`; freed at the end.
699
+ @source_lines_for_where_clause ||= @source.lines
700
+ end
701
+
506
702
  def find_node_in_statement(stmt, tag)
507
703
  case stmt
508
704
  when Model::Statements::Alias
@@ -515,15 +711,6 @@ module Expressir
515
711
  nil
516
712
  end
517
713
 
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
714
  def build_scope_path(node)
528
715
  return nil unless node
529
716
 
@@ -535,7 +722,6 @@ module Expressir
535
722
  parts.unshift(current.id)
536
723
  end
537
724
 
538
- # Stop at schema level
539
725
  break if current.is_a?(Model::Declarations::Schema)
540
726
 
541
727
  current = current.parent
@@ -544,113 +730,33 @@ module Expressir
544
730
  parts.empty? ? nil : parts.join(".")
545
731
  end
546
732
 
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
733
+ # ----- Path-based lookup -----
624
734
 
625
735
  def find_by_exact_path(model, path)
626
736
  return nil unless path
627
-
628
- # Only Repository and ExpFile support path-based find
629
737
  return nil unless repository?(model) || exp_file?(model)
630
738
 
631
- # Try original path
632
739
  result = safe_find(model, path)
633
740
  return result if result
634
741
 
635
- # Try with colon converted to dot
636
742
  normalized = path.tr(":", ".")
637
743
  normalized == path ? nil : safe_find(model, normalized)
638
744
  end
639
745
 
746
+ # ----- Target creation -----
747
+
640
748
  def create_implicit_remark_item_at_schema(model, item_id, schema_id)
641
- # Only Repository and ExpFile support schema lookup
642
749
  return nil unless repository?(model) || exp_file?(model)
643
750
 
644
751
  schema = safe_find(model, schema_id)
645
752
  return nil unless schema.is_a?(Model::Declarations::Schema)
646
753
 
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)
754
+ # Strip "wr:" prefix convention (e.g. "wr:IP1" "IP1")
755
+ ip_id = extract_ip_id(item_id)
756
+ if ip_id && supports_informal_propositions?(schema)
757
+ return create_or_find_informal_proposition(schema, ip_id)
651
758
  end
652
759
 
653
- # Handle remark items
654
760
  return nil unless supports_remark_items?(schema)
655
761
 
656
762
  existing = schema.remark_items&.find { |ri| ri.id == item_id }
@@ -662,19 +768,35 @@ module Expressir
662
768
  def create_implicit_remark_item(model, path, schema_ids = [])
663
769
  return nil unless repository?(model) || exp_file?(model)
664
770
 
665
- # Normalize path (handle "ip:IP1" format)
771
+ # Handle "wr:IP1" convention: extract parent path and IP id directly
772
+ # so normalize_path doesn't split "wr:IP1" into "wr"."IP1" segments.
773
+ ip_prefix_match = path.match(/\A(.+)\.(\w+):(IP\d+)\z/)
774
+ if ip_prefix_match
775
+ parent_path = ip_prefix_match[1]
776
+ ip_id = ip_prefix_match[3]
777
+ parent = safe_find(model, parent_path)
778
+ if parent.nil? && schema_ids.any?
779
+ schema_ids.each do |schema_id|
780
+ parent = safe_find(model, "#{schema_id}.#{parent_path}")
781
+ break if parent
782
+ end
783
+ end
784
+
785
+ if parent && supports_informal_propositions?(parent)
786
+ return create_or_find_informal_proposition(parent, ip_id)
787
+ end
788
+ end
789
+
666
790
  clean_path = normalize_path(path)
667
791
  parts = clean_path.split(".")
668
792
  return nil if parts.length < 2
669
793
 
670
- # Find the deepest existing parent and create item there
671
794
  (parts.length - 1).downto(1) do |i|
672
795
  parent_path = parts[0...i].join(".")
673
796
  item_id = parts[i]
674
797
 
675
798
  parent = safe_find(model, parent_path)
676
799
 
677
- # Try with schema prefix if not found
678
800
  if parent.nil? && schema_ids.any?
679
801
  schema_ids.each do |schema_id|
680
802
  parent = safe_find(model, "#{schema_id}.#{parent_path}")
@@ -698,12 +820,12 @@ module Expressir
698
820
  end
699
821
 
700
822
  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)
823
+ # Strip "wr:" prefix convention (e.g. "wr:IP1" → "IP1")
824
+ ip_id = extract_ip_id(item_id)
825
+ if ip_id && supports_informal_propositions?(parent)
826
+ return create_or_find_informal_proposition(parent, ip_id)
704
827
  end
705
828
 
706
- # Handle remark items
707
829
  return nil unless supports_remark_items?(parent)
708
830
 
709
831
  existing = parent.remark_items&.find { |ri| ri.id == item_id }
@@ -713,7 +835,6 @@ module Expressir
713
835
  end
714
836
 
715
837
  def create_or_find_informal_proposition(parent, id)
716
- # Only Entity, Rule, Type, and InformalPropositionRule have informal_propositions
717
838
  return nil unless supports_informal_propositions?(parent)
718
839
 
719
840
  existing = parent.informal_propositions&.find { |ip| ip.id == id }
@@ -725,15 +846,12 @@ module Expressir
725
846
  parent.informal_propositions << ip
726
847
  safe_reset_children_by_id(parent)
727
848
 
728
- # Also create a RemarkItem inside the InformalPropositionRule
729
- # This is the expected structure for informal proposition remarks
730
849
  remark_item = Model::Declarations::RemarkItem.new(id: id)
731
850
  remark_item.parent = ip
732
851
  ip.remark_items ||= []
733
852
  ip.remark_items << remark_item
734
853
  safe_reset_children_by_id(ip)
735
854
 
736
- # Return the remark_item so remarks are added to it
737
855
  remark_item
738
856
  end
739
857
 
@@ -746,92 +864,32 @@ module Expressir
746
864
  item
747
865
  end
748
866
 
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
867
+ # ----- Remark storage -----
812
868
 
813
- def add_remark(node, text, format: "tail", tag: nil)
869
+ def add_remark(node, text, format: Model::RemarkFormat::TAIL, tag: nil,
870
+ placement: nil, region: nil, source_offset: nil)
814
871
  return unless node
815
872
  return unless node.is_a?(Model::ModelElement)
816
873
 
817
- # Only add remarks to nodes that support them
818
874
  if supports_remarks?(node)
819
- # Always add to remarks attribute (for types that have it)
820
875
  if node_has_remarks?(node)
821
876
  node.remarks ||= []
822
877
  node.remarks << text
823
878
  end
824
879
 
825
880
  if tag.nil?
826
- # Untagged remark: store in untagged_remarks
827
- remark_info = Model::RemarkInfo.new(text: text, format: format)
881
+ remark_info = Model::RemarkInfo.new(text: text, format: format,
882
+ placement: placement,
883
+ region: region,
884
+ source_offset: source_offset)
828
885
  node.untagged_remarks ||= []
829
886
  node.untagged_remarks << remark_info
830
887
  end
831
888
  end
832
889
  end
833
890
 
834
- # All ModelElement subclasses have untagged_remarks from ModelElement
891
+ # ----- Type predicates -----
892
+
835
893
  def supports_remarks?(obj)
836
894
  obj.is_a?(Model::ModelElement)
837
895
  end
@@ -840,180 +898,23 @@ module Expressir
840
898
  obj.is_a?(Model::HasRemarks)
841
899
  end
842
900
 
843
- # Types that include HasRemarkItems can have remark_items
844
901
  def supports_remark_items?(obj)
845
902
  obj.is_a?(Model::HasRemarkItems)
846
903
  end
847
904
 
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
905
+ def supports_informal_propositions?(obj)
906
+ obj.is_a?(Model::HasInformalPropositions)
944
907
  end
945
908
 
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
909
+ # Extracts an IP id from a potentially prefixed item_id.
910
+ # "wr:IP1" "IP1", "IP1" "IP1", "WR1" nil
911
+ def extract_ip_id(item_id)
912
+ bare = item_id.sub(/\A\w+:/, "")
913
+ bare.match?(/^IP\d+$/) ? bare : nil
954
914
  end
955
915
 
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
916
+ def supports_where_rules?(obj)
917
+ obj.is_a?(Model::HasWhereRules)
1017
918
  end
1018
919
 
1019
920
  def repository?(obj)
@@ -1028,13 +929,15 @@ module Expressir
1028
929
  obj.is_a?(Model::Cache)
1029
930
  end
1030
931
 
1031
- def supports_informal_propositions?(obj)
1032
- obj.is_a?(Model::HasInformalPropositions)
932
+ def get_schema_ids(model)
933
+ if repository?(model) || exp_file?(model)
934
+ model.schemas.filter_map(&:id)
935
+ else
936
+ []
937
+ end
1033
938
  end
1034
939
 
1035
- def supports_where_rules?(obj)
1036
- obj.is_a?(Model::HasWhereRules)
1037
- end
940
+ # ----- Collection access -----
1038
941
 
1039
942
  # Type-driven collection access — returns all collections for a node's type.
1040
943
  def collections_on(node)
@@ -1055,6 +958,8 @@ module Expressir
1055
958
  collection if collection.is_a?(Array)
1056
959
  end
1057
960
 
961
+ # ----- Helpers -----
962
+
1058
963
  def safe_find(model, path)
1059
964
  return nil unless model
1060
965
 
@@ -1069,6 +974,17 @@ module Expressir
1069
974
 
1070
975
  obj.reset_children_by_id
1071
976
  end
977
+
978
+ def line_content_for(line_num)
979
+ lines = source_lines_for_where_clause
980
+ return "" if line_num < 1 || line_num > lines.length
981
+
982
+ lines[line_num - 1]
983
+ end
984
+
985
+ def source_line_count
986
+ source_lines_for_where_clause.length
987
+ end
1072
988
  end
1073
989
  end
1074
990
  end