expressir 2.3.7 → 2.4.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecbd13317e56cc0139eb317331134c80ba9f5ae95eaf221f2f5e2046ebe95387
4
- data.tar.gz: 86dbdfe6818f5c751d11c6fc4cd81e53a60b38145d3f7d80f5bd1b5ff6869815
3
+ metadata.gz: 738451ad45c2f5e9268ef7562cbb2c667b90648f0e2df9ac409c79ceeeb6b879
4
+ data.tar.gz: e4e04ff5ede476581d65882a5e8d6fefba98ced3345337673bf61366e5b96392
5
5
  SHA512:
6
- metadata.gz: b3b26b8456c55b2a5b342d508a2af8f1e3c46ed23f78b96b5900a036292533c33b68f140248f6c8b552f5ed571adc245fe71acf1159b21feca3aa6c0b7bb354f
7
- data.tar.gz: 3d33e2d5c850898a8b451a91cd76a9a85736b7e4c6d846c29e1652dc5e8fbb080bec35b2c55a908c7da68bdb32c5b7940162ea88cd811a519643a5f97db65fcf
6
+ metadata.gz: 6e9c0b2be28d8fa52f4c64f151e73c345701b2e6a118ee771f1442459c9c97bd5539bf1d799692ad065317849f9e794fb4cdc03a7d55efec1ddf627408f710de
7
+ data.tar.gz: 44100a53632c094d8cd7d5da781a8f2ab63274f4fcdcad44a0aafaf2e6c94821a5eefa6263c9f4c982876f55aeb6e23f5858cb7ca78decce3489a4c00b50abea
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Maps byte positions in a source string to 1-based line numbers.
6
+ #
7
+ # Single source of truth for byte→line lookup across the remark pipeline
8
+ # (RemarkScanner and RemarkAttacher both rely on it). Builds an offset
9
+ # table once and answers queries in O(log n) via binary search.
10
+ class LineMap
11
+ # Newline byte (ASCII 0x0A).
12
+ NEWLINE = "\n"
13
+
14
+ # @param source_bytes [String] BINARY / ASCII-8BIT encoded source.
15
+ def initialize(source_bytes)
16
+ @offsets = build_offsets(source_bytes)
17
+ end
18
+
19
+ # Returns the 1-based line number that contains the given byte position.
20
+ # Returns 1 for position 0 (or any position before the first newline).
21
+ # Returns the last line number for positions past the final newline.
22
+ #
23
+ # @param byte_pos [Integer, nil]
24
+ # @return [Integer] 1-based line number; nil input yields 1.
25
+ def line_number(byte_pos)
26
+ return 1 if byte_pos.nil? || byte_pos <= 0
27
+
28
+ idx = @offsets.bsearch_index { |offset| offset > byte_pos }
29
+ idx || @offsets.size
30
+ end
31
+
32
+ private
33
+
34
+ # Builds the sorted array of byte positions where each line begins.
35
+ # offsets[0] = 0 (line 1 begins at byte 0).
36
+ # offsets[i] = (byte position of the i-th newline) + 1 (line i+1 begins).
37
+ def build_offsets(source_bytes)
38
+ offsets = [0]
39
+ i = 0
40
+ while (i = source_bytes.index(NEWLINE, i))
41
+ offsets << (i + 1)
42
+ i += 1
43
+ end
44
+ offsets
45
+ end
46
+ end
47
+ end
48
+ end
@@ -48,7 +48,7 @@ module Expressir
48
48
  def initialize(source)
49
49
  @source = source
50
50
  @attached_spans = Set.new
51
- @line_cache = {}
51
+ @line_map = LineMap.new(source.b)
52
52
  @model = nil
53
53
  @source_lines = nil # cached @source.lines
54
54
  @scope_map = nil # cached scope at each line number
@@ -56,7 +56,7 @@ module Expressir
56
56
 
57
57
  def attach(model)
58
58
  @model = model
59
- remarks = extract_all_remarks
59
+ remarks = RemarkScanner.new(@source).scan
60
60
 
61
61
  # Build nodes_with_positions ONCE for both tagged and untagged remark passes.
62
62
  # This avoids double tree walk (381K nodes × 2 = 762K visits) which was
