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.
Files changed (129) 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/TODO.max-perf/01-restore-ci-green.md +29 -0
  34. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  35. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  36. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  37. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  38. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  39. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  40. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  41. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  42. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  43. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  44. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  45. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  46. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  47. data/benchmark/srl_benchmark.rb +76 -17
  48. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  49. data/docs/_tutorials/index.adoc +10 -0
  50. data/docs/lychee.toml +3 -0
  51. data/expressir.gemspec +4 -2
  52. data/lib/expressir/cli.rb +3 -0
  53. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  54. data/lib/expressir/commands/changes_validate.rb +0 -2
  55. data/lib/expressir/commands/clean.rb +1 -1
  56. data/lib/expressir/commands/coverage.rb +6 -2
  57. data/lib/expressir/commands/file_violations.rb +70 -0
  58. data/lib/expressir/commands/format.rb +1 -1
  59. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  60. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  61. data/lib/expressir/commands/package.rb +143 -187
  62. data/lib/expressir/commands/validate_ascii.rb +0 -510
  63. data/lib/expressir/commands/version.rb +1 -1
  64. data/lib/expressir/commands.rb +3 -0
  65. data/lib/expressir/coverage.rb +49 -117
  66. data/lib/expressir/express/ast_key_converter.rb +114 -0
  67. data/lib/expressir/express/builder.rb +71 -194
  68. data/lib/expressir/express/builder_context.rb +22 -0
  69. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  70. data/lib/expressir/express/cache.rb +35 -8
  71. data/lib/expressir/express/error.rb +24 -0
  72. data/lib/expressir/express/formatter.rb +33 -18
  73. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  74. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  75. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  76. data/lib/expressir/express/grammar/parser.rb +705 -0
  77. data/lib/expressir/express/grammar.rb +11 -0
  78. data/lib/expressir/express/node_position_index.rb +215 -0
  79. data/lib/expressir/express/parallel_files.rb +229 -0
  80. data/lib/expressir/express/parser.rb +138 -950
  81. data/lib/expressir/express/pretty_formatter.rb +23 -5
  82. data/lib/expressir/express/remark_attacher.rb +497 -581
  83. data/lib/expressir/express/remark_scanner.rb +84 -19
  84. data/lib/expressir/express/schema_block_scanner.rb +137 -0
  85. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  86. data/lib/expressir/express/scope_resolver.rb +223 -0
  87. data/lib/expressir/express/source_formatter.rb +15 -0
  88. data/lib/expressir/express/streaming_builder.rb +147 -176
  89. data/lib/expressir/express.rb +10 -6
  90. data/lib/expressir/model/concerns.rb +3 -0
  91. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  92. data/lib/expressir/model/declarations/entity.rb +5 -0
  93. data/lib/expressir/model/declarations/function.rb +5 -0
  94. data/lib/expressir/model/declarations/procedure.rb +5 -0
  95. data/lib/expressir/model/declarations/rule.rb +6 -0
  96. data/lib/expressir/model/declarations/schema.rb +21 -8
  97. data/lib/expressir/model/declarations/type.rb +3 -0
  98. data/lib/expressir/model/exp_file.rb +2 -0
  99. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  100. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  101. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  102. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  103. data/lib/expressir/model/expressions/function_call.rb +1 -0
  104. data/lib/expressir/model/expressions/interval.rb +1 -0
  105. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  106. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  107. data/lib/expressir/model/model_element.rb +133 -14
  108. data/lib/expressir/model/remark_format.rb +17 -0
  109. data/lib/expressir/model/remark_info.rb +31 -3
  110. data/lib/expressir/model/remark_placement.rb +29 -0
  111. data/lib/expressir/model/repository.rb +18 -5
  112. data/lib/expressir/model/statements/alias.rb +2 -0
  113. data/lib/expressir/model/statements/assignment.rb +4 -0
  114. data/lib/expressir/model/statements/case.rb +21 -0
  115. data/lib/expressir/model/statements/case_action.rb +1 -0
  116. data/lib/expressir/model/statements/compound.rb +4 -0
  117. data/lib/expressir/model/statements/escape.rb +3 -0
  118. data/lib/expressir/model/statements/if.rb +5 -0
  119. data/lib/expressir/model/statements/null.rb +3 -0
  120. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  121. data/lib/expressir/model/statements/repeat.rb +3 -0
  122. data/lib/expressir/model/statements/return.rb +3 -0
  123. data/lib/expressir/model/statements/skip.rb +3 -0
  124. data/lib/expressir/model.rb +12 -3
  125. data/lib/expressir/package/builder.rb +2 -2
  126. data/lib/expressir/version.rb +6 -1
  127. data/lib/expressir.rb +56 -3
  128. metadata +81 -7
  129. 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,137 @@
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. Retained for the future streaming
10
+ # parse path (see TODO.max-perf/02) that parses each schema
11
+ # independently with a fresh memory arena.
12
+ #
13
+ # Extracted from the outer Parser class (TODO.bugs/09) so block-
14
+ # scanning logic lives in one focused module behind one interface.
15
+ module SchemaBlockScanner
16
+ END_SCHEMA_KEYWORD = "END_SCHEMA"
17
+ SCHEMA_KEYWORD = "SCHEMA"
18
+ WHITESPACE_CHARS = [" ", "\t", "\n", "\r"].freeze
19
+
20
+ module_function
21
+
22
+ # Extract individual schema blocks from EXPRESS source.
23
+ #
24
+ # Uses a state machine to properly handle nested comments and strings.
25
+ #
26
+ # @param source [String] EXPRESS source
27
+ # @return [Array<Hash>] Array of {name:, source:, start_pos:, end_pos:}
28
+ # for each schema block found.
29
+ def extract_schema_blocks(source)
30
+ blocks = []
31
+ pos = 0
32
+ len = source.length
33
+
34
+ while pos < len
35
+ pos = skip_ws_and_comments(source, pos)
36
+ break if pos >= len
37
+
38
+ if source[pos..].start_with?(SCHEMA_KEYWORD)
39
+ result = parse_schema_block(source, pos)
40
+ if result
41
+ blocks << result
42
+ pos = result[:end_pos]
43
+ next
44
+ end
45
+ end
46
+
47
+ pos += 1
48
+ end
49
+
50
+ blocks
51
+ end
52
+
53
+ def parse_schema_block(source, start_pos)
54
+ return nil unless source[start_pos..].start_with?(SCHEMA_KEYWORD)
55
+
56
+ pos = start_pos + SCHEMA_KEYWORD.length
57
+ pos = skip_spaces(source, pos)
58
+
59
+ name_start = pos
60
+ while pos < source.length && (source[pos] =~ /[a-zA-Z0-9_]/ || source[pos] == "_")
61
+ pos += 1
62
+ end
63
+ schema_name = source[name_start...pos]
64
+ return nil if schema_name.empty?
65
+
66
+ depth = 1
67
+ search_pos = pos
68
+ end_pos = nil
69
+
70
+ while search_pos < source.length
71
+ if source[search_pos] == '"'
72
+ search_pos += 1
73
+ while search_pos < source.length && source[search_pos] != '"'
74
+ search_pos += 1
75
+ end
76
+ search_pos += 1
77
+ elsif source[search_pos] == "(" && source[search_pos + 1] == "*"
78
+ search_pos += 2
79
+ while search_pos < source.length && !(source[search_pos] == "*" && source[search_pos + 1] == ")")
80
+ search_pos += 1
81
+ end
82
+ search_pos += 2
83
+ elsif source[search_pos..].start_with?(END_SCHEMA_KEYWORD)
84
+ depth -= 1
85
+ if depth.zero?
86
+ end_pos = search_pos + END_SCHEMA_KEYWORD.length
87
+ pos = end_pos
88
+ pos = skip_spaces(source, pos)
89
+ pos += 1 if source[pos] == ";"
90
+ break
91
+ end
92
+ search_pos += END_SCHEMA_KEYWORD.length
93
+ else
94
+ search_pos += 1
95
+ end
96
+ end
97
+
98
+ return nil unless end_pos
99
+
100
+ {
101
+ name: schema_name,
102
+ source: source[start_pos...end_pos],
103
+ start_pos: start_pos,
104
+ end_pos: pos,
105
+ }
106
+ end
107
+
108
+ def skip_spaces(source, pos)
109
+ while pos < source.length && WHITESPACE_CHARS.include?(source[pos])
110
+ pos += 1
111
+ end
112
+ pos
113
+ end
114
+
115
+ def skip_ws_and_comments(source, pos)
116
+ len = source.length
117
+ while pos < len
118
+ c = source[pos]
119
+ if WHITESPACE_CHARS.include?(c)
120
+ pos += 1
121
+ elsif c == "(" && source[pos + 1] == "*"
122
+ pos += 2
123
+ while pos < len - 1 && !(source[pos] == "*" && source[pos + 1] == ")")
124
+ pos += 1
125
+ end
126
+ pos += 2
127
+ else
128
+ break
129
+ end
130
+ end
131
+ pos
132
+ end
133
+
134
+ private_class_method :parse_schema_block, :skip_spaces, :skip_ws_and_comments
135
+ end
136
+ end
137
+ 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,223 @@
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
+ @source_lines = nil
54
+ @position_buckets = nil
55
+ end
56
+
57
+ # Line-band width for the position fallback index.
58
+ BUCKET_LINES = 1024
59
+
60
+ # Returns the innermost Model::ScopeContainer whose source span contains
61
+ # the given 1-based remark line, or nil if none is found.
62
+ def containing_scope_for(remark_line)
63
+ scope = find_by_name(remark_line)
64
+ scope ||= find_by_position(remark_line)
65
+ scope
66
+ end
67
+
68
+ # Source-text fallback: scan backwards from remark_line for the
69
+ # entity/type/rule declaration that contains it. Used by the attacher
70
+ # when both name- and position-based lookup miss (e.g. for IP tags
71
+ # whose target is a Type/Entity/Rule declared on a previous line).
72
+ def find_by_source_text(remark_line)
73
+ entity_state = { line: nil, name: nil }
74
+ type_state = { line: nil, name: nil }
75
+ rule_state = { line: nil, name: nil }
76
+
77
+ source_lines.each_with_index do |line, idx|
78
+ line_num = idx + 1
79
+
80
+ case line
81
+ when /^\s*ENTITY\s+(\w+)/i
82
+ entity_state = { line: line_num, name: $1 }
83
+ when /^\s*END_ENTITY/i
84
+ return find_in_schema(Model::Declarations::Entity, entity_state[:name]) if span_contains?(entity_state, remark_line, line_num)
85
+
86
+ entity_state = { line: nil, name: nil }
87
+ when /^\s*TYPE\s+(\w+)/i
88
+ type_state = { line: line_num, name: $1 }
89
+ when /^\s*END_TYPE/i
90
+ return find_in_schema(Model::Declarations::Type, type_state[:name]) if span_contains?(type_state, remark_line, line_num)
91
+
92
+ type_state = { line: nil, name: nil }
93
+ when /^\s*RULE\s+(\w+)/i
94
+ rule_state = { line: line_num, name: $1 }
95
+ when /^\s*END_RULE/i
96
+ return find_in_schema(Model::Declarations::Rule, rule_state[:name]) if span_contains?(rule_state, remark_line, line_num)
97
+
98
+ rule_state = { line: nil, name: nil }
99
+ end
100
+ end
101
+
102
+ nil
103
+ end
104
+
105
+ private
106
+
107
+ def span_contains?(state, remark_line, end_line)
108
+ state[:line] && remark_line >= state[:line] && remark_line <= end_line
109
+ end
110
+
111
+ # --- Strategy 1: precomputed line → scope-name map ---
112
+
113
+ def find_by_name(remark_line)
114
+ scope_name = scope_map[remark_line]
115
+ return nil unless scope_name
116
+ return nil unless @model
117
+
118
+ @model.schemas.each do |schema|
119
+ return schema if schema.id == scope_name
120
+
121
+ Model::Declarations::Schema::SCOPE_DECL_COLLECTIONS.each do |decl_type|
122
+ collection = schema.public_send(decl_type)
123
+ next unless collection.is_a?(Array)
124
+
125
+ found = collection.find { |n| n.id == scope_name }
126
+ return found if found
127
+ end
128
+ end
129
+
130
+ nil
131
+ end
132
+
133
+ # Lazily build, on first lookup, the line → scope-name table.
134
+ # O(file_lines) scan; subsequent lookups are O(1).
135
+ def scope_map
136
+ @scope_map ||= build_scope_map
137
+ end
138
+
139
+ # Source split into lines, computed once per resolver: both the
140
+ # scope-map build and find_by_source_text need it, and re-splitting the
141
+ # whole source per lookup dominates remark attachment on large files.
142
+ def source_lines
143
+ @source_lines ||= @source.lines
144
+ end
145
+
146
+ def build_scope_map
147
+ lines = source_lines
148
+ map = {}
149
+ return map if lines.empty?
150
+
151
+ stack = [] # array of { type: Class, name: String }
152
+
153
+ lines.each_with_index do |line, idx|
154
+ line_num = idx + 1
155
+
156
+ SCOPE_OPEN_PATTERNS.each do |klass, pattern|
157
+ stack << { type: klass, name: $1 } if line =~ pattern
158
+ end
159
+
160
+ SCOPE_CLOSE_PATTERNS.each do |klass, pattern|
161
+ next unless line&.match?(pattern)
162
+ next unless stack.last && stack.last[:type] == klass
163
+
164
+ stack.pop
165
+ end
166
+
167
+ map[line_num] = stack.last&.dig(:name)
168
+ end
169
+
170
+ map
171
+ end
172
+
173
+ # --- Strategy 2: position-based fallback against the node index ---
174
+
175
+ # Nodes bucketed by 1024-line bands: a node spanning [line, end_line]
176
+ # is registered in every band it overlaps, so a remark-line lookup only
177
+ # scans nodes that can possibly contain it. Bucket order preserves the
178
+ # original index order, so select+reverse_each semantics are unchanged.
179
+ def position_buckets
180
+ @position_buckets ||= begin
181
+ buckets = Hash.new { |h, k| h[k] = [] }
182
+ @nodes_with_positions.each do |n|
183
+ next unless n[:line] && n[:end_line]
184
+
185
+ ((n[:line] / BUCKET_LINES)..(n[:end_line] / BUCKET_LINES)).each do |b|
186
+ buckets[b] << n
187
+ end
188
+ end
189
+ buckets
190
+ end
191
+ end
192
+
193
+ def find_by_position(remark_line)
194
+ containing = position_buckets[remark_line / BUCKET_LINES].select do |n|
195
+ remark_line.between?(n[:line], n[:end_line]) &&
196
+ !n[:node].is_a?(Model::Repository) && !n[:node].is_a?(Model::Cache)
197
+ end
198
+
199
+ containing.reverse_each do |n|
200
+ return n[:node] if n[:node].is_a?(Model::ScopeContainer)
201
+ end
202
+
203
+ nil
204
+ end
205
+
206
+ # --- Schema-wide name search (used by source-text fallback) ---
207
+
208
+ def find_in_schema(node_class, name)
209
+ return nil unless @model && name
210
+
211
+ accessor = SCHEMA_COLLECTION_ACCESSOR[node_class]
212
+ return nil unless accessor
213
+
214
+ @model.schemas.each do |schema|
215
+ found = accessor.call(schema)&.find { |n| n.id == name }
216
+ return found if found
217
+ end
218
+
219
+ nil
220
+ end
221
+ end
222
+ end
223
+ 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