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.
- checksums.yaml +7 -0
- data/lib/oselvar/var/core/ast.rb +49 -0
- data/lib/oselvar/var/core/canonical_json.rb +49 -0
- data/lib/oselvar/var/core/cell_diff.rb +107 -0
- data/lib/oselvar/var/core/conformance.rb +264 -0
- data/lib/oselvar/var/core/deep_freeze.rb +27 -0
- data/lib/oselvar/var/core/diagnostics.rb +50 -0
- data/lib/oselvar/var/core/doc_string_diff.rb +38 -0
- data/lib/oselvar/var/core/drift.rb +187 -0
- data/lib/oselvar/var/core/execute.rb +189 -0
- data/lib/oselvar/var/core/failure_anchor.rb +30 -0
- data/lib/oselvar/var/core/hash.rb +29 -0
- data/lib/oselvar/var/core/matcher.rb +134 -0
- data/lib/oselvar/var/core/param_diff.rb +54 -0
- data/lib/oselvar/var/core/parse.rb +20 -0
- data/lib/oselvar/var/core/plan.rb +290 -0
- data/lib/oselvar/var/core/registry.rb +76 -0
- data/lib/oselvar/var/core/scanner.rb +348 -0
- data/lib/oselvar/var/core/sentences.rb +116 -0
- data/lib/oselvar/var/core/span.rb +86 -0
- data/lib/oselvar/var/core/step_role.rb +25 -0
- data/lib/oselvar/var/core/structurer.rb +86 -0
- data/lib/oselvar/var/core/table_cells.rb +46 -0
- data/lib/oselvar/var/core.rb +34 -0
- metadata +82 -0
|
@@ -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
|