@@ -71,103 +71,22 @@ module Expressir
71
71
  @source = nil
72
72
  @source_lines = nil
73
73
  @scope_map = nil
74
- @line_cache = nil
74
+ @line_map = nil
75
75
 
76
76
  model
77
77
  end
78
78
 
79
79
  private
80
80
 
81
- def extract_all_remarks
82
- remarks = []
83
- byte_position = 0
84
-
85
- @source.each_line.with_index do |line, line_idx|
86
- line_bytesize = line.bytesize
87
- line_bytes = line.b # Get byte string for indexing
88
- if (dash_byte_idx = line_bytes.index("--"))
89
- remark_text = line.byteslice((dash_byte_idx + 2)..).strip
90
-
91
- # Check for special patterns like --IP1: content (informal proposition)
92
- if remark_text.match?(/^IP\d+:\s*(.*)$/)
93
- tag = remark_text[/^(IP\d+):/, 1]
94
- content = remark_text[/^IP\d+:\s*(.*)$/, 1]
95
- remarks << {
96
- position: byte_position + dash_byte_idx,
97
- line: line_idx + 1,
98
- text: content,
99
- tag: tag,
100
- format: "tail",
101
- }
102
- else
103
- tag, content = parse_tagged_remark(remark_text)
104
- remarks << {
105
- position: byte_position + dash_byte_idx,
106
- line: line_idx + 1,
107
- text: content || remark_text,
108
- tag: tag,
109
- format: "tail",
110
- }
111
- end
112
- end
113
- byte_position += line_bytesize
114
- end
115
-
116
- extract_embedded_remarks(remarks)
117
- # Sort by position to ensure remarks are processed in source order
118
- remarks.sort_by! { |r| r[:position] }
119
- remarks
120
- end
121
-
122
- def extract_embedded_remarks(remarks)
123
- source_bytes = @source.b
124
- start_pos = 0
125
- while (start_idx = source_bytes.index("(*", start_pos))
126
- end_idx = source_bytes.index("*)", start_idx + 2)
127
- break unless end_idx
128
-
129
- content = @source.byteslice((start_idx + 2)...end_idx)
130
- line_num = get_line_number(start_idx)
131
-
132
- tag, text = parse_tagged_embedded_remark(content)
133
-
134
- remarks << {
135
- position: start_idx,
136
- line: line_num,
137
- text: text,
138
- tag: tag,
139
- format: "embedded",
140
- }
141
-
142
- start_pos = end_idx + 2
143
- end
144
- end
145
-
146
- def parse_tagged_remark(text)
147
- if text.start_with?('"') && (end_quote = text.index('"', 1))
148
- [text[1...end_quote], text[(end_quote + 1)..].strip]
149
- else
150
- [nil, text]
151
- end
152
- end
153
-
154
- def parse_tagged_embedded_remark(content)
155
- stripped = content.strip
156
- if stripped.start_with?('"') && (end_quote = stripped.index('"', 1))
157
- [stripped[1...end_quote], stripped[(end_quote + 1)..].strip]
158
- else
159
- [nil, stripped]
160
- end
161
- end
81
+ # Remark extraction lives in {RemarkScanner}; this class only attaches.
82
+ # Line-number lookup is delegated to {LineMap} for O(log n) queries.
162
83
 
163
84
  def source_lines
164
85
  @source_lines ||= @source.lines
165
86
  end
166
87
 
167
88
  def get_line_number(position)
168
- return 1 if position.nil? || position.zero?
169
-
170
- @line_cache[position] ||= @source.byteslice(0...position).count("\n") + 1
89
+ @line_map.line_number(position)
171
90
  end
172
91
 
173
92
  def attach_tagged_remarks(model, remarks, nodes_with_positions)
@@ -180,23 +99,23 @@ module Expressir
180
99
  # This is the key optimization that makes scope lookup O(1) per remark
181
100
  @scope_map ||= build_scope_map
182
101
 
183
- tagged.sort_by { |r| r[:position] }.each do |remark|
184
- next if @attached_spans.include?(remark[:position])
102
+ tagged.sort_by(&:position).each do |remark|
103
+ next if @attached_spans.include?(remark.position)
185
104
 
