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.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bugs/01-stale-transformer-autoload.md +39 -0
  3. data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
  4. data/TODO.bugs/03-builder-mutable-state.md +43 -0
  5. data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
  6. data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
  7. data/TODO.bugs/06-collection-registry-single-source.md +53 -0
  8. data/TODO.bugs/07-require-relative-cleanup.md +42 -0
  9. data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
  10. data/TODO.bugs/09-parser-split.md +53 -0
  11. data/TODO.bugs/10-to-s-override.md +42 -0
  12. data/TODO.bugs/11-parser-class-variables.md +39 -0
  13. data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
  14. data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
  15. data/TODO.bugs/14-model-formatting-leak.md +30 -0
  16. data/TODO.bugs/15-expression-children-macro.md +27 -0
  17. data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
  18. data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
  19. data/TODO.bugs/18-const-get-private-constants.md +30 -0
  20. data/TODO.bugs/19-format-methods-public.md +22 -0
  21. data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
  22. data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
  23. data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
  24. data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
  25. data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
  26. data/TODO.bugs/25-debug-puts-in-production.md +21 -0
  27. data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
  28. data/TODO.bugs/27-package-build-god-method.md +19 -0
  29. data/TODO.bugs/28-package-god-class.md +30 -0
  30. data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
  31. data/TODO.bugs/30-unicode-map-extraction.md +19 -0
  32. data/TODO.bugs/README.md +43 -0
  33. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  34. data/docs/_tutorials/index.adoc +10 -0
  35. data/docs/lychee.toml +3 -0
  36. data/expressir.gemspec +4 -2
  37. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  38. data/lib/expressir/commands/changes_validate.rb +0 -2
  39. data/lib/expressir/commands/clean.rb +1 -1
  40. data/lib/expressir/commands/file_violations.rb +70 -0
  41. data/lib/expressir/commands/format.rb +1 -1
  42. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  43. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  44. data/lib/expressir/commands/package.rb +138 -185
  45. data/lib/expressir/commands/validate_ascii.rb +0 -510
  46. data/lib/expressir/commands/version.rb +1 -1
  47. data/lib/expressir/commands.rb +3 -0
  48. data/lib/expressir/coverage.rb +49 -117
  49. data/lib/expressir/express/builder.rb +74 -86
  50. data/lib/expressir/express/builder_context.rb +22 -0
  51. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  52. data/lib/expressir/express/cache.rb +35 -8
  53. data/lib/expressir/express/error.rb +7 -0
  54. data/lib/expressir/express/formatter.rb +33 -18
  55. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  56. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  57. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  58. data/lib/expressir/express/grammar/parser.rb +705 -0
  59. data/lib/expressir/express/grammar.rb +11 -0
  60. data/lib/expressir/express/line_map.rb +48 -0
  61. data/lib/expressir/express/node_position_index.rb +215 -0
  62. data/lib/expressir/express/parser.rb +114 -884
  63. data/lib/expressir/express/pretty_formatter.rb +23 -5
  64. data/lib/expressir/express/remark_attacher.rb +483 -669
  65. data/lib/expressir/express/remark_scanner.rb +245 -0
  66. data/lib/expressir/express/schema_block_scanner.rb +136 -0
  67. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  68. data/lib/expressir/express/scope_resolver.rb +194 -0
  69. data/lib/expressir/express/source_formatter.rb +15 -0
  70. data/lib/expressir/express/streaming_builder.rb +147 -176
  71. data/lib/expressir/express.rb +10 -6
  72. data/lib/expressir/model/concerns.rb +3 -0
  73. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  74. data/lib/expressir/model/declarations/entity.rb +5 -0
  75. data/lib/expressir/model/declarations/function.rb +5 -0
  76. data/lib/expressir/model/declarations/procedure.rb +5 -0
  77. data/lib/expressir/model/declarations/rule.rb +6 -0
  78. data/lib/expressir/model/declarations/schema.rb +21 -8
  79. data/lib/expressir/model/declarations/type.rb +3 -0
  80. data/lib/expressir/model/exp_file.rb +2 -0
  81. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  82. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  83. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  84. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  85. data/lib/expressir/model/expressions/function_call.rb +1 -0
  86. data/lib/expressir/model/expressions/interval.rb +1 -0
  87. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  88. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  89. data/lib/expressir/model/model_element.rb +128 -14
  90. data/lib/expressir/model/remark_format.rb +17 -0
  91. data/lib/expressir/model/remark_info.rb +31 -3
  92. data/lib/expressir/model/remark_placement.rb +29 -0
  93. data/lib/expressir/model/statements/alias.rb +2 -0
  94. data/lib/expressir/model/statements/assignment.rb +4 -0
  95. data/lib/expressir/model/statements/case.rb +21 -0
  96. data/lib/expressir/model/statements/case_action.rb +1 -0
  97. data/lib/expressir/model/statements/compound.rb +4 -0
  98. data/lib/expressir/model/statements/escape.rb +3 -0
  99. data/lib/expressir/model/statements/if.rb +5 -0
  100. data/lib/expressir/model/statements/null.rb +3 -0
  101. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  102. data/lib/expressir/model/statements/repeat.rb +3 -0
  103. data/lib/expressir/model/statements/return.rb +3 -0
  104. data/lib/expressir/model/statements/skip.rb +3 -0
  105. data/lib/expressir/model.rb +12 -3
  106. data/lib/expressir/package/builder.rb +2 -2
  107. data/lib/expressir/version.rb +6 -1
  108. data/lib/expressir.rb +38 -3
  109. metadata +65 -5
  110. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Scans EXPRESS source text and extracts all remarks (tail and embedded),
