oselvar-var-core 0.3.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.
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/span'
4
+ require 'oselvar/var/core/ast'
5
+ require 'oselvar/var/core/table_cells'
6
+
7
+ module Oselvar
8
+ module Var
9
+ module Core
10
+ # Markdown block scanner. Port of scanner.ts. All offsets count UTF-16
11
+ # code units; split_lines advances by utf16_len(line) + 1 per newline, and
12
+ # code-point indices from String#index are converted to UTF-16 before use.
13
+ module Scanner
14
+ RawLine = Data.define(:text, :start_offset, :end_offset)
15
+
16
+ # Regexes — verbatim ports of the TS constants (`#` escaped as `\#` so
17
+ # Ruby does not read `#{...}` as interpolation).
18
+ THEMATIC_RE = /^\s*([-*_])(\s*\1){2,}\s*$/
19
+ UL_RE = /^(\s*)([-*+])\s+(.*)$/
20
+ OL_RE = /^(\s*)(\d+)([.)])\s+(.*)$/
21
+ BQ_RE = /^>\s?(.*)$/
22
+ FENCE_RE = /^(`{3,})\s*(\S*)\s*$/
23
+ ROW_RE = /^\|(.+)\|\s*$/
24
+ DELIM_RE = /^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|\s*$/
25
+ HEADING_RE = /^(\#{1,6})\s+(.*?)(?:\s+\#+)?\s*$/
26
+ PARA_HEADING_RE = /^\#{1,6}\s+/
27
+
28
+ module_function
29
+
30
+ # Scan +source+ into an immutable Array of Block nodes.
31
+ def scan(source, plugins = [])
32
+ blocks = []
33
+ lines = split_lines(source)
34
+
35
+ i = 0
36
+ while i < lines.length
37
+ line = lines[i]
38
+ if line.text.strip.empty?
39
+ i += 1
40
+ next
41
+ end
42
+
43
+ matched = run_plugins(source, lines, i, plugins)
44
+ if matched
45
+ blocks << matched[0]
46
+ i = matched[1]
47
+ next
48
+ end
49
+
50
+ fence_result = try_fence(source, lines, i)
51
+ if fence_result
52
+ blocks << fence_result[0]
53
+ i = fence_result[1]
54
+ next
55
+ end
56
+
57
+ table_result = try_table(source, lines, i)
58
+ if table_result
59
+ blocks << table_result[0]
60
+ i = table_result[1]
61
+ next
62
+ end
63
+
64
+ thematic = try_thematic(source, line)
65
+ if thematic
66
+ blocks << thematic
67
+ i += 1
68
+ next
69
+ end
70
+
71
+ bq_result = try_blockquote(source, lines, i)
72
+ if bq_result
73
+ blocks << bq_result[0]
74
+ i = bq_result[1]
75
+ next
76
+ end
77
+
78
+ heading = try_heading(source, line)
79
+ if heading
80
+ blocks << heading
81
+ i += 1
82
+ next
83
+ end
84
+
85
+ list_item = try_list_item(source, line)
86
+ if list_item
87
+ blocks << list_item
88
+ i += 1
89
+ next
90
+ end
91
+
92
+ paragraph, next_i = consume_paragraph(source, lines, i, plugins)
93
+ blocks << paragraph
94
+ i = next_i
95
+ end
96
+
97
+ blocks
98
+ end
99
+
100
+ def run_plugins(source, lines, start_idx, plugins)
101
+ plugins.each do |p|
102
+ r = p.try_scan(source: source, lines: lines, start_idx: start_idx)
103
+ return r if r
104
+ end
105
+ nil
106
+ end
107
+
108
+ # Split +source+ into RawLines with UTF-16 start/end offsets.
109
+ def split_lines(source)
110
+ out = []
111
+ start_u16 = 0
112
+ current_u16 = 0
113
+ start_cp = 0
114
+
115
+ source.each_char.with_index do |ch, cp_i|
116
+ if ch == "\n"
117
+ out << RawLine.new(text: source[start_cp...cp_i], start_offset: start_u16, end_offset: current_u16)
118
+ start_u16 = current_u16 + 1 # '\n' is BMP → 1 UTF-16 unit
119
+ start_cp = cp_i + 1
120
+ end
121
+ current_u16 += ch.ord > 0xFFFF ? 2 : 1
122
+ end
123
+
124
+ out << RawLine.new(text: source[start_cp..] || '', start_offset: start_u16, end_offset: current_u16)
125
+ out
126
+ end
127
+
128
+ def try_thematic(source, line)
129
+ return nil unless THEMATIC_RE.match?(line.text)
130
+
131
+ ThematicBreak.new(span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset))
132
+ end
133
+
134
+ def try_heading(source, line)
135
+ m = HEADING_RE.match(line.text)
136
+ return nil unless m
137
+
138
+ Heading.new(
139
+ level: m[1].length,
140
+ text: (m[2] || '').strip,
141
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset)
142
+ )
143
+ end
144
+
145
+ def try_list_item(source, line)
146
+ if (ul = UL_RE.match(line.text))
147
+ text = ul[3] || ''
148
+ marker_start = line.start_offset + Offsets.utf16_len(ul[1] || '')
149
+ marker_end = marker_start + Offsets.utf16_len(ul[2] || '')
150
+ cp_idx = line.text.index(text)
151
+ text_start = line.start_offset + Offsets.to_utf16_offset(line.text, cp_idx)
152
+ return ListItem.new(
153
+ text: text,
154
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset),
155
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: text_start)],
156
+ ordered: false,
157
+ marker_span: Offsets.span_from_offsets(source, marker_start, marker_end)
158
+ )
159
+ end
160
+
161
+ if (ol = OL_RE.match(line.text))
162
+ text = ol[4] || ''
163
+ marker_start = line.start_offset + Offsets.utf16_len(ol[1] || '')
164
+ marker_end = marker_start + Offsets.utf16_len(ol[2] || '') + Offsets.utf16_len(ol[3] || '')
165
+ cp_idx = line.text.index(text)
166
+ text_start = line.start_offset + Offsets.to_utf16_offset(line.text, cp_idx)
167
+ return ListItem.new(
168
+ text: text,
169
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset),
170
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: text_start)],
171
+ ordered: true,
172
+ marker_span: Offsets.span_from_offsets(source, marker_start, marker_end)
173
+ )
174
+ end
175
+
176
+ nil
177
+ end
178
+
179
+ def try_blockquote(source, lines, start_idx)
180
+ return nil if start_idx >= lines.length
181
+
182
+ first = lines[start_idx]
183
+ m = BQ_RE.match(first.text)
184
+ return nil unless m
185
+
186
+ first_segment = m[1] || ''
187
+ cp_idx = first.text.index(first_segment)
188
+ segments = [first_segment]
189
+ segment_map = [
190
+ SegmentOffset.new(
191
+ text_offset: 0,
192
+ source_offset: first.start_offset + Offsets.to_utf16_offset(first.text, cp_idx)
193
+ )
194
+ ]
195
+ joined_text_offset = Offsets.utf16_len(first_segment)
196
+
197
+ i = start_idx + 1
198
+ end_offset = first.end_offset
199
+ while i < lines.length
200
+ ln = lines[i]
201
+ next_m = BQ_RE.match(ln.text)
202
+ break unless next_m
203
+
204
+ segment = next_m[1] || ''
205
+ cp_idx2 = ln.text.index(segment)
206
+ joined_text_offset += 1 # newline separator
207
+ segment_map << SegmentOffset.new(
208
+ text_offset: joined_text_offset,
209
+ source_offset: ln.start_offset + Offsets.to_utf16_offset(ln.text, cp_idx2)
210
+ )
211
+ segments << segment
212
+ joined_text_offset += Offsets.utf16_len(segment)
213
+ end_offset = ln.end_offset
214
+ i += 1
215
+ end
216
+
217
+ [
218
+ Blockquote.new(
219
+ text: segments.join("\n"),
220
+ span: Offsets.span_from_offsets(source, first.start_offset, end_offset),
221
+ segment_map: segment_map
222
+ ),
223
+ i
224
+ ]
225
+ end
226
+
227
+ def consume_paragraph(source, lines, start_idx, plugins)
228
+ raise 'invariant: start_idx out of range' if start_idx >= lines.length
229
+
230
+ first = lines[start_idx]
231
+ end_idx = start_idx
232
+ while end_idx + 1 < lines.length
233
+ candidate_idx = end_idx + 1
234
+ candidate = lines[candidate_idx]
235
+ break if candidate.text.strip.empty?
236
+ break if PARA_HEADING_RE.match?(candidate.text)
237
+ break if UL_RE.match?(candidate.text)
238
+ break if OL_RE.match?(candidate.text)
239
+ break if BQ_RE.match?(candidate.text)
240
+ break if FENCE_RE.match?(candidate.text)
241
+ break if ROW_RE.match?(candidate.text)
242
+ break if THEMATIC_RE.match?(candidate.text)
243
+ break if run_plugins(source, lines, candidate_idx, plugins)
244
+
245
+ end_idx += 1
246
+ end
247
+
248
+ last = lines[end_idx]
249
+ start_offset = first.start_offset
250
+ end_offset = last.end_offset
251
+ [
252
+ Paragraph.new(
253
+ text: Offsets.utf16_slice(source, start_offset, end_offset),
254
+ span: Offsets.span_from_offsets(source, start_offset, end_offset),
255
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: start_offset)]
256
+ ),
257
+ end_idx + 1
258
+ ]
259
+ end
260
+
261
+ def try_fence(source, lines, start_idx)
262
+ return nil if start_idx >= lines.length
263
+
264
+ start = lines[start_idx]
265
+ open_m = FENCE_RE.match(start.text)
266
+ return nil unless open_m
267
+
268
+ fence_marker = open_m[1] || ''
269
+ info = (open_m[2] || '').strip
270
+
271
+ i = start_idx + 1
272
+ body_start = nil
273
+ body_end = nil
274
+ end_offset = start.end_offset
275
+
276
+ while i < lines.length
277
+ ln = lines[i]
278
+ close_m = FENCE_RE.match(ln.text)
279
+ if close_m && (close_m[1] || '').length >= fence_marker.length
280
+ end_offset = ln.end_offset
281
+ break
282
+ end
283
+ body_start = ln.start_offset if body_start.nil?
284
+ body_end = ln.end_offset + 1 # +1 to include the '\n' after this line
285
+ i += 1
286
+ end
287
+
288
+ body = body_start.nil? || body_end.nil? ? '' : Offsets.utf16_slice(source, body_start, body_end)
289
+
290
+ fallback = start.end_offset
291
+ body_span = Offsets.span_from_offsets(source, body_start || fallback, body_end || fallback)
292
+ [
293
+ Fence.new(
294
+ info: info,
295
+ body: body,
296
+ body_span: body_span,
297
+ span: Offsets.span_from_offsets(source, start.start_offset, end_offset)
298
+ ),
299
+ i + 1
300
+ ]
301
+ end
302
+
303
+ def try_table(source, lines, start_idx)
304
+ return nil if start_idx + 1 >= lines.length
305
+
306
+ header_line = lines[start_idx]
307
+ delim_line = lines[start_idx + 1]
308
+ return nil unless ROW_RE.match?(header_line.text)
309
+ return nil unless DELIM_RE.match?(delim_line.text)
310
+
311
+ header_cells, header_cell_spans = TableCells.parse_row_cells(header_line.text, header_line.start_offset,
312
+ source)
313
+ header = Row.new(
314
+ cells: header_cells,
315
+ cell_spans: header_cell_spans,
316
+ span: Offsets.span_from_offsets(source, header_line.start_offset, header_line.end_offset)
317
+ )
318
+
319
+ rows = []
320
+ i = start_idx + 2
321
+ while i < lines.length
322
+ ln = lines[i]
323
+ break unless ROW_RE.match?(ln.text)
324
+
325
+ cells, cell_spans = TableCells.parse_row_cells(ln.text, ln.start_offset, source)
326
+ rows << Row.new(
327
+ cells: cells,
328
+ cell_spans: cell_spans,
329
+ span: Offsets.span_from_offsets(source, ln.start_offset, ln.end_offset)
330
+ )
331
+ i += 1
332
+ end
333
+
334
+ last_row = rows.last
335
+ end_offset = last_row ? last_row.span.end_offset : delim_line.end_offset
336
+ [
337
+ Table.new(
338
+ span: Offsets.span_from_offsets(source, header_line.start_offset, end_offset),
339
+ header: header,
340
+ rows: rows
341
+ ),
342
+ i
343
+ ]
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # Split a block of plain text into sentences on . ! ? and \n, skipping
7
+ # terminators inside backtick spans and double-quoted strings and treating
8
+ # common abbreviations as non-breaking. All offsets are UTF-16 code units
9
+ # into the block text. Port of sentences.ts.
10
+ Sentence = Data.define(:text, :start_offset, :end_offset)
11
+
12
+ module Sentences
13
+ ABBREVIATIONS = Set.new(['e.g.', 'i.e.', 'etc.', 'cf.', 'vs.']).freeze
14
+
15
+ module_function
16
+
17
+ def split_sentences(text)
18
+ cp_to_u16 = build_cp_to_u16(text)
19
+ n = text.length
20
+ out = []
21
+
22
+ # Mark no-split zones (backtick spans, double-quoted strings).
23
+ skip = Array.new(n, false)
24
+ j = 0
25
+ while j < n
26
+ c = text[j]
27
+ if ['`', '"'].include?(c)
28
+ close = text.index(c, j + 1)
29
+ break if close.nil?
30
+
31
+ (j..close).each { |k| skip[k] = true }
32
+ j = close + 1
33
+ next
34
+ end
35
+ j += 1
36
+ end
37
+
38
+ i = 0
39
+ segment_start = 0
40
+ while i < n
41
+ if skip[i]
42
+ i += 1
43
+ next
44
+ end
45
+ ch = text[i]
46
+ if ["\n", '.', '!', '?'].include?(ch)
47
+ if ch == '.' && inside_number_or_abbrev?(text, i)
48
+ i += 1
49
+ next
50
+ end
51
+ stop = i + 1
52
+ push_segment(out, text, segment_start, stop, cp_to_u16)
53
+ i = stop
54
+ i += 1 while i < n && [' ', "\n"].include?(text[i])
55
+ segment_start = i
56
+ next
57
+ end
58
+ i += 1
59
+ end
60
+
61
+ push_segment(out, text, segment_start, n, cp_to_u16)
62
+ out
63
+ end
64
+
65
+ # cp_to_u16[cp_i] is the UTF-16 offset of text[cp_i].
66
+ def build_cp_to_u16(text)
67
+ result = Array.new(text.length + 1, 0)
68
+ u16 = 0
69
+ text.each_char.with_index do |ch, i|
70
+ result[i] = u16
71
+ u16 += ch.ord > 0xFFFF ? 2 : 1
72
+ end
73
+ result[text.length] = u16
74
+ result
75
+ end
76
+
77
+ def inside_number_or_abbrev?(text, dot_pos)
78
+ prev = dot_pos.positive? ? text[dot_pos - 1] : ''
79
+ nxt = dot_pos + 1 < text.length ? text[dot_pos + 1] : ''
80
+ return true if digit?(prev) && digit?(nxt)
81
+
82
+ ABBREVIATIONS.each do |abbrev|
83
+ start = [0, dot_pos + 1 - abbrev.length].max
84
+ return true if text[start...(dot_pos + 1)] == abbrev
85
+ end
86
+ lower?(nxt)
87
+ end
88
+
89
+ def push_segment(out, text, start_cp, end_cp, cp_to_u16)
90
+ return if end_cp <= start_cp
91
+
92
+ raw = text[start_cp...end_cp]
93
+ stripped = raw.strip
94
+ return if stripped.empty?
95
+
96
+ lead = raw.length - raw.lstrip.length
97
+ trail = raw.length - raw.rstrip.length
98
+ out << Sentence.new(
99
+ text: stripped,
100
+ start_offset: cp_to_u16[start_cp + lead],
101
+ end_offset: cp_to_u16[end_cp - trail]
102
+ )
103
+ end
104
+
105
+ def digit?(ch)
106
+ !ch.empty? && ch.match?(/[0-9]/)
107
+ end
108
+
109
+ # Unicode-aware "is a lowercase letter": has case and is already lower.
110
+ def lower?(ch)
111
+ !ch.empty? && ch != ch.upcase && ch == ch.downcase
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # A source span. Offsets and columns are **UTF-16 code units** (an astral
7
+ # character like 😀 counts as 2), matching the goldens and LSP's default
8
+ # position encoding. Lines/cols are 1-based.
9
+ Span = Data.define(
10
+ :start_offset, :end_offset,
11
+ :start_line, :start_col,
12
+ :end_line, :end_col
13
+ )
14
+
15
+ # UTF-16 offset conversion. Ruby strings are code-point indexed, so the
16
+ # whole core converts to/from UTF-16 code units here (the single riskiest
17
+ # part of the port — mirrors Python's span.py). Reused by the matcher and
18
+ # by hash.rb.
19
+ module Offsets
20
+ module_function
21
+
22
+ # UTF-16 code-unit length of a string (astral chars count as 2).
23
+ def utf16_len(str)
24
+ n = 0
25
+ str.each_char { |ch| n += ch.ord > 0xFFFF ? 2 : 1 }
26
+ n
27
+ end
28
+
29
+ # UTF-16 offset of the code-point index `cp_index` in `source`.
30
+ def to_utf16_offset(source, cp_index)
31
+ utf16_len(source[0...cp_index])
32
+ end
33
+
34
+ # Inverse of to_utf16_offset: the code-point index at a UTF-16 offset.
35
+ def cp_index_for_utf16(source, u16)
36
+ count = 0
37
+ source.each_char.with_index do |ch, i|
38
+ return i if count >= u16
39
+
40
+ count += ch.ord > 0xFFFF ? 2 : 1
41
+ end
42
+ source.length
43
+ end
44
+
45
+ # Slice `source` by UTF-16 offsets, returning the covered substring.
46
+ def utf16_slice(source, start_u16, end_u16)
47
+ a = cp_index_for_utf16(source, start_u16)
48
+ b = cp_index_for_utf16(source, end_u16)
49
+ source[a...b]
50
+ end
51
+
52
+ # 1-based [line, col] at a UTF-16 offset; col counts UTF-16 units and
53
+ # resets to 1 after each newline (mirrors span.ts's lineCol).
54
+ def line_col(source, offset_u16)
55
+ line = 1
56
+ col = 1
57
+ count = 0
58
+ source.each_char do |ch|
59
+ break if count >= offset_u16
60
+
61
+ width = ch.ord > 0xFFFF ? 2 : 1
62
+ if ch == "\n"
63
+ line += 1
64
+ col = 1
65
+ else
66
+ col += width
67
+ end
68
+ count += width
69
+ end
70
+ [line, col]
71
+ end
72
+
73
+ # Build a Span from UTF-16 start/end offsets.
74
+ def span_from_offsets(source, start_u16, end_u16)
75
+ start_line, start_col = line_col(source, start_u16)
76
+ end_line, end_col = line_col(source, end_u16)
77
+ Span.new(
78
+ start_offset: start_u16, end_offset: end_u16,
79
+ start_line: start_line, start_col: start_col,
80
+ end_line: end_line, end_col: end_col
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oselvar
4
+ module Var
5
+ module Core
6
+ # The role a step definition plays:
7
+ # "stimulus" — drives the software (arranges and acts on state)
8
+ # "sensor" — the read-only assertion (the only role that returns for
9
+ # comparison)
10
+ # Purely structural — never inspects sentence words (no Given/When/Then
11
+ # heuristics). Port of step-role.ts.
12
+ module StepRole
13
+ module_function
14
+
15
+ # Guess a step's role from its document-order neighbours. A step with
16
+ # nothing after it is most likely the observation; anything followed by
17
+ # other steps is most likely driving the software.
18
+ def infer_step_role(neighbours)
19
+ after = neighbours[:after] || neighbours['after'] || []
20
+ after.empty? ? 'sensor' : 'stimulus'
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/span'
4
+ require 'oselvar/var/core/ast'
5
+
6
+ module Oselvar
7
+ module Var
8
+ module Core
9
+ # Group scanned blocks into Examples, tracking heading scope and orphan
10
+ # attachments. Port of structurer.ts.
11
+ module Structurer
12
+ module_function
13
+
14
+ def structure(path, source, blocks)
15
+ examples = []
16
+ orphan_attachments = []
17
+ scope_stack = [] # [[level, text], ...]
18
+ last_example_idx = -1
19
+ attachment_open = false
20
+
21
+ blocks.each do |block|
22
+ case block.kind
23
+ when 'heading'
24
+ # Pop deeper-or-equal-level entries before pushing the new heading.
25
+ scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level
26
+ scope_stack << [block.level, block.text]
27
+ attachment_open = false
28
+
29
+ when 'paragraph', 'list_item', 'blockquote'
30
+ # Merge a block into the previous example when that example's last
31
+ # block is an attachment (table/fence) with no blank line between.
32
+ if attachment_open && last_example_idx >= 0
33
+ prev = examples[last_example_idx]
34
+ prev_last = prev.body.last
35
+ last_is_attachment = !prev_last.nil? && %w[table fence].include?(prev_last.kind)
36
+ if last_is_attachment
37
+ between = Offsets.utf16_slice(source, prev.span.end_offset, block.span.start_offset)
38
+ unless between.match?(/\n\s*\n/)
39
+ new_span = Offsets.span_from_offsets(source, prev.span.start_offset, block.span.end_offset)
40
+ examples[last_example_idx] = Example.new(
41
+ scope_stack: prev.scope_stack,
42
+ span: new_span,
43
+ body: prev.body + [block]
44
+ )
45
+ next
46
+ end
47
+ end
48
+ end
49
+
50
+ examples << Example.new(
51
+ scope_stack: scope_stack.map { |(_, text)| text },
52
+ span: block.span,
53
+ body: [block]
54
+ )
55
+ last_example_idx = examples.length - 1
56
+ attachment_open = true
57
+
58
+ when 'table', 'fence'
59
+ if attachment_open && last_example_idx >= 0
60
+ prev = examples[last_example_idx]
61
+ new_span = Offsets.span_from_offsets(source, prev.span.start_offset, block.span.end_offset)
62
+ examples[last_example_idx] = Example.new(
63
+ scope_stack: prev.scope_stack,
64
+ span: new_span,
65
+ body: prev.body + [block]
66
+ )
67
+ else
68
+ orphan_attachments << block
69
+ end
70
+
71
+ when 'thematic_break'
72
+ attachment_open = false
73
+ end
74
+ end
75
+
76
+ VarDoc.new(
77
+ path: path,
78
+ source: source,
79
+ examples: examples,
80
+ orphan_attachments: orphan_attachments
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end