186
- tag = remark[:tag]
105
+ tag = remark.tag
187
106
  target = nil
188
107
 
189
108
  # Find containing scope using pre-computed scope map (O(1))
190
109
  # Falls back to position-based lookup if scope map doesn't have the line
191
- containing_scope = find_containing_scope_by_name(remark[:line])
192
- containing_scope ||= find_containing_scope_position(remark[:line],
110
+ containing_scope = find_containing_scope_by_name(remark.line)
111
+ containing_scope ||= find_containing_scope_position(remark.line,
193
112
  nodes_with_positions)
194
113
 
195
114
  # Check if this is an informal proposition tag (IP\d+)
196
115
  if tag.match?(/^IP\d+$/)
197
116
  scope = containing_scope
198
117
  if scope.nil?
199
- scope = find_scope_by_source_text(remark[:line])
118
+ scope = find_scope_by_source_text(remark.line)
200
119
  end
201
120
  if scope && supports_informal_propositions?(scope)
202
121
  target = create_or_find_informal_proposition(scope, tag)
@@ -246,7 +165,7 @@ module Expressir
246
165
  # Special handling for remarks inside WHERE clauses
247
166
  if target.nil? && supports_where_rules?(containing_scope)
248
167
  target = find_target_in_where_clause(containing_scope, tag,
249
- remark[:line])
168
+ remark.line)
250
169
  end
251
170
 
252
171
  # Only fall back to schema prefix if NOT inside a function/rule/procedure
@@ -296,9 +215,8 @@ module Expressir
296
215
  end
297
216
 
298
217
  if target
299
- add_remark(target, remark[:text], format: remark[:format],
300
- tag: remark[:tag])
301
- @attached_spans << remark[:position]
218
+ add_remark(target, remark.text, format: remark.format, tag: remark.tag)
219
+ @attached_spans << remark.position
302
220
  end
303
221
  end
304
222
  end
@@ -829,28 +747,26 @@ module Expressir
829
747
  end
830
748
 
831
749
  def attach_untagged_remarks(remarks, nodes_with_positions)
832
- untagged = remarks.reject { |r| r[:tag] }
750
+ untagged = remarks.reject(&:tag)
833
751
  return unless untagged.any?
834
752
 
835
753
  untagged.each do |remark|
836
- next if @attached_spans.include?(remark[:position])
754
+ next if @attached_spans.include?(remark.position)
837
755
 
838
- if end_scope_line?(remark[:line])
756
+ if end_scope_line?(remark.line)
839
757
  matched_node = find_node_for_end_scope_remark(remark,
840
758
  nodes_with_positions)
841
759
  if matched_node
842
- add_remark(matched_node, remark[:text], format: remark[:format],
843
- tag: nil)
844
- @attached_spans << remark[:position]
760
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil)
761
+ @attached_spans << remark.position
845
762
  next
846
763
  end
847
764
  end
848
765
 
849
766
  matched_node = find_nearest_node(remark, nodes_with_positions)
850
767
  if matched_node
851
- add_remark(matched_node, remark[:text], format: remark[:format],
852
- tag: nil)
853
- @attached_spans << remark[:position]
768
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil)
769
+ @attached_spans << remark.position
854
770
  end
855
771
  end
856
772
  end
@@ -868,7 +784,7 @@ module Expressir
868
784
  end
869
785
 
870
786
  def find_node_for_end_scope_remark(remark, nodes)
871
- line_content = get_line_content(remark[:line])
787
+ line_content = get_line_content(remark.line)
872
788
 
873
789
  node_type = case line_content
874
790
  when /END_SCHEMA/i then Model::Declarations::Schema
@@ -883,8 +799,8 @@ module Expressir
883
799
 
884
800
  matching_nodes = nodes.select do |n|
885
801
  n[:node].is_a?(node_type) &&
886
- (n[:end_line] == remark[:line] ||
887
- (n[:end_line] && n[:end_line] <= remark[:line] && n[:end_line] >= remark[:line] - 2))
802
+ (n[:end_line] == remark.line ||
803
+ (n[:end_line] && n[:end_line] <= remark.line && n[:end_line] >= remark.line - 2))
888
804
  end
889
805
 
