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.
- checksums.yaml +4 -4
- data/TODO.bugs/01-stale-transformer-autoload.md +39 -0
- data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
- data/TODO.bugs/03-builder-mutable-state.md +43 -0
- data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
- data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
- data/TODO.bugs/06-collection-registry-single-source.md +53 -0
- data/TODO.bugs/07-require-relative-cleanup.md +42 -0
- data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
- data/TODO.bugs/09-parser-split.md +53 -0
- data/TODO.bugs/10-to-s-override.md +42 -0
- data/TODO.bugs/11-parser-class-variables.md +39 -0
- data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
- data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
- data/TODO.bugs/14-model-formatting-leak.md +30 -0
- data/TODO.bugs/15-expression-children-macro.md +27 -0
- data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
- data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
- data/TODO.bugs/18-const-get-private-constants.md +30 -0
- data/TODO.bugs/19-format-methods-public.md +22 -0
- data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
- data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
- data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
- data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
- data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
- data/TODO.bugs/25-debug-puts-in-production.md +21 -0
- data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
- data/TODO.bugs/27-package-build-god-method.md +19 -0
- data/TODO.bugs/28-package-god-class.md +30 -0
- data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
- data/TODO.bugs/30-unicode-map-extraction.md +19 -0
- data/TODO.bugs/README.md +43 -0
- data/docs/_tutorials/formatting-schemas.adoc +89 -0
- data/docs/_tutorials/index.adoc +10 -0
- data/docs/lychee.toml +3 -0
- data/expressir.gemspec +4 -2
- data/lib/expressir/commands/changes_import_eengine.rb +0 -6
- data/lib/expressir/commands/changes_validate.rb +0 -2
- data/lib/expressir/commands/clean.rb +1 -1
- data/lib/expressir/commands/file_violations.rb +70 -0
- data/lib/expressir/commands/format.rb +1 -1
- data/lib/expressir/commands/non_ascii_character.rb +49 -0
- data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
- data/lib/expressir/commands/package.rb +138 -185
- data/lib/expressir/commands/validate_ascii.rb +0 -510
- data/lib/expressir/commands/version.rb +1 -1
- data/lib/expressir/commands.rb +3 -0
- data/lib/expressir/coverage.rb +49 -117
- data/lib/expressir/express/builder.rb +74 -86
- data/lib/expressir/express/builder_context.rb +22 -0
- data/lib/expressir/express/builders/expression_builder.rb +16 -16
- data/lib/expressir/express/cache.rb +35 -8
- data/lib/expressir/express/error.rb +7 -0
- data/lib/expressir/express/formatter.rb +33 -18
- data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
- data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
- data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
- data/lib/expressir/express/grammar/parser.rb +705 -0
- data/lib/expressir/express/grammar.rb +11 -0
- data/lib/expressir/express/node_position_index.rb +215 -0
- data/lib/expressir/express/parser.rb +114 -884
- data/lib/expressir/express/pretty_formatter.rb +23 -5
- data/lib/expressir/express/remark_attacher.rb +475 -577
- data/lib/expressir/express/remark_scanner.rb +84 -19
- data/lib/expressir/express/schema_block_scanner.rb +136 -0
- data/lib/expressir/express/schema_source_formatter.rb +15 -0
- data/lib/expressir/express/scope_resolver.rb +194 -0
- data/lib/expressir/express/source_formatter.rb +15 -0
- data/lib/expressir/express/streaming_builder.rb +147 -176
- data/lib/expressir/express.rb +8 -6
- data/lib/expressir/model/concerns.rb +3 -0
- data/lib/expressir/model/data_types/generic_entity.rb +6 -6
- data/lib/expressir/model/declarations/entity.rb +5 -0
- data/lib/expressir/model/declarations/function.rb +5 -0
- data/lib/expressir/model/declarations/procedure.rb +5 -0
- data/lib/expressir/model/declarations/rule.rb +6 -0
- data/lib/expressir/model/declarations/schema.rb +21 -8
- data/lib/expressir/model/declarations/type.rb +3 -0
- data/lib/expressir/model/exp_file.rb +2 -0
- data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
- data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
- data/lib/expressir/model/expressions/binary_expression.rb +1 -0
- data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
- data/lib/expressir/model/expressions/function_call.rb +1 -0
- data/lib/expressir/model/expressions/interval.rb +1 -0
- data/lib/expressir/model/expressions/query_expression.rb +2 -0
- data/lib/expressir/model/expressions/unary_expression.rb +1 -0
- data/lib/expressir/model/model_element.rb +128 -14
- data/lib/expressir/model/remark_format.rb +17 -0
- data/lib/expressir/model/remark_info.rb +31 -3
- data/lib/expressir/model/remark_placement.rb +29 -0
- data/lib/expressir/model/statements/alias.rb +2 -0
- data/lib/expressir/model/statements/assignment.rb +4 -0
- data/lib/expressir/model/statements/case.rb +21 -0
- data/lib/expressir/model/statements/case_action.rb +1 -0
- data/lib/expressir/model/statements/compound.rb +4 -0
- data/lib/expressir/model/statements/escape.rb +3 -0
- data/lib/expressir/model/statements/if.rb +5 -0
- data/lib/expressir/model/statements/null.rb +3 -0
- data/lib/expressir/model/statements/procedure_call.rb +3 -0
- data/lib/expressir/model/statements/repeat.rb +3 -0
- data/lib/expressir/model/statements/return.rb +3 -0
- data/lib/expressir/model/statements/skip.rb +3 -0
- data/lib/expressir/model.rb +12 -3
- data/lib/expressir/package/builder.rb +2 -2
- data/lib/expressir/version.rb +6 -1
- data/lib/expressir.rb +38 -3
- metadata +63 -5
- 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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
@
|
|
54
|
-
@
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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(
|
|
67
|
-
attach_untagged_remarks(remarks
|
|
60
|
+
attach_tagged_remarks(remarks)
|
|
61
|
+
attach_untagged_remarks(remarks)
|
|
68
62
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
@
|
|
73
|
-
@
|
|
69
|
+
@scope_resolver = nil
|
|
70
|
+
@node_index = nil
|
|
74
71
|
@line_map = nil
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
@owner_map = nil
|
|
73
|
+
@active_scope_map = nil
|
|
77
74
|
end
|
|
78
75
|
|
|
79
76
|
private
|
|
80
77
|
|
|
81
|
-
#
|
|
82
|
-
# Line-number lookup is delegated to {LineMap} for O(log n) queries.
|
|
78
|
+
# ----- Tagged remark attachment -----
|
|
83
79
|
|
|
84
|
-
def
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
end
|
|
395
|
+
active[1]
|
|
396
|
+
end
|
|
262
397
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
648
|
-
|
|
649
|
-
if
|
|
650
|
-
return create_or_find_informal_proposition(schema,
|
|
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
|
-
#
|
|
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
|
-
#
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
#
|
|
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
|
|
849
|
-
|
|
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
|
-
#
|
|
947
|
-
#
|
|
948
|
-
def
|
|
949
|
-
|
|
950
|
-
|
|
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
|
|
957
|
-
|
|
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
|
|
1032
|
-
|
|
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
|
-
|
|
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
|