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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oselvar/var/core/span'
|
|
4
|
+
require 'oselvar/var/core/ast'
|
|
5
|
+
require 'oselvar/var/core/cell_diff'
|
|
6
|
+
require 'oselvar/var/core/diagnostics'
|
|
7
|
+
require 'oselvar/var/core/matcher'
|
|
8
|
+
require 'oselvar/var/core/sentences'
|
|
9
|
+
|
|
10
|
+
module Oselvar
|
|
11
|
+
module Var
|
|
12
|
+
module Core
|
|
13
|
+
DocString = Data.define(:content, :content_type, :span)
|
|
14
|
+
|
|
15
|
+
PlannedStep = Data.define(:text, :match_span, :param_spans, :step_def, :args, :formats, :data_table,
|
|
16
|
+
:doc_string) do
|
|
17
|
+
def initialize(text:, match_span:, param_spans:, step_def:, args:, formats: [], data_table: nil,
|
|
18
|
+
doc_string: nil)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
HeaderBinding = Data.define(:match_span, :param_spans, :step_def)
|
|
24
|
+
|
|
25
|
+
PlannedExample = Data.define(:name, :scope_stack, :span, :steps, :header_binding, :row_checks,
|
|
26
|
+
:expected_outcome, :expected_error_message) do
|
|
27
|
+
def initialize(name:, scope_stack:, span:, steps:, header_binding: nil, row_checks: nil,
|
|
28
|
+
expected_outcome: nil, expected_error_message: nil)
|
|
29
|
+
super
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ExecutionPlan = Data.define(:var_doc, :examples, :diagnostics)
|
|
34
|
+
|
|
35
|
+
# Produce an ExecutionPlan from a VarDoc + Registry: match step expressions
|
|
36
|
+
# against every text block, attach trailing tables/fences, detect
|
|
37
|
+
# header-bound tables, and collect diagnostics. Port of plan.ts.
|
|
38
|
+
module Plan
|
|
39
|
+
BlockPlan = Data.define(:steps, :ambiguities)
|
|
40
|
+
Ambiguity = Data.define(:match_start, :match_end, :candidates)
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def plan(var_doc, registry)
|
|
45
|
+
examples = []
|
|
46
|
+
diagnostics = []
|
|
47
|
+
|
|
48
|
+
var_doc.examples.each do |ex|
|
|
49
|
+
had_ambiguous = false
|
|
50
|
+
steps_by_block = {}
|
|
51
|
+
|
|
52
|
+
# Pass 1: plan each text-bearing block.
|
|
53
|
+
ex.body.each_with_index do |block, idx|
|
|
54
|
+
next unless %w[paragraph list_item blockquote].include?(block.kind)
|
|
55
|
+
|
|
56
|
+
result = plan_block(block.text, registry)
|
|
57
|
+
|
|
58
|
+
result.ambiguities.each do |collision|
|
|
59
|
+
span = lift_span(var_doc.source, block, collision.match_start, collision.match_end)
|
|
60
|
+
cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
|
|
61
|
+
cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
|
|
62
|
+
diagnostics << Diagnostics.ambiguous_match(
|
|
63
|
+
AmbiguousInput.new(
|
|
64
|
+
text: block.text[cp_start...cp_end],
|
|
65
|
+
span: span,
|
|
66
|
+
candidates: collision.candidates.map do |c|
|
|
67
|
+
Candidate.new(
|
|
68
|
+
expression: c.expression,
|
|
69
|
+
source_file: c.step_def.expression_source_file,
|
|
70
|
+
source_line: c.step_def.expression_source_line
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
had_ambiguous = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
next unless !had_ambiguous && !result.steps.empty?
|
|
79
|
+
|
|
80
|
+
steps_by_block[idx] = result.steps.map do |hit|
|
|
81
|
+
PlannedStep.new(
|
|
82
|
+
text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
|
|
83
|
+
match_span: lift_span(var_doc.source, block, hit.match_start, hit.match_end),
|
|
84
|
+
param_spans: hit.param_spans.map { |p| lift_span(var_doc.source, block, p.start, p.end) },
|
|
85
|
+
step_def: hit.step_def,
|
|
86
|
+
args: hit.args,
|
|
87
|
+
formats: hit.formats
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Header-bound table detection.
|
|
93
|
+
bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, var_doc.source)
|
|
94
|
+
if bound
|
|
95
|
+
table, binding_step, header_spans = bound
|
|
96
|
+
header_binding = HeaderBinding.new(
|
|
97
|
+
match_span: binding_step.match_span,
|
|
98
|
+
param_spans: header_spans,
|
|
99
|
+
step_def: binding_step.step_def
|
|
100
|
+
)
|
|
101
|
+
table.rows.each do |row|
|
|
102
|
+
row_object = {}
|
|
103
|
+
table.header.cells.each_with_index do |cell_name, i|
|
|
104
|
+
row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
|
|
105
|
+
end
|
|
106
|
+
row_step = PlannedStep.new(
|
|
107
|
+
text: binding_step.text,
|
|
108
|
+
match_span: row.span,
|
|
109
|
+
param_spans: binding_step.param_spans,
|
|
110
|
+
step_def: binding_step.step_def,
|
|
111
|
+
args: binding_step.args + [row_object],
|
|
112
|
+
formats: binding_step.formats
|
|
113
|
+
)
|
|
114
|
+
row_checks = table.header.cells.each_with_index.map do |cell_name, i|
|
|
115
|
+
RowCheck.new(
|
|
116
|
+
column: cell_name,
|
|
117
|
+
value: i < row.cells.length ? row.cells[i] : '',
|
|
118
|
+
span: i < row.cell_spans.length ? row.cell_spans[i] : row.span
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
examples << PlannedExample.new(
|
|
122
|
+
name: row.cells.join(' / '),
|
|
123
|
+
scope_stack: ex.scope_stack + [binding_step.text],
|
|
124
|
+
span: row.span,
|
|
125
|
+
steps: [row_step],
|
|
126
|
+
header_binding: header_binding,
|
|
127
|
+
row_checks: row_checks
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
next
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Error fence detection.
|
|
134
|
+
error_fence = ex.body.find { |b| b.kind == 'fence' && b.info == 'error' }
|
|
135
|
+
|
|
136
|
+
# Pass 2: attach trailing table / fence to the last step of a block.
|
|
137
|
+
attachments = {}
|
|
138
|
+
(1...ex.body.length).each do |idx|
|
|
139
|
+
here = ex.body[idx]
|
|
140
|
+
if here.kind == 'table' && steps_by_block.key?(idx - 1)
|
|
141
|
+
_prev_data, prev_doc = attachments[idx - 1] || [nil, nil]
|
|
142
|
+
attachments[idx - 1] = [here, prev_doc]
|
|
143
|
+
elsif here.kind == 'fence' && here.info != 'error' && steps_by_block.key?(idx - 1)
|
|
144
|
+
prev_data, = attachments[idx - 1] || [nil, nil]
|
|
145
|
+
attachments[idx - 1] = [
|
|
146
|
+
prev_data,
|
|
147
|
+
DocString.new(content: here.body, content_type: here.info, span: here.body_span)
|
|
148
|
+
]
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Pass 3: rebuild the final step list, applying attachments.
|
|
153
|
+
final_steps = []
|
|
154
|
+
(0...ex.body.length).each do |idx|
|
|
155
|
+
block_steps = steps_by_block[idx] || []
|
|
156
|
+
attach = attachments[idx]
|
|
157
|
+
block_steps.each_with_index do |step, s_idx|
|
|
158
|
+
if s_idx == block_steps.length - 1 && attach
|
|
159
|
+
data_table, doc_string = attach
|
|
160
|
+
final_steps << PlannedStep.new(
|
|
161
|
+
text: step.text, match_span: step.match_span, param_spans: step.param_spans,
|
|
162
|
+
step_def: step.step_def, args: step.args, formats: step.formats,
|
|
163
|
+
data_table: data_table, doc_string: doc_string
|
|
164
|
+
)
|
|
165
|
+
else
|
|
166
|
+
final_steps << step
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
runnable_steps = had_ambiguous ? [] : final_steps
|
|
172
|
+
|
|
173
|
+
if error_fence && runnable_steps.empty?
|
|
174
|
+
diagnostics << Diagnostics.error_fence_without_step(error_fence.span)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
next if final_steps.empty? && !had_ambiguous
|
|
178
|
+
|
|
179
|
+
expected_outcome = nil
|
|
180
|
+
expected_error_message = nil
|
|
181
|
+
if error_fence
|
|
182
|
+
expected_outcome = 'fail'
|
|
183
|
+
msg = error_fence.body.strip
|
|
184
|
+
expected_error_message = msg unless msg.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
examples << PlannedExample.new(
|
|
188
|
+
name: derive_example_name(ex.body),
|
|
189
|
+
scope_stack: ex.scope_stack,
|
|
190
|
+
span: ex.span,
|
|
191
|
+
steps: runnable_steps,
|
|
192
|
+
expected_outcome: expected_outcome,
|
|
193
|
+
expected_error_message: expected_error_message
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
ExecutionPlan.new(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def plan_block(text, registry)
|
|
201
|
+
all_steps = []
|
|
202
|
+
all_ambiguities = []
|
|
203
|
+
|
|
204
|
+
Sentences.split_sentences(text).each do |sentence|
|
|
205
|
+
hits = Matcher.find_hits(sentence.text, registry)
|
|
206
|
+
adjusted = hits.map do |h|
|
|
207
|
+
Hit.new(
|
|
208
|
+
expression: h.expression,
|
|
209
|
+
step_def: h.step_def,
|
|
210
|
+
match_start: h.match_start + sentence.start_offset,
|
|
211
|
+
match_end: h.match_end + sentence.start_offset,
|
|
212
|
+
args: h.args,
|
|
213
|
+
param_spans: h.param_spans.map do |p|
|
|
214
|
+
ParamSpan.new(start: p.start + sentence.start_offset, end: p.end + sentence.start_offset)
|
|
215
|
+
end,
|
|
216
|
+
formats: h.formats
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
resolved = Matcher.resolve_hits(adjusted)
|
|
220
|
+
if resolved.kind == 'ambiguous'
|
|
221
|
+
resolved.collisions.each do |c|
|
|
222
|
+
all_ambiguities << Ambiguity.new(match_start: c.match_start, match_end: c.match_end,
|
|
223
|
+
candidates: c.candidates)
|
|
224
|
+
end
|
|
225
|
+
elsif !resolved.steps.empty?
|
|
226
|
+
all_steps.concat(resolved.steps)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
BlockPlan.new(steps: all_steps, ambiguities: all_ambiguities)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Whole-word, case-sensitive start index of +word+ in +haystack+, or nil.
|
|
234
|
+
def word_offset(haystack, word)
|
|
235
|
+
m = /(?<![^\W_])#{Regexp.escape(word)}(?![^\W_])/.match(haystack)
|
|
236
|
+
m&.begin(0)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def detect_header_bound(ex, steps_by_block, source)
|
|
240
|
+
body = ex.body
|
|
241
|
+
(1...body.length).each do |idx|
|
|
242
|
+
here = body[idx]
|
|
243
|
+
next unless here.kind == 'table'
|
|
244
|
+
|
|
245
|
+
above = body[idx - 1]
|
|
246
|
+
next unless %w[paragraph list_item blockquote].include?(above.kind)
|
|
247
|
+
|
|
248
|
+
steps = steps_by_block[idx - 1]
|
|
249
|
+
next if steps.nil? || steps.empty?
|
|
250
|
+
|
|
251
|
+
header_cells = here.header.cells
|
|
252
|
+
offsets = header_cells.map { |cell| word_offset(above.text, cell) }
|
|
253
|
+
next if offsets.any?(&:nil?)
|
|
254
|
+
|
|
255
|
+
utf16_offsets = offsets.map { |o| Offsets.to_utf16_offset(above.text, o) }
|
|
256
|
+
header_spans = header_cells.each_index.map do |i|
|
|
257
|
+
lift_span(source, above, utf16_offsets[i], utf16_offsets[i] + Offsets.utf16_len(header_cells[i]))
|
|
258
|
+
end
|
|
259
|
+
return [here, steps.last, header_spans]
|
|
260
|
+
end
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def derive_example_name(body)
|
|
265
|
+
primary = body.find { |b| %w[paragraph list_item blockquote].include?(b.kind) }
|
|
266
|
+
return '' if primary.nil?
|
|
267
|
+
|
|
268
|
+
name = primary.text.gsub(/\s+/, ' ').strip
|
|
269
|
+
name.sub(/[.!?]$/, '')
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def lift_segment_offset(segment_map, text_offset)
|
|
273
|
+
best = segment_map.first
|
|
274
|
+
segment_map.each { |entry| best = entry if entry.text_offset <= text_offset }
|
|
275
|
+
raise 'empty segment_map' if best.nil?
|
|
276
|
+
|
|
277
|
+
best.source_offset + (text_offset - best.text_offset)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def lift_span(source, block, block_start, block_end)
|
|
281
|
+
return block.span unless %w[paragraph list_item blockquote].include?(block.kind)
|
|
282
|
+
|
|
283
|
+
start_src = lift_segment_offset(block.segment_map, block_start)
|
|
284
|
+
end_src = lift_segment_offset(block.segment_map, block_end)
|
|
285
|
+
Offsets.span_from_offsets(source, start_src, end_src)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cucumber/cucumber_expressions/cucumber_expression'
|
|
4
|
+
require 'cucumber/cucumber_expressions/parameter_type'
|
|
5
|
+
require 'cucumber/cucumber_expressions/parameter_type_registry'
|
|
6
|
+
|
|
7
|
+
module Oselvar
|
|
8
|
+
module Var
|
|
9
|
+
module Core
|
|
10
|
+
# One registered step definition. `compiled` is the CucumberExpression.
|
|
11
|
+
StepRegistration = Data.define(
|
|
12
|
+
:expression, :expression_source_file, :expression_source_line,
|
|
13
|
+
:handler, :compiled, :kind
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# A registry of step definitions plus the shared cucumber ParameterTypeRegistry
|
|
17
|
+
# and per-type display formatters (kept beside it because ParameterType
|
|
18
|
+
# can't carry one). Port of registry.ts.
|
|
19
|
+
Registry = Data.define(:steps, :parameter_types, :formats)
|
|
20
|
+
|
|
21
|
+
module Registries
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def create_registry
|
|
25
|
+
Registry.new(
|
|
26
|
+
steps: [],
|
|
27
|
+
parameter_types: Cucumber::CucumberExpressions::ParameterTypeRegistry.new,
|
|
28
|
+
formats: {}
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Compile +expression+ and append it. Returns a new Registry (the
|
|
33
|
+
# ParameterTypeRegistry is shared by reference but never mutated here).
|
|
34
|
+
# Raises on duplicate expressions, mirroring the TS message.
|
|
35
|
+
def add_step(registry, expression:, expression_source_file:, expression_source_line:, handler:, kind: nil)
|
|
36
|
+
duplicate = registry.steps.find { |s| s.expression == expression }
|
|
37
|
+
if duplicate
|
|
38
|
+
raise "duplicate step definition for \"#{expression}\" at " \
|
|
39
|
+
"#{duplicate.expression_source_file}:#{duplicate.expression_source_line} and " \
|
|
40
|
+
"#{expression_source_file}:#{expression_source_line}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
compiled = Cucumber::CucumberExpressions::CucumberExpression.new(expression, registry.parameter_types)
|
|
44
|
+
reg = StepRegistration.new(
|
|
45
|
+
expression: expression,
|
|
46
|
+
expression_source_file: expression_source_file,
|
|
47
|
+
expression_source_line: expression_source_line,
|
|
48
|
+
handler: handler,
|
|
49
|
+
compiled: compiled,
|
|
50
|
+
kind: kind
|
|
51
|
+
)
|
|
52
|
+
registry.with(steps: registry.steps + [reg])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register a custom parameter type with the shared ParameterTypeRegistry
|
|
56
|
+
# (mutated in place, same as TS, so previously compiled expressions gain
|
|
57
|
+
# the new type). Returns the same Registry unless a +format+ is given.
|
|
58
|
+
def define_parameter_type(registry, name:, regexp:, parse: nil, use_for_snippets: true,
|
|
59
|
+
prefer_for_regexp_match: false, format: nil)
|
|
60
|
+
regexps = regexp.is_a?(Array) ? regexp : [regexp]
|
|
61
|
+
transformer = parse || ->(*groups) { groups[0] }
|
|
62
|
+
# `type` is return-type metadata only (used by snippet generation, never
|
|
63
|
+
# by matching, transformation, or any conformance artifact). The Ruby
|
|
64
|
+
# gem rejects a nil type (Python's accepts None), so pass Object.
|
|
65
|
+
pt = Cucumber::CucumberExpressions::ParameterType.new(
|
|
66
|
+
name, regexps, Object, transformer, use_for_snippets, prefer_for_regexp_match
|
|
67
|
+
)
|
|
68
|
+
registry.parameter_types.define_parameter_type(pt)
|
|
69
|
+
return registry if format.nil?
|
|
70
|
+
|
|
71
|
+
registry.with(formats: registry.formats.merge(name => format))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|