varar-core 0.6.1 → 0.8.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 +4 -4
- data/lib/varar/core/ast.rb +6 -2
- data/lib/varar/core/canonical_json.rb +15 -15
- data/lib/varar/core/cell_diff.rb +7 -2
- data/lib/varar/core/conformance.rb +8 -17
- data/lib/varar/core/doc_string_diff.rb +12 -14
- data/lib/varar/core/drift.rb +82 -41
- data/lib/varar/core/execute.rb +42 -21
- data/lib/varar/core/failure.rb +55 -0
- data/lib/varar/core/failure_anchor.rb +20 -2
- data/lib/varar/core/param_diff.rb +1 -1
- data/lib/varar/core/parse.rb +1 -1
- data/lib/varar/core/plan.rb +210 -133
- data/lib/varar/core/registry.rb +32 -4
- data/lib/varar/core/result.rb +78 -0
- data/lib/varar/core/structurer.rb +19 -25
- data/lib/varar/core.rb +3 -2
- metadata +4 -3
- data/lib/varar/core/deep_freeze.rb +0 -25
|
@@ -12,17 +12,35 @@ module Varar
|
|
|
12
12
|
module FailureAnchor
|
|
13
13
|
module_function
|
|
14
14
|
|
|
15
|
+
# The anchor travels with the raised error, from the executor (which knows
|
|
16
|
+
# the step) to Failures.to_failure (which only sees the error) — the same
|
|
17
|
+
# job TS does with a global symbol on the Error. An instance variable on
|
|
18
|
+
# the exception, so it never shows up in `inspect` output the way an
|
|
19
|
+
# extra attribute would.
|
|
20
|
+
ANCHOR_IVAR = :@varar_failure_anchor
|
|
21
|
+
|
|
15
22
|
def failure_anchor(error, fallback)
|
|
16
23
|
case error
|
|
17
24
|
when CellMismatchError
|
|
18
25
|
failing = error.cells.find { |c| !c.ok }
|
|
19
26
|
failing ? failing.span : fallback
|
|
20
|
-
when DocStringMismatchError
|
|
21
|
-
error.diff.span
|
|
22
27
|
else
|
|
23
28
|
fallback
|
|
24
29
|
end
|
|
25
30
|
end
|
|
31
|
+
|
|
32
|
+
# Record on the error itself where the failure points.
|
|
33
|
+
def attach_anchor(error, anchor)
|
|
34
|
+
error.instance_variable_set(ANCHOR_IVAR, anchor) if error.respond_to?(:instance_variable_set)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The anchor the executor attached, or nil if there is none — then a
|
|
38
|
+
# renderer only has the failing line to go on.
|
|
39
|
+
def attached_anchor(error)
|
|
40
|
+
return nil unless error.respond_to?(:instance_variable_get)
|
|
41
|
+
|
|
42
|
+
error.instance_variable_get(ANCHOR_IVAR)
|
|
43
|
+
end
|
|
26
44
|
end
|
|
27
45
|
end
|
|
28
46
|
end
|
data/lib/varar/core/parse.rb
CHANGED
data/lib/varar/core/plan.rb
CHANGED
|
@@ -29,169 +29,246 @@ module Varar
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
ExecutionPlan = Data.define(:
|
|
32
|
+
ExecutionPlan = Data.define(:doc, :examples, :diagnostics)
|
|
33
33
|
|
|
34
|
-
# Produce an ExecutionPlan from a
|
|
34
|
+
# Produce an ExecutionPlan from a Doc + Registry: match step expressions
|
|
35
35
|
# against every text block, attach trailing tables/fences, detect
|
|
36
36
|
# header-bound tables, and collect diagnostics. Port of plan.ts.
|
|
37
37
|
module Plan
|
|
38
38
|
BlockPlan = Data.define(:steps, :ambiguities)
|
|
39
39
|
Ambiguity = Data.define(:match_start, :match_end, :candidates)
|
|
40
40
|
|
|
41
|
+
# A candidate paragraph, planned in isolation (Phase 1). Either a
|
|
42
|
+
# header-bound table (standalone rows) or a step-bearing candidate the
|
|
43
|
+
# grouping pass may merge into an open example.
|
|
44
|
+
HeaderBoundUnit = Data.define(:rows)
|
|
45
|
+
StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps,
|
|
46
|
+
:expected_outcome, :expected_error_message)
|
|
47
|
+
|
|
48
|
+
# An open, merging example being built up across adjacent matching
|
|
49
|
+
# candidates in Phase 2.
|
|
50
|
+
MergedExample = Struct.new(:name, :scope_stack, :start_offset, :end_offset, :steps,
|
|
51
|
+
:expected_outcome, :expected_error_message)
|
|
52
|
+
|
|
41
53
|
module_function
|
|
42
54
|
|
|
43
|
-
def plan(
|
|
44
|
-
examples = []
|
|
55
|
+
def plan(doc, registry)
|
|
45
56
|
diagnostics = []
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
58
|
+
# Phase 1: plan each candidate paragraph independently into a "unit".
|
|
59
|
+
units = doc.examples.map { |ex| plan_candidate(ex, doc, registry, diagnostics) }
|
|
60
|
+
|
|
61
|
+
# Phase 2: group adjacent candidates into examples. A matching candidate
|
|
62
|
+
# continues the open example when no delimiter (heading / `---`) precedes
|
|
63
|
+
# it; otherwise it starts a new one. A non-matching candidate (prose) is
|
|
64
|
+
# a delimiter: it closes the open example and is dropped. A header-bound
|
|
65
|
+
# table candidate is standalone — one example per row. See ADR 0012.
|
|
66
|
+
examples = []
|
|
67
|
+
open = nil
|
|
68
|
+
flush = lambda do
|
|
69
|
+
examples << finish_merged(open, doc.source) if open
|
|
70
|
+
open = nil
|
|
71
|
+
end
|
|
72
|
+
units.each do |unit|
|
|
73
|
+
if unit.is_a?(HeaderBoundUnit)
|
|
74
|
+
flush.call
|
|
75
|
+
examples.concat(unit.rows)
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
unless unit.matched
|
|
79
|
+
# Prose paragraph — a delimiter. Drop it and end the open example.
|
|
80
|
+
flush.call
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
if open && !unit.preceded_by_delimiter
|
|
84
|
+
merge_into(open, unit)
|
|
85
|
+
else
|
|
86
|
+
flush.call
|
|
87
|
+
open = start_merged(unit)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
flush.call
|
|
91
|
+
|
|
92
|
+
ExecutionPlan.new(doc: doc, examples: examples, diagnostics: diagnostics)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def start_merged(unit)
|
|
96
|
+
MergedExample.new(unit.name, unit.scope_stack, unit.span.start_offset, unit.span.end_offset,
|
|
97
|
+
unit.steps.dup, unit.expected_outcome, unit.expected_error_message)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def merge_into(open, unit)
|
|
101
|
+
open.end_offset = unit.span.end_offset
|
|
102
|
+
open.steps.concat(unit.steps)
|
|
103
|
+
# Any error fence in a merged part marks the whole example
|
|
104
|
+
# expected-to-fail; keep the first message we see.
|
|
105
|
+
return unless unit.expected_outcome == 'fail'
|
|
106
|
+
|
|
107
|
+
open.expected_outcome = 'fail'
|
|
108
|
+
return unless open.expected_error_message.nil? && !unit.expected_error_message.nil?
|
|
109
|
+
|
|
110
|
+
open.expected_error_message = unit.expected_error_message
|
|
111
|
+
end
|
|
76
112
|
|
|
77
|
-
|
|
113
|
+
def finish_merged(open, source)
|
|
114
|
+
span = Offsets.span_from_offsets(source, open.start_offset, open.end_offset)
|
|
115
|
+
PlannedExample.new(
|
|
116
|
+
name: open.name,
|
|
117
|
+
scope_stack: open.scope_stack,
|
|
118
|
+
span: span,
|
|
119
|
+
steps: open.steps,
|
|
120
|
+
expected_outcome: open.expected_outcome,
|
|
121
|
+
expected_error_message: open.expected_error_message
|
|
122
|
+
)
|
|
123
|
+
end
|
|
78
124
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
125
|
+
# Plan a single candidate paragraph (plus attached tables/fences) in
|
|
126
|
+
# isolation. Emits ambiguity / error-fence diagnostics into +diagnostics+.
|
|
127
|
+
def plan_candidate(ex, doc, registry, diagnostics)
|
|
128
|
+
had_ambiguous = false
|
|
129
|
+
steps_by_block = {}
|
|
130
|
+
|
|
131
|
+
# Pass 1: plan each text-bearing block.
|
|
132
|
+
ex.body.each_with_index do |block, idx|
|
|
133
|
+
next unless %w[paragraph list_item blockquote].include?(block.kind)
|
|
134
|
+
|
|
135
|
+
result = plan_block(block.text, registry)
|
|
136
|
+
|
|
137
|
+
result.ambiguities.each do |collision|
|
|
138
|
+
span = lift_span(doc.source, block, collision.match_start, collision.match_end)
|
|
139
|
+
cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
|
|
140
|
+
cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
|
|
141
|
+
diagnostics << Diagnostics.ambiguous_match(
|
|
142
|
+
AmbiguousInput.new(
|
|
143
|
+
text: block.text[cp_start...cp_end],
|
|
144
|
+
span: span,
|
|
145
|
+
candidates: collision.candidates.map do |c|
|
|
146
|
+
Candidate.new(
|
|
147
|
+
expression: c.expression,
|
|
148
|
+
source_file: c.step_def.expression_source_file,
|
|
149
|
+
source_line: c.step_def.expression_source_line
|
|
150
|
+
)
|
|
151
|
+
end
|
|
87
152
|
)
|
|
88
|
-
|
|
153
|
+
)
|
|
154
|
+
had_ambiguous = true
|
|
89
155
|
end
|
|
90
156
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
match_span:
|
|
97
|
-
param_spans:
|
|
98
|
-
step_def:
|
|
157
|
+
next unless !had_ambiguous && !result.steps.empty?
|
|
158
|
+
|
|
159
|
+
steps_by_block[idx] = result.steps.map do |hit|
|
|
160
|
+
PlannedStep.new(
|
|
161
|
+
text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
|
|
162
|
+
match_span: lift_span(doc.source, block, hit.match_start, hit.match_end),
|
|
163
|
+
param_spans: hit.param_spans.map { |p| lift_span(doc.source, block, p.start, p.end) },
|
|
164
|
+
step_def: hit.step_def,
|
|
165
|
+
args: hit.args,
|
|
166
|
+
formats: hit.formats
|
|
99
167
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Header-bound table detection.
|
|
172
|
+
bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, doc.source)
|
|
173
|
+
if bound
|
|
174
|
+
table, binding_step, header_spans = bound
|
|
175
|
+
header_binding = HeaderBinding.new(
|
|
176
|
+
match_span: binding_step.match_span,
|
|
177
|
+
param_spans: header_spans,
|
|
178
|
+
step_def: binding_step.step_def
|
|
179
|
+
)
|
|
180
|
+
rows = table.rows.map do |row|
|
|
181
|
+
row_object = {}
|
|
182
|
+
table.header.cells.each_with_index do |cell_name, i|
|
|
183
|
+
row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
|
|
184
|
+
end
|
|
185
|
+
row_step = PlannedStep.new(
|
|
186
|
+
text: binding_step.text,
|
|
187
|
+
match_span: row.span,
|
|
188
|
+
param_spans: binding_step.param_spans,
|
|
189
|
+
step_def: binding_step.step_def,
|
|
190
|
+
args: binding_step.args + [row_object],
|
|
191
|
+
formats: binding_step.formats
|
|
192
|
+
)
|
|
193
|
+
row_checks = table.header.cells.each_with_index.map do |cell_name, i|
|
|
194
|
+
RowCheck.new(
|
|
195
|
+
column: cell_name,
|
|
196
|
+
value: i < row.cells.length ? row.cells[i] : '',
|
|
197
|
+
span: i < row.cell_spans.length ? row.cell_spans[i] : row.span
|
|
127
198
|
)
|
|
128
199
|
end
|
|
129
|
-
|
|
200
|
+
PlannedExample.new(
|
|
201
|
+
name: row.cells.join(' / '),
|
|
202
|
+
scope_stack: ex.scope_stack + [binding_step.text],
|
|
203
|
+
span: row.span,
|
|
204
|
+
steps: [row_step],
|
|
205
|
+
header_binding: header_binding,
|
|
206
|
+
row_checks: row_checks
|
|
207
|
+
)
|
|
130
208
|
end
|
|
209
|
+
return HeaderBoundUnit.new(rows: rows)
|
|
210
|
+
end
|
|
131
211
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
end
|
|
212
|
+
# Error fence detection.
|
|
213
|
+
error_fence = ex.body.find { |b| b.kind == 'fence' && b.info == 'error' }
|
|
214
|
+
|
|
215
|
+
# Pass 2: attach trailing table / fence to the last step of a block.
|
|
216
|
+
attachments = {}
|
|
217
|
+
(1...ex.body.length).each do |idx|
|
|
218
|
+
here = ex.body[idx]
|
|
219
|
+
if here.kind == 'table' && steps_by_block.key?(idx - 1)
|
|
220
|
+
_prev_data, prev_doc = attachments[idx - 1] || [nil, nil]
|
|
221
|
+
attachments[idx - 1] = [here, prev_doc]
|
|
222
|
+
elsif here.kind == 'fence' && here.info != 'error' && steps_by_block.key?(idx - 1)
|
|
223
|
+
prev_data, = attachments[idx - 1] || [nil, nil]
|
|
224
|
+
attachments[idx - 1] = [
|
|
225
|
+
prev_data,
|
|
226
|
+
DocString.new(content: here.body, content_type: here.info, span: here.body_span)
|
|
227
|
+
]
|
|
149
228
|
end
|
|
229
|
+
end
|
|
150
230
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
end
|
|
231
|
+
# Pass 3: rebuild the final step list, applying attachments.
|
|
232
|
+
final_steps = []
|
|
233
|
+
(0...ex.body.length).each do |idx|
|
|
234
|
+
block_steps = steps_by_block[idx] || []
|
|
235
|
+
attach = attachments[idx]
|
|
236
|
+
block_steps.each_with_index do |step, s_idx|
|
|
237
|
+
if s_idx == block_steps.length - 1 && attach
|
|
238
|
+
data_table, doc_string = attach
|
|
239
|
+
final_steps << PlannedStep.new(
|
|
240
|
+
text: step.text, match_span: step.match_span, param_spans: step.param_spans,
|
|
241
|
+
step_def: step.step_def, args: step.args, formats: step.formats,
|
|
242
|
+
data_table: data_table, doc_string: doc_string
|
|
243
|
+
)
|
|
244
|
+
else
|
|
245
|
+
final_steps << step
|
|
167
246
|
end
|
|
168
247
|
end
|
|
248
|
+
end
|
|
169
249
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
diagnostics << Diagnostics.error_fence_without_step(error_fence.span) if error_fence && runnable_steps.empty?
|
|
250
|
+
runnable_steps = had_ambiguous ? [] : final_steps
|
|
173
251
|
|
|
174
|
-
|
|
252
|
+
diagnostics << Diagnostics.error_fence_without_step(error_fence.span) if error_fence && runnable_steps.empty?
|
|
175
253
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
examples << PlannedExample.new(
|
|
185
|
-
name: derive_example_name(ex.body),
|
|
186
|
-
scope_stack: ex.scope_stack,
|
|
187
|
-
span: ex.span,
|
|
188
|
-
steps: runnable_steps,
|
|
189
|
-
expected_outcome: expected_outcome,
|
|
190
|
-
expected_error_message: expected_error_message
|
|
191
|
-
)
|
|
254
|
+
expected_outcome = nil
|
|
255
|
+
expected_error_message = nil
|
|
256
|
+
if error_fence
|
|
257
|
+
expected_outcome = 'fail'
|
|
258
|
+
msg = error_fence.body.strip
|
|
259
|
+
expected_error_message = msg unless msg.empty?
|
|
192
260
|
end
|
|
193
261
|
|
|
194
|
-
|
|
262
|
+
StepsUnit.new(
|
|
263
|
+
matched: !runnable_steps.empty?,
|
|
264
|
+
preceded_by_delimiter: ex.preceded_by_delimiter,
|
|
265
|
+
name: derive_example_name(ex.body),
|
|
266
|
+
scope_stack: ex.scope_stack,
|
|
267
|
+
span: ex.span,
|
|
268
|
+
steps: runnable_steps,
|
|
269
|
+
expected_outcome: expected_outcome,
|
|
270
|
+
expected_error_message: expected_error_message
|
|
271
|
+
)
|
|
195
272
|
end
|
|
196
273
|
|
|
197
274
|
def plan_block(text, registry)
|
data/lib/varar/core/registry.rb
CHANGED
|
@@ -20,11 +20,39 @@ module Varar
|
|
|
20
20
|
module Registries
|
|
21
21
|
module_function
|
|
22
22
|
|
|
23
|
+
# Markdown emphasis, as a built-in {emph} parameter type. Matches the
|
|
24
|
+
# uniform emphasis notations (bold-italic, bold, italic; `*` and `_`
|
|
25
|
+
# delimiters), ordered longest-delimiter-first so `**x**` isn't
|
|
26
|
+
# half-eaten by the `*` branch. Each branch captures the inner text in
|
|
27
|
+
# its own group, so only the outermost delimiter pair is stripped
|
|
28
|
+
# (`**_x_**` -> `_x_`). Byte-identical to the TS port's EMPH_REGEXP.
|
|
29
|
+
EMPH_REGEXP = '\*\*\*([^*]+)\*\*\*|___([^_]+)___|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_'
|
|
30
|
+
|
|
23
31
|
def create_registry
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
seed_builtins(
|
|
33
|
+
Registry.new(
|
|
34
|
+
steps: [],
|
|
35
|
+
parameter_types: Cucumber::CucumberExpressions::ParameterTypeRegistry.new,
|
|
36
|
+
formats: {}
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Seed Varar's own built-in parameter types (beyond cucumber-expressions'
|
|
42
|
+
# int/float/string/word). Shared by every port so oaths match
|
|
43
|
+
# identically. Built-ins are NOT tracked as custom parameter types, so
|
|
44
|
+
# they never appear in the conformance registry.json projection.
|
|
45
|
+
def seed_builtins(registry)
|
|
46
|
+
define_parameter_type(
|
|
47
|
+
registry,
|
|
48
|
+
name: 'emph',
|
|
49
|
+
regexp: EMPH_REGEXP,
|
|
50
|
+
# Exactly one alternation branch matches, so exactly one group is set.
|
|
51
|
+
parse: ->(*groups) { groups.find { |g| !g.nil? } || '' },
|
|
52
|
+
# Emphasis is distinctive notation; don't auto-suggest it in snippets.
|
|
53
|
+
use_for_snippets: false,
|
|
54
|
+
# Mismatch display renders the value back in single-asterisk emphasis.
|
|
55
|
+
format: ->(value) { "*#{value}*" }
|
|
28
56
|
)
|
|
29
57
|
end
|
|
30
58
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Varar
|
|
4
|
+
module Core
|
|
5
|
+
# Run-result records — port of result.ts / result.rb's siblings in every
|
|
6
|
+
# other port. The persisted .varar/<oath_path>.json file is a serialized
|
|
7
|
+
# OathResults, read by the language server to place run diagnostics in the
|
|
8
|
+
# editor (ADR 0014).
|
|
9
|
+
|
|
10
|
+
# One mismatched CELL as a source-offset range plus the runtime value.
|
|
11
|
+
# `from`/`to` are absolute UTF-16 source offsets; `to` is exclusive.
|
|
12
|
+
CellFailure = Data.define(:from, :to, :actual)
|
|
13
|
+
|
|
14
|
+
# Where a failure points in the source: an offset range, `to` exclusive.
|
|
15
|
+
# The failing step's match span, or the first mismatched cell's span (the
|
|
16
|
+
# failure_anchor rule) — what lets a renderer underline the step that
|
|
17
|
+
# failed rather than the whole line it sits on.
|
|
18
|
+
AnchorRange = Data.define(:from, :to)
|
|
19
|
+
|
|
20
|
+
# The failure payload of a failed ExampleResult. `cells` and `anchor` are
|
|
21
|
+
# nil when they do not apply, and serialize as absent (not null), so a
|
|
22
|
+
# reader that predates them still parses the file. `stack` is deliberately
|
|
23
|
+
# runtime-shaped — no consumer parses it.
|
|
24
|
+
ExampleFailure = Data.define(:line, :message, :stack, :cells, :anchor) do
|
|
25
|
+
def initialize(line:, message:, stack:, cells: nil, anchor: nil)
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The run result for one BDD example. `lines` are the 1-based source lines
|
|
31
|
+
# of its steps (the editor's line-wash anchors).
|
|
32
|
+
ExampleResult = Data.define(:name, :status, :lines, :failure) do
|
|
33
|
+
def initialize(name:, status:, lines:, failure: nil)
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The persisted run result for one oath file. `oath_path` uses POSIX
|
|
39
|
+
# separators and is relative to the workspace root; `source_hash` is
|
|
40
|
+
# Hashing.hash_source over the oath as it was run, so a reader can tell
|
|
41
|
+
# whether the offsets still apply to the buffer in front of it.
|
|
42
|
+
OathResults = Data.define(:version, :oath_path, :source_hash, :examples)
|
|
43
|
+
|
|
44
|
+
# Projection of OathResults onto the JSON shape of .varar/<oath_path>.json.
|
|
45
|
+
#
|
|
46
|
+
# The wire format is the TypeScript one (ADR 0014): camelCase names,
|
|
47
|
+
# declaration order, and the optional members absent rather than null so a
|
|
48
|
+
# reader that predates them still parses the file. Pure — writing the file
|
|
49
|
+
# is the shell's job.
|
|
50
|
+
module Results
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
def to_wire(results)
|
|
54
|
+
{
|
|
55
|
+
'version' => results.version,
|
|
56
|
+
'oathPath' => results.oath_path,
|
|
57
|
+
'sourceHash' => results.source_hash,
|
|
58
|
+
'examples' => results.examples.map { |e| example_to_wire(e) }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def example_to_wire(example)
|
|
63
|
+
out = { 'name' => example.name, 'status' => example.status, 'lines' => example.lines.to_a }
|
|
64
|
+
out['failure'] = failure_to_wire(example.failure) if example.failure
|
|
65
|
+
out
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def failure_to_wire(failure)
|
|
69
|
+
out = { 'line' => failure.line, 'message' => failure.message, 'stack' => failure.stack }
|
|
70
|
+
if failure.cells && !failure.cells.empty?
|
|
71
|
+
out['cells'] = failure.cells.map { |c| { 'from' => c.from, 'to' => c.to, 'actual' => c.actual } }
|
|
72
|
+
end
|
|
73
|
+
out['anchor'] = { 'from' => failure.anchor.from, 'to' => failure.anchor.to } if failure.anchor
|
|
74
|
+
out
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -5,8 +5,12 @@ require 'varar/core/ast'
|
|
|
5
5
|
|
|
6
6
|
module Varar
|
|
7
7
|
module Core
|
|
8
|
-
# Group scanned blocks into Examples, tracking heading scope and
|
|
9
|
-
# attachments.
|
|
8
|
+
# Group scanned blocks into candidate Examples, tracking heading scope and
|
|
9
|
+
# orphan attachments. This is pure syntax — it does NOT decide where one
|
|
10
|
+
# example ends and the next begins. Each candidate records
|
|
11
|
+
# +preceded_by_delimiter+ (a heading or `---` sits before it) and the planner
|
|
12
|
+
# groups adjacent matching candidates into examples. Port of structurer.ts.
|
|
13
|
+
# See ADR 0012.
|
|
10
14
|
module Structurer
|
|
11
15
|
module_function
|
|
12
16
|
|
|
@@ -16,6 +20,11 @@ module Varar
|
|
|
16
20
|
scope_stack = [] # [[level, text], ...]
|
|
17
21
|
last_example_idx = -1
|
|
18
22
|
attachment_open = false
|
|
23
|
+
# A heading or thematic break seen since the previous candidate — the
|
|
24
|
+
# next candidate is then delimiter-preceded. Starts true so the first
|
|
25
|
+
# candidate in the file counts as delimiter-preceded (nothing to merge
|
|
26
|
+
# into).
|
|
27
|
+
delimiter_pending = true
|
|
19
28
|
|
|
20
29
|
blocks.each do |block|
|
|
21
30
|
case block.kind
|
|
@@ -24,35 +33,18 @@ module Varar
|
|
|
24
33
|
scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level
|
|
25
34
|
scope_stack << [block.level, block.text]
|
|
26
35
|
attachment_open = false
|
|
36
|
+
delimiter_pending = true
|
|
27
37
|
|
|
28
38
|
when 'paragraph', 'list_item', 'blockquote'
|
|
29
|
-
# Merge a block into the previous example when that example's last
|
|
30
|
-
# block is an attachment (table/fence) with no blank line between.
|
|
31
|
-
if attachment_open && last_example_idx >= 0
|
|
32
|
-
prev = examples[last_example_idx]
|
|
33
|
-
prev_last = prev.body.last
|
|
34
|
-
last_is_attachment = !prev_last.nil? && %w[table fence].include?(prev_last.kind)
|
|
35
|
-
if last_is_attachment
|
|
36
|
-
between = Offsets.utf16_slice(source, prev.span.end_offset, block.span.start_offset)
|
|
37
|
-
unless between.match?(/\n\s*\n/)
|
|
38
|
-
new_span = Offsets.span_from_offsets(source, prev.span.start_offset, block.span.end_offset)
|
|
39
|
-
examples[last_example_idx] = Example.new(
|
|
40
|
-
scope_stack: prev.scope_stack,
|
|
41
|
-
span: new_span,
|
|
42
|
-
body: prev.body + [block]
|
|
43
|
-
)
|
|
44
|
-
next
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
39
|
examples << Example.new(
|
|
50
40
|
scope_stack: scope_stack.map { |(_, text)| text },
|
|
51
41
|
span: block.span,
|
|
52
|
-
body: [block]
|
|
42
|
+
body: [block],
|
|
43
|
+
preceded_by_delimiter: delimiter_pending
|
|
53
44
|
)
|
|
54
45
|
last_example_idx = examples.length - 1
|
|
55
46
|
attachment_open = true
|
|
47
|
+
delimiter_pending = false
|
|
56
48
|
|
|
57
49
|
when 'table', 'fence'
|
|
58
50
|
if attachment_open && last_example_idx >= 0
|
|
@@ -61,7 +53,8 @@ module Varar
|
|
|
61
53
|
examples[last_example_idx] = Example.new(
|
|
62
54
|
scope_stack: prev.scope_stack,
|
|
63
55
|
span: new_span,
|
|
64
|
-
body: prev.body + [block]
|
|
56
|
+
body: prev.body + [block],
|
|
57
|
+
preceded_by_delimiter: prev.preceded_by_delimiter
|
|
65
58
|
)
|
|
66
59
|
else
|
|
67
60
|
orphan_attachments << block
|
|
@@ -69,10 +62,11 @@ module Varar
|
|
|
69
62
|
|
|
70
63
|
when 'thematic_break'
|
|
71
64
|
attachment_open = false
|
|
65
|
+
delimiter_pending = true
|
|
72
66
|
end
|
|
73
67
|
end
|
|
74
68
|
|
|
75
|
-
|
|
69
|
+
Doc.new(
|
|
76
70
|
path: path,
|
|
77
71
|
source: source,
|
|
78
72
|
examples: examples,
|