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