expressir 2.4.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) 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/node_position_index.rb +215 -0
  61. data/lib/expressir/express/parser.rb +114 -884
  62. data/lib/expressir/express/pretty_formatter.rb +23 -5
  63. data/lib/expressir/express/remark_attacher.rb +475 -577
  64. data/lib/expressir/express/remark_scanner.rb +84 -19
  65. data/lib/expressir/express/schema_block_scanner.rb +136 -0
  66. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  67. data/lib/expressir/express/scope_resolver.rb +194 -0
  68. data/lib/expressir/express/source_formatter.rb +15 -0
  69. data/lib/expressir/express/streaming_builder.rb +147 -176
  70. data/lib/expressir/express.rb +8 -6
  71. data/lib/expressir/model/concerns.rb +3 -0
  72. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  73. data/lib/expressir/model/declarations/entity.rb +5 -0
  74. data/lib/expressir/model/declarations/function.rb +5 -0
  75. data/lib/expressir/model/declarations/procedure.rb +5 -0
  76. data/lib/expressir/model/declarations/rule.rb +6 -0
  77. data/lib/expressir/model/declarations/schema.rb +21 -8
  78. data/lib/expressir/model/declarations/type.rb +3 -0
  79. data/lib/expressir/model/exp_file.rb +2 -0
  80. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  81. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  82. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  83. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  84. data/lib/expressir/model/expressions/function_call.rb +1 -0
  85. data/lib/expressir/model/expressions/interval.rb +1 -0
  86. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  87. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  88. data/lib/expressir/model/model_element.rb +128 -14
  89. data/lib/expressir/model/remark_format.rb +17 -0
  90. data/lib/expressir/model/remark_info.rb +31 -3
  91. data/lib/expressir/model/remark_placement.rb +29 -0
  92. data/lib/expressir/model/statements/alias.rb +2 -0
  93. data/lib/expressir/model/statements/assignment.rb +4 -0
  94. data/lib/expressir/model/statements/case.rb +21 -0
  95. data/lib/expressir/model/statements/case_action.rb +1 -0
  96. data/lib/expressir/model/statements/compound.rb +4 -0
  97. data/lib/expressir/model/statements/escape.rb +3 -0
  98. data/lib/expressir/model/statements/if.rb +5 -0
  99. data/lib/expressir/model/statements/null.rb +3 -0
  100. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  101. data/lib/expressir/model/statements/repeat.rb +3 -0
  102. data/lib/expressir/model/statements/return.rb +3 -0
  103. data/lib/expressir/model/statements/skip.rb +3 -0
  104. data/lib/expressir/model.rb +12 -3
  105. data/lib/expressir/package/builder.rb +2 -2
  106. data/lib/expressir/version.rb +6 -1
  107. data/lib/expressir.rb +38 -3
  108. metadata +63 -5
  109. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -15,35 +15,50 @@ module Expressir
15
15
  # embedded_remark = '(*' [ remark_tag ]
16
16
  # { <any char> | nested embedded_remark } '*)'
17
17
  #
18
- # The scanner is a single-pass state machine with three states:
19
- # :top — outside any remark; look for `--` or `(*`.
18
+ # The scanner is a single-pass state machine with four states:
19
+ # :top — outside any remark/string; look for `--`, `(*`, or `'`.
20
20
  # :in_tail — inside a tail remark; look only for the next newline.
21
21
  # :in_embedded — inside one or more nested embedded remarks; look
22
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.
23
26
  class RemarkScanner
24
27
  # Immutable value object describing a single extracted remark.
25
- Remark = Struct.new(:position, :line, :text, :tag, :format, keyword_init: true) do
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
26
35
  def tail?
27
- format == TAIL_FORMAT
36
+ format == Model::RemarkFormat::TAIL
28
37
  end
29
38
 
30
39
  def embedded?
31
- format == EMBEDDED_FORMAT
40
+ format == Model::RemarkFormat::EMBEDDED
32
41
  end
33
42
 
34
43
  def tagged?
35
44
  !tag.nil? && !tag.empty?
36
45
  end
37
- end
38
46
 
39
- # Format discriminator values.
40
- TAIL_FORMAT = "tail"
41
- EMBEDDED_FORMAT = "embedded"
47
+ # Byte length of the full remark (opener + content + closer).
48
+ def bytesize
49
+ end_position - position
50
+ end
51
+ end
42
52
 
43
53
  # Lexical markers recognised by EXPRESS.
44
54
  TAIL_MARKER = "--"
45
55
  EMBEDDED_OPEN = "(*"
46
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
47
62
 
48
63
  # Tag forms (see ISO 10303-11 §7.1.6.3 Remark tag).
49
64
  # IP-style informal-proposition tags are recognised only in tail remarks
@@ -68,6 +83,17 @@ module Expressir
68
83
 
69
84
  private
70
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
+
71
97
  def run_state_machine
72
98
  remarks = []
73
99
  src = @source_bytes
@@ -96,8 +122,13 @@ module Expressir
96
122
  closing = src.index(EMBEDDED_CLOSE, pos)
97
123
  nesting = src.index(EMBEDDED_OPEN, pos)
98
124
  if nesting && (!closing || nesting < closing)
99
- embedded_depth += 1
100
- pos = nesting + EMBEDDED_OPEN.bytesize
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
101
132
  elsif closing
102
133
  embedded_depth -= 1
103
134
  pos = closing + EMBEDDED_CLOSE.bytesize
@@ -109,17 +140,49 @@ module Expressir
109
140
  # Unclosed embedded remark — terminate scan gracefully.
110
141
  return remarks
111
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
112
159
  else # :top
113
160
  next_embed = src.index(EMBEDDED_OPEN, pos)
114
161
  next_tail = src.index(TAIL_MARKER, pos)
115
- if next_embed.nil? && next_tail.nil?
162
+ next_quote = src.index(STRING_QUOTE, pos)
163
+ if next_embed.nil? && next_tail.nil? && next_quote.nil?
116
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
117
170
  elsif next_embed && (next_tail.nil? || next_embed <= next_tail)
118
- embedded_start = next_embed + EMBEDDED_OPEN.bytesize
119
- embedded_line = @line_map.line_number(next_embed)
120
- embedded_depth = 1
121
- state = :in_embedded
122
- pos = next_embed + EMBEDDED_OPEN.bytesize
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
123
186
  else
124
187
  tail_start = next_tail + TAIL_MARKER.bytesize
125
188
  tail_line = @line_map.line_number(next_tail)
@@ -139,10 +202,11 @@ module Expressir
139
202
  tag, content = parse_tail(raw.strip)
140
203
  remarks << Remark.new(
141
204
  position: content_start - TAIL_MARKER.bytesize,
205
+ end_position: content_end,
142
206
  line: line,
143
207
  text: content,
144
208
  tag: tag,
145
- format: TAIL_FORMAT,
209
+ format: Model::RemarkFormat::TAIL,
146
210
  )
147
211
  end
148
212
 
@@ -151,10 +215,11 @@ module Expressir
151
215
  tag, content = parse_embedded(raw)
152
216
  remarks << Remark.new(
153
217
  position: content_start - EMBEDDED_OPEN.bytesize,
218
+ end_position: content_end + EMBEDDED_CLOSE.bytesize,
154
219
  line: line,
155
220
  text: content,
156
221
  tag: tag,
157
- format: EMBEDDED_FORMAT,
222
+ format: Model::RemarkFormat::EMBEDDED,
158
223
  )
159
224
  end
160
225
 
@@ -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