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