890
806
  matching_nodes.first&.dig(:node) || find_node_by_type(nodes, node_type)
@@ -1038,7 +954,7 @@ module Expressir
1038
954
  end
1039
955
 
1040
956
  def find_nearest_node(remark, nodes)
1041
- remark_line = remark[:line]
957
+ remark_line = remark.line
1042
958
 
1043
959
  # For tail remarks, prefer nodes that START on the same line
1044
960
  # This handles cases like: "attr : STRING; -- tail remark"
@@ -0,0 +1,180 @@
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 three states:
19
+ # :top — outside any remark; 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
+ class RemarkScanner
24
+ # Immutable value object describing a single extracted remark.
25
+ Remark = Struct.new(:position, :line, :text, :tag, :format, keyword_init: true) do
26
+ def tail?
27
+ format == TAIL_FORMAT
28
+ end
29
+
30
+ def embedded?
31
+ format == EMBEDDED_FORMAT
32
+ end
33
+
34
+ def tagged?
35
+ !tag.nil? && !tag.empty?
36
+ end
37
+ end
38
+
39
+ # Format discriminator values.
40
+ TAIL_FORMAT = "tail"
41
+ EMBEDDED_FORMAT = "embedded"
42
+
43
+ # Lexical markers recognised by EXPRESS.
44
+ TAIL_MARKER = "--"
45
+ EMBEDDED_OPEN = "(*"
46
+ EMBEDDED_CLOSE = "*)"
47
+
48
+ # Tag forms (see ISO 10303-11 §7.1.6.3 Remark tag).
49
+ # IP-style informal-proposition tags are recognised only in tail remarks
50
+ # (matching historic behaviour); embedded remarks use the quoted form.
51
+ INFORMAL_PROPOSITION_TAG = /\A(IP\d+):\s*(.*)\z/m
52
+ QUOTED_TAG = /\A"([^"]*)"\s*(.*)\z/m
53
+
54
+ # @param source [String] EXPRESS source text (any encoding).
55
+ def initialize(source)
56
+ @source = source
57
+ @source_bytes = source.b
58
+ @line_map = LineMap.new(@source_bytes)
59
+ end
60
+
61
+ # Returns an Array of {Remark}, in source order (by byte position).
62
+ # @return [Array<Remark>]
63
+ def scan
64
+ run_state_machine
65
+ end
66
+
67
+ alias call scan
68
+
69
+ private
70
+
71
+ def run_state_machine
72
+ remarks = []
73
+ src = @source_bytes
74
+ pos = 0
75
+
76
+ state = :top
77
+ embedded_depth = 0
78
+ tail_start = nil # byte position of first content char after "--"
79
+ tail_line = nil
80
+ embedded_start = nil # byte position of first content char after "(*"
81
+ embedded_line = nil
82
+
83
+ while pos < src.bytesize
84
+ case state
85
+ when :in_tail
86
+ nl = src.index("\n", pos)
87
+ if nl
88
+ emit_tail(remarks, tail_start, nl, tail_line)
89
+ pos = nl + 1
90
+ state = :top
91
+ else
92
+ emit_tail(remarks, tail_start, src.bytesize, tail_line)
93
+ return remarks
94
+ end
95
+ when :in_embedded
96
+ closing = src.index(EMBEDDED_CLOSE, pos)
97
+ nesting = src.index(EMBEDDED_OPEN, pos)
98
+ if nesting && (!closing || nesting < closing)
99
+ embedded_depth += 1
100
+ pos = nesting + EMBEDDED_OPEN.bytesize
101
+ elsif closing
102
+ embedded_depth -= 1
103
+ pos = closing + EMBEDDED_CLOSE.bytesize
104
+ if embedded_depth.zero?
105
+ emit_embedded(remarks, embedded_start, closing, embedded_line)
106
+ state = :top
107
+ end
108
+ else
109
+ # Unclosed embedded remark — terminate scan gracefully.
110
+ return remarks
111
+ end
112
+ else # :top
113
+ next_embed = src.index(EMBEDDED_OPEN, pos)
114
+ next_tail = src.index(TAIL_MARKER, pos)
115
+ if next_embed.nil? && next_tail.nil?
116
+ return remarks
117
+ 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
123
+ else
124
+ tail_start = next_tail + TAIL_MARKER.bytesize
125
+ tail_line = @line_map.line_number(next_tail)
126
+ state = :in_tail
127
+ pos = next_tail + TAIL_MARKER.bytesize
128
+ end
129
+ end
130
+ end
131
+
132
+ remarks
133
+ end
134
+
135
+ def emit_tail(remarks, content_start, content_end, line)
136
+ # Slice content from the original (UTF-8) source so the returned text
137
+ # preserves the source encoding. Byte offsets come from the BINARY copy.
138
+ raw = @source.byteslice(content_start...content_end)
139
+ tag, content = parse_tail(raw.strip)
140
+ remarks << Remark.new(
141
+ position: content_start - TAIL_MARKER.bytesize,
142
+ line: line,
143
+ text: content,
144
+ tag: tag,
145
+ format: TAIL_FORMAT,
146
+ )
147
+ end
148
+
149
+ def emit_embedded(remarks, content_start, content_end, line)
150
+ raw = @source.byteslice(content_start...content_end)
151
+ tag, content = parse_embedded(raw)
152
+ remarks << Remark.new(
153
+ position: content_start - EMBEDDED_OPEN.bytesize,
154
+ line: line,
155
+ text: content,
156
+ tag: tag,
157
+ format: EMBEDDED_FORMAT,
158
+ )
159
+ end
160
+
161
+ # Tail remarks support both quoted tags ("schema.entity" ...) and the
162
+ # shorthand IP-tag form (IP1: ...). Returns [tag, content]; for untagged
163
+ # remarks tag is nil and content is the stripped text.
164
+ def parse_tail(text)
165
+ match = text.match(INFORMAL_PROPOSITION_TAG) || text.match(QUOTED_TAG)
166
+ match ? match.captures : [nil, text]
167
+ end
168
+
169
+ # Embedded remarks only use the quoted tag form.
170
+ def parse_embedded(content)
171
+ stripped = content.strip
172
+ if (m = stripped.match(QUOTED_TAG))
173
+ [m[1], m[2]]
174
+ else
175
+ [nil, stripped]
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -12,10 +12,12 @@ module Expressir
12
12
  autoload :Formatter, "#{__dir__}/express/formatter"