6
+ # correctly tracking embedded-remark nesting so that inline `--` inside
7
+ # a `(* ... *)` documentation block is NOT mistaken for a tail remark.
8
+ #
9
+ # Single responsibility: source string -> Array<Remark>. The scanner has
10
+ # no knowledge of the model; attaching remarks to model elements is the
11
+ # job of {RemarkAttacher}.
12
+ #
13
+ # Reference: ISO 10303-11 §7.1.6.
14
+ # tail_remark = '--' [ remark_tag ] { <any char except newline> } '\n'
15
+ # embedded_remark = '(*' [ remark_tag ]
16
+ # { <any char> | nested embedded_remark } '*)'
17
+ #
18
+ # The scanner is a single-pass state machine with four states:
19
+ # :top — outside any remark/string; look for `--`, `(*`, or `'`.
20
+ # :in_tail — inside a tail remark; look only for the next newline.
21
+ # :in_embedded — inside one or more nested embedded remarks; look
22
+ # for the matching `*)` or a nested `(*`, ignoring `--`.
23
+ # :in_string — inside a simple string literal; look for the closing
24
+ # `'`, treating `''` as an escaped quote (ISO 10303-11
25
+ # §7.1.6.5). `--` and `(*` inside a string are content.
26
+ class RemarkScanner
27
+ # Immutable value object describing a single extracted remark.
28
+ # `position` is the byte offset of the opener (`--` or `(*`).
29
+ # `end_position` is the byte offset one past the closer (the newline
30
+ # byte for tail remarks, the byte after `*)` for embedded remarks).
31
+ # `[position, end_position)` is the full byte range the remark
32
+ # occupied in the original source.
33
+ Remark = Struct.new(:position, :end_position, :line, :text, :tag,
34
+ :format, keyword_init: true) do
35
+ def tail?
36
+ format == Model::RemarkFormat::TAIL
37
+ end
38
+
39
+ def embedded?
40
+ format == Model::RemarkFormat::EMBEDDED
41
+ end
42
+
43
+ def tagged?
44
+ !tag.nil? && !tag.empty?
45
+ end
46
+
47
+ # Byte length of the full remark (opener + content + closer).
48
+ def bytesize
49
+ end_position - position
50
+ end
51
+ end
52
+
53
+ # Lexical markers recognised by EXPRESS.
54
+ TAIL_MARKER = "--"
55
+ EMBEDDED_OPEN = "(*"
56
+ EMBEDDED_CLOSE = "*)"
57
+ STRING_QUOTE = "'"
58
+
59
+ # Byte value of `)` — used to detect the `(*)` overlap pattern where
60
+ # the embedded-remark opener `(*` and closer `*)` share the `*`.
61
+ RPAREN_BYTE = ")".ord
62
+
63
+ # Tag forms (see ISO 10303-11 §7.1.6.3 Remark tag).
64
+ # IP-style informal-proposition tags are recognised only in tail remarks
65
+ # (matching historic behaviour); embedded remarks use the quoted form.
66
+ INFORMAL_PROPOSITION_TAG = /\A(IP\d+):\s*(.*)\z/m
67
+ QUOTED_TAG = /\A"([^"]*)"\s*(.*)\z/m
68
+
69
+ # @param source [String] EXPRESS source text (any encoding).
70
+ def initialize(source)
71
+ @source = source
72
+ @source_bytes = source.b
73
+ @line_map = LineMap.new(@source_bytes)
74
+ end
75
+
76
+ # Returns an Array of {Remark}, in source order (by byte position).
77
+ # @return [Array<Remark>]
78
+ def scan
79
+ run_state_machine
80
+ end
81
+
82
+ alias call scan
83
+
84
+ private
85
+
86
+ # Detects the `(*)` overlap pattern: opener `(*` (positions pos, pos+1)
87
+ # and closer `*)` (positions pos+1, pos+2) share the `*` character.
88
+ # Per strict ISO 10303-11 §7.1.6 grammar the opener and closer are
89
+ # distinct tokens that cannot share characters, so `(*)` is technically
90
+ # invalid; we accept it as a 3-character empty embedded remark for
91
+ # robustness (issue #126).
92
+ def empty_overlap_remark?(pos)
93
+ pos + 2 < @source_bytes.bytesize &&
94
+ @source_bytes.getbyte(pos + 2) == RPAREN_BYTE
95
+ end
96
+
97
+ def run_state_machine
98
+ remarks = []
99
+ src = @source_bytes
100
+ pos = 0
101
+
102
+ state = :top
103
+ embedded_depth = 0
104
+ tail_start = nil # byte position of first content char after "--"
105
+ tail_line = nil
106
+ embedded_start = nil # byte position of first content char after "(*"
107
+ embedded_line = nil
108
+
109
+ while pos < src.bytesize
110
+ case state
111
+ when :in_tail
112
+ nl = src.index("\n", pos)
113
+ if nl
114
+ emit_tail(remarks, tail_start, nl, tail_line)
115
+ pos = nl + 1
116
+ state = :top
117
+ else
118
+ emit_tail(remarks, tail_start, src.bytesize, tail_line)
119
+ return remarks
120
+ end
121
+ when :in_embedded
122
+ closing = src.index(EMBEDDED_CLOSE, pos)
123
+ nesting = src.index(EMBEDDED_OPEN, pos)
124
+ if nesting && (!closing || nesting < closing)
125
+ if empty_overlap_remark?(nesting)
126
+ # `(*)` opens and closes immediately; depth unchanged.
127
+ pos = nesting + 3
128
+ else
129
+ embedded_depth += 1
130
+ pos = nesting + EMBEDDED_OPEN.bytesize
131
+ end
132
+ elsif closing
133
+ embedded_depth -= 1
134
+ pos = closing + EMBEDDED_CLOSE.bytesize
135
+ if embedded_depth.zero?
136
+ emit_embedded(remarks, embedded_start, closing, embedded_line)
137
+ state = :top
138
+ end
139
+ else
140
+ # Unclosed embedded remark — terminate scan gracefully.
141
+ return remarks
142
+ end
143
+ when :in_string
144
+ # ISO 10303-11 §7.1.6.5: `''` inside a string is an escaped quote.
145
+ # Find the next `'`; if followed by another `'`, skip both; else
146
+ # the string closes.
147
+ quote_pos = src.index(STRING_QUOTE, pos)
148
+ if quote_pos.nil?
149
+ # Unclosed string — malformed EXPRESS; terminate scan gracefully.
150
+ return remarks
151
+ end
152
+
153
+ if quote_pos + 1 < src.bytesize && src.getbyte(quote_pos + 1) == STRING_QUOTE.ord
154
+ pos = quote_pos + 2
155
+ else
156
+ pos = quote_pos + 1
157
+ state = :top
158
+ end
159
+ else # :top
160
+ next_embed = src.index(EMBEDDED_OPEN, pos)
161
+ next_tail = src.index(TAIL_MARKER, pos)
162
+ next_quote = src.index(STRING_QUOTE, pos)
163
+ if next_embed.nil? && next_tail.nil? && next_quote.nil?
164
+ return remarks
165
+ elsif next_quote && (next_embed.nil? || next_quote < next_embed) &&
166
+ (next_tail.nil? || next_quote < next_tail)
167
+ # String opener wins — enter :in_string and skip the quote.
168
+ pos = next_quote + STRING_QUOTE.bytesize
169
+ state = :in_string
170
+ elsif next_embed && (next_tail.nil? || next_embed <= next_tail)
171
+ if empty_overlap_remark?(next_embed)
172
+ # Standalone `(*)`: empty embedded remark, opener and closer
173
+ # share the `*`. Emit with empty content and continue.
174
+ emit_embedded(remarks, next_embed + EMBEDDED_OPEN.bytesize,
175
+ next_embed + EMBEDDED_OPEN.bytesize,
176
+ @line_map.line_number(next_embed))
177
+ pos = next_embed + 3
178
+ state = :top
179
+ else
180
+ embedded_start = next_embed + EMBEDDED_OPEN.bytesize
181
+ embedded_line = @line_map.line_number(next_embed)
182
+ embedded_depth = 1
183
+ state = :in_embedded
184
+ pos = next_embed + EMBEDDED_OPEN.bytesize
185
+ end
186
+ else
187
+ tail_start = next_tail + TAIL_MARKER.bytesize
188
+ tail_line = @line_map.line_number(next_tail)
189
+ state = :in_tail
190
+ pos = next_tail + TAIL_MARKER.bytesize
191
+ end
192
+ end
193
+ end
194
+
195
+ remarks
196
+ end
197
+
198
+ def emit_tail(remarks, content_start, content_end, line)
199
+ # Slice content from the original (UTF-8) source so the returned text
200
+ # preserves the source encoding. Byte offsets come from the BINARY copy.
201
+ raw = @source.byteslice(content_start...content_end)
202
+ tag, content = parse_tail(raw.strip)
203
+ remarks << Remark.new(
204
+ position: content_start - TAIL_MARKER.bytesize,
205
+ end_position: content_end,
206
+ line: line,
207
+ text: content,
208
+ tag: tag,
209
+ format: Model::RemarkFormat::TAIL,
210
+ )
211
+ end
212
+
213
+ def emit_embedded(remarks, content_start, content_end, line)
214
+ raw = @source.byteslice(content_start...content_end)
215
+ tag, content = parse_embedded(raw)
216
+ remarks << Remark.new(
217
+ position: content_start - EMBEDDED_OPEN.bytesize,
218
+ end_position: content_end + EMBEDDED_CLOSE.bytesize,
219
+ line: line,
220
+ text: content,
221
+ tag: tag,
222
+ format: Model::RemarkFormat::EMBEDDED,
223
+ )
224
+ end
225
+
226
+ # Tail remarks support both quoted tags ("schema.entity" ...) and the
227
+ # shorthand IP-tag form (IP1: ...). Returns [tag, content]; for untagged
228
+ # remarks tag is nil and content is the stripped text.
229
+ def parse_tail(text)
230
+ match = text.match(INFORMAL_PROPOSITION_TAG) || text.match(QUOTED_TAG)
231
+ match ? match.captures : [nil, text]
232
+ end
233
+
234
+ # Embedded remarks only use the quoted tag form.
235
+ def parse_embedded(content)
236
+ stripped = content.strip
237
+ if (m = stripped.match(QUOTED_TAG))
238
+ [m[1], m[2]]
239
+ else
240
+ [nil, stripped]
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Splits multi-schema EXPRESS source into individual schema blocks.
6
+ #
7
+ # A hand-rolled state machine that skips comments (`(* ... *)`) and
8
+ # string literals, tracks SCHEMA/END_SCHEMA depth, and returns one
9
+ # block per schema declaration. Used by `Parser.from_exp_streaming`
10
+ # to parse each schema independently with a fresh memory arena.
11
+ #
12
+ # Extracted from the outer Parser class (TODO.bugs/09) so block-
13
+ # scanning logic lives in one focused module behind one interface.
14
+ module SchemaBlockScanner
15
+ END_SCHEMA_KEYWORD = "END_SCHEMA"
16
+ SCHEMA_KEYWORD = "SCHEMA"
17
+ WHITESPACE_CHARS = [" ", "\t", "\n", "\r"].freeze
18
+
19
+ module_function
20
+
21
+ # Extract individual schema blocks from EXPRESS source.
22
+ #
23
+ # Uses a state machine to properly handle nested comments and strings.
24
+ #
25
+ # @param source [String] EXPRESS source
26
+ # @return [Array<Hash>] Array of {name:, source:, start_pos:, end_pos:}
27
+ # for each schema block found.
28
+ def extract_schema_blocks(source)
29
+ blocks = []
30
+ pos = 0
31
+ len = source.length
32
+
33
+ while pos < len
34
+ pos = skip_ws_and_comments(source, pos)
35
+ break if pos >= len
36
+
37
+ if source[pos..].start_with?(SCHEMA_KEYWORD)
38
+ result = parse_schema_block(source, pos)
39
+ if result
40
+ blocks << result
41
+ pos = result[:end_pos]
42
+ next
43
+ end
44
+ end
45
+
46
+ pos += 1
47
+ end
48
+
49
+ blocks
50
+ end
51
+
52
+ def parse_schema_block(source, start_pos)
53
+ return nil unless source[start_pos..].start_with?(SCHEMA_KEYWORD)
54
+
55
+ pos = start_pos + SCHEMA_KEYWORD.length
56
+ pos = skip_spaces(source, pos)
57
+
58
+ name_start = pos
59
+ while pos < source.length && (source[pos] =~ /[a-zA-Z0-9_]/ || source[pos] == "_")
60
+ pos += 1
61
+ end
62
+ schema_name = source[name_start...pos]
63
+ return nil if schema_name.empty?
64
+
65
+ depth = 1
66
+ search_pos = pos
67
+ end_pos = nil
68
+
69
+ while search_pos < source.length
70
+ if source[search_pos] == '"'
71
+ search_pos += 1
72
+ while search_pos < source.length && source[search_pos] != '"'
73
+ search_pos += 1
74
+ end
75
+ search_pos += 1
76
+ elsif source[search_pos] == "(" && source[search_pos + 1] == "*"
77
+ search_pos += 2
78
+ while search_pos < source.length && !(source[search_pos] == "*" && source[search_pos + 1] == ")")
79
+ search_pos += 1
80
+ end
81
+ search_pos += 2
82
+ elsif source[search_pos..].start_with?(END_SCHEMA_KEYWORD)
83
+ depth -= 1
84
+ if depth.zero?
85
+ end_pos = search_pos + END_SCHEMA_KEYWORD.length
86
+ pos = end_pos
87
+ pos = skip_spaces(source, pos)
88
+ pos += 1 if source[pos] == ";"
89
+ break
90
+ end
91
+ search_pos += END_SCHEMA_KEYWORD.length
92
+ else
93
+ search_pos += 1
94
+ end
95
+ end
96
+
97
+ return nil unless end_pos
98
+
99
+ {
100
+ name: schema_name,
101
+ source: source[start_pos...end_pos],
102
+ start_pos: start_pos,
103
+ end_pos: pos,
104
+ }
105
+ end
106
+
107
+ def skip_spaces(source, pos)
108
+ while pos < source.length && WHITESPACE_CHARS.include?(source[pos])
109
+ pos += 1
110
+ end
111
+ pos
112
+ end
113
+
114
+ def skip_ws_and_comments(source, pos)
115
+ len = source.length
116
+ while pos < len
117
+ c = source[pos]
118
+ if WHITESPACE_CHARS.include?(c)
119
+ pos += 1
120
+ elsif c == "(" && source[pos + 1] == "*"
121
+ pos += 2
122
+ while pos < len - 1 && !(source[pos] == "*" && source[pos + 1] == ")")
123
+ pos += 1
124
+ end
125
+ pos += 2
126
+ else
127
+ break
128
+ end
129
+ end
130
+ pos
131
+ end
132
+
133
+ private_class_method :parse_schema_block, :skip_spaces, :skip_ws_and_comments
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Pre-built Formatter subclass for Schema#source.
6
+ #
7
+ # Combines SchemaHeadFormatter (renders the SCHEMA header block) with
8
+ # HyperlinkFormatter (renders cross-references as hyperlinks). Allocated
9
+ # once at load time rather than per #source call.
10
+ class SchemaSourceFormatter < Formatter
11
+ include SchemaHeadFormatter
12
+ include HyperlinkFormatter
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Resolves the containing scope for a remark line.
6
+ #
7
+ # One interface (`containing_scope_for`), four strategies behind it:
8
+ # 1. O(1) lookup via a precomputed line→scope-name map
9
+ # 2. O(n) position-based fallback against the node index
10
+ # 3. Source-text scan for entity/type/rule boundaries
11
+ # 4. Schema-wide search by name
12
+ #
13
+ # Extracted from RemarkAttacher to deepen the seam: scope lookup is the
14
+ # only concern here, so changes to scope-detection heuristics land in
15
+ # one module and the attacher stops carrying the how.
16
+ class ScopeResolver
17
+ # Match declarations of the scopes we track.
18
+ SCOPE_OPEN_PATTERNS = {
19
+ Model::Declarations::Schema => /^\s*SCHEMA\s+(\w+)/i,
20
+ Model::Declarations::Function => /^\s*FUNCTION\s+(\w+)/i,
21
+ Model::Declarations::Procedure => /^\s*PROCEDURE\s+(\w+)/i,
22
+ Model::Declarations::Rule => /^\s*RULE\s+(\w+)/i,
23
+ Model::Declarations::Entity => /^\s*ENTITY\s+(\w+)/i,
24
+ Model::Declarations::Type => /^\s*TYPE\s+(\w+)/i,
25
+ }.freeze
26
+
27
+ SCOPE_CLOSE_PATTERNS = {
28
+ Model::Declarations::Schema => /END_SCHEMA/i,
29
+ Model::Declarations::Function => /END_FUNCTION/i,
30
+ Model::Declarations::Procedure => /END_PROCEDURE/i,
31
+ Model::Declarations::Rule => /END_RULE/i,
32
+ Model::Declarations::Entity => /END_ENTITY/i,
33
+ Model::Declarations::Type => /END_TYPE/i,
34
+ }.freeze
35
+
36
+ # Map scope node class → schema-level collection accessor on Schema.
37
+ SCHEMA_COLLECTION_ACCESSOR = {
38
+ Model::Declarations::Entity => lambda(&:entities),
39
+ Model::Declarations::Type => lambda(&:types),
40
+ Model::Declarations::Rule => lambda(&:rules),
41
+ }.freeze
42
+
43
+ # Schema-level collections searched when resolving a scope name to a
44
+ # model node. Sourced from Model::Declarations::Schema::SCOPE_DECL_COLLECTIONS
45
+ # so the "which Schema collections hold named scopes" fact lives on
46
+ # the model, not duplicated here.
47
+
48
+ def initialize(source:, model:, nodes_with_positions:)
49
+ @source = source
50
+ @model = model
51
+ @nodes_with_positions = nodes_with_positions
52
+ @scope_map = nil
53
+ end
54
+
55
+ # Returns the innermost Model::ScopeContainer whose source span contains
56
+ # the given 1-based remark line, or nil if none is found.
57
+ def containing_scope_for(remark_line)
58
+ scope = find_by_name(remark_line)
59
+ scope ||= find_by_position(remark_line)
60
+ scope
61
+ end
62
+
63
+ # Source-text fallback: scan backwards from remark_line for the
64
+ # entity/type/rule declaration that contains it. Used by the attacher
65
+ # when both name- and position-based lookup miss (e.g. for IP tags
66
+ # whose target is a Type/Entity/Rule declared on a previous line).
67
+ def find_by_source_text(remark_line)
68
+ entity_state = { line: nil, name: nil }
69
+ type_state = { line: nil, name: nil }
70
+ rule_state = { line: nil, name: nil }
71
+
72
+ @source.lines.each_with_index do |line, idx|
73
+ line_num = idx + 1
74
+
75
+ case line
76
+ when /^\s*ENTITY\s+(\w+)/i
77
+ entity_state = { line: line_num, name: $1 }
78
+ when /^\s*END_ENTITY/i
79
+ return find_in_schema(Model::Declarations::Entity, entity_state[:name]) if span_contains?(entity_state, remark_line, line_num)
80
+
81
+ entity_state = { line: nil, name: nil }
82
+ when /^\s*TYPE\s+(\w+)/i
83
+ type_state = { line: line_num, name: $1 }
84
+ when /^\s*END_TYPE/i
85
+ return find_in_schema(Model::Declarations::Type, type_state[:name]) if span_contains?(type_state, remark_line, line_num)
86
+
87
+ type_state = { line: nil, name: nil }
88
+ when /^\s*RULE\s+(\w+)/i
89
+ rule_state = { line: line_num, name: $1 }
90
+ when /^\s*END_RULE/i
91
+ return find_in_schema(Model::Declarations::Rule, rule_state[:name]) if span_contains?(rule_state, remark_line, line_num)
92
+
93
+ rule_state = { line: nil, name: nil }
94
+ end
95
+ end
96
+
97
+ nil
98
+ end
99
+
100
+ private
101
+
102
+ def span_contains?(state, remark_line, end_line)
103
+ state[:line] && remark_line >= state[:line] && remark_line <= end_line
104
+ end
105
+
106
+ # --- Strategy 1: precomputed line → scope-name map ---
107
+
108
+ def find_by_name(remark_line)
109
+ scope_name = scope_map[remark_line]
110
+ return nil unless scope_name
111
+ return nil unless @model
112
+
113
+ @model.schemas.each do |schema|
114
+ return schema if schema.id == scope_name
115
+
116
+ Model::Declarations::Schema::SCOPE_DECL_COLLECTIONS.each do |decl_type|
117
+ collection = schema.public_send(decl_type)
118
+ next unless collection.is_a?(Array)
119
+
120
+ found = collection.find { |n| n.id == scope_name }
121
+ return found if found
122
+ end
123
+ end
124
+
125
+ nil
126
+ end
127
+
128
+ # Lazily build, on first lookup, the line → scope-name table.
129
+ # O(file_lines) scan; subsequent lookups are O(1).
130
+ def scope_map
131
+ @scope_map ||= build_scope_map
132
+ end
133
+
134
+ def build_scope_map
135
+ lines = @source.lines
136
+ map = {}
137
+ return map if lines.empty?
138
+
139
+ stack = [] # array of { type: Class, name: String }
140
+
141
+ lines.each_with_index do |line, idx|
142
+ line_num = idx + 1
143
+
144
+ SCOPE_OPEN_PATTERNS.each do |klass, pattern|
145
+ stack << { type: klass, name: $1 } if line =~ pattern
146
+ end
147
+
148
+ SCOPE_CLOSE_PATTERNS.each do |klass, pattern|
149
+ next unless line&.match?(pattern)
150
+ next unless stack.last && stack.last[:type] == klass
151
+
152
+ stack.pop
153
+ end
154
+
155
+ map[line_num] = stack.last&.dig(:name)
156
+ end
157
+
158
+ map
159
+ end
160
+
161
+ # --- Strategy 2: position-based fallback against the node index ---
162
+
163
+ def find_by_position(remark_line)
164
+ containing = @nodes_with_positions.select do |n|
165
+ n[:line] && n[:end_line] &&
166
+ remark_line >= n[:line] && remark_line <= n[:end_line] &&
167
+ !n[:node].is_a?(Model::Repository) && !n[:node].is_a?(Model::Cache)
168
+ end
169
+
170
+ containing.reverse_each do |n|
171
+ return n[:node] if n[:node].is_a?(Model::ScopeContainer)
172
+ end
173
+
174
+ nil
175
+ end
176
+
177
+ # --- Schema-wide name search (used by source-text fallback) ---
178
+
179
+ def find_in_schema(node_class, name)
180
+ return nil unless @model && name
181
+
182
+ accessor = SCHEMA_COLLECTION_ACCESSOR[node_class]
183
+ return nil unless accessor
184
+
185
+ @model.schemas.each do |schema|
186
+ found = accessor.call(schema)&.find { |n| n.id == name }
187
+ return found if found
188
+ end
189
+
190
+ nil
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Pre-built Formatter subclass that mixes in HyperlinkFormatter.
6
+ #
7
+ # Used by ModelElement#source to format an element back into EXPRESS
8
+ # source text with hyperlinks. Allocated once at load time rather than
9
+ # per #source call (which was the previous behaviour — wasteful and
10
+ # rebuilt the format_registry on every call).
11
+ class SourceFormatter < Formatter
12
+ include HyperlinkFormatter
13
+ end
14
+ end
15
+ end