13
13
  autoload :Formatters, "#{__dir__}/express/formatters"
14
14
  autoload :HyperlinkFormatter, "#{__dir__}/express/hyperlink_formatter"
15
+ autoload :LineMap, "#{__dir__}/express/line_map"
15
16
  autoload :ModelVisitor, "#{__dir__}/express/model_visitor"
16
17
  autoload :Parser, "#{__dir__}/express/parser"
17
18
  autoload :PrettyFormatter, "#{__dir__}/express/pretty_formatter"
18
19
  autoload :RemarkAttacher, "#{__dir__}/express/remark_attacher"
20
+ autoload :RemarkScanner, "#{__dir__}/express/remark_scanner"
19
21
  autoload :ResolveReferencesModelVisitor,
20
22
  "#{__dir__}/express/resolve_references_model_visitor"
21
23
  autoload :SchemaHeadFormatter, "#{__dir__}/express/schema_head_formatter"
@@ -1,3 +1,3 @@
1
1
  module Expressir
2
- VERSION = "2.3.7".freeze
2
+ VERSION = "2.4.0".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expressir
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.7
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-07-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -398,10 +398,12 @@ files:
398
398
  - lib/expressir/express/formatters/statements_formatter.rb
399
399
  - lib/expressir/express/formatters/supertype_expressions_formatter.rb
400
400
  - lib/expressir/express/hyperlink_formatter.rb
401
+ - lib/expressir/express/line_map.rb
401
402
  - lib/expressir/express/model_visitor.rb
402
403
  - lib/expressir/express/parser.rb
403
404
  - lib/expressir/express/pretty_formatter.rb
404
405
  - lib/expressir/express/remark_attacher.rb
406
+ - lib/expressir/express/remark_scanner.rb
405
407
  - lib/expressir/express/resolve_references_model_visitor.rb
406
408
  - lib/expressir/express/schema_head_formatter.rb
407
409
  - lib/expressir/express/streaming_builder.rb