varar-core 0.6.1 → 0.7.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 +5 -1
- data/lib/varar/core/cell_diff.rb +7 -2
- data/lib/varar/core/conformance.rb +2 -11
- data/lib/varar/core/doc_string_diff.rb +12 -14
- data/lib/varar/core/drift.rb +11 -3
- data/lib/varar/core/execute.rb +30 -12
- data/lib/varar/core/failure_anchor.rb +0 -2
- data/lib/varar/core/param_diff.rb +1 -1
- data/lib/varar/core/plan.rb +207 -130
- data/lib/varar/core/registry.rb +32 -4
- data/lib/varar/core/structurer.rb +18 -24
- data/lib/varar/core.rb +1 -2
- metadata +2 -3
- data/lib/varar/core/deep_freeze.rb +0 -25
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d857a484c127ac8dc313cff7dc855f7924e9b90dd9935b3575e880a4abc5e9c
|
|
4
|
+
data.tar.gz: becbdbfe8eda5afc06b7506b719fafd3868e1506cec1e484e461766b7b39225a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1f17d5bf2fef77cc8c8dbb4d0991c72232b08d0fe139f0c60768016c86b2eeeca33e54b66a4098ba1ab5bf15cbb00aa91f0a3e48e9ceadc7c457c630085d62c
|
|
7
|
+
data.tar.gz: 05404cecafaeca822ede078824c9bfad5d959236ea7c23f00a8b44a9d0ffe4a002ff9363d7c52bc86649d2d3bfd6b353f68c67c1131ac0c867172beac0e0913f
|
data/lib/varar/core/ast.rb
CHANGED
|
@@ -40,7 +40,11 @@ module Varar
|
|
|
40
40
|
def kind = 'thematic_break'
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
# +preceded_by_delimiter+ is true when a heading or thematic break (`---`)
|
|
44
|
+
# sits between this candidate and the previous one (also true for the first
|
|
45
|
+
# candidate). The planner uses it to group adjacent matching candidates into
|
|
46
|
+
# one example. See ADR 0012.
|
|
47
|
+
Example = Data.define(:scope_stack, :span, :body, :preceded_by_delimiter)
|
|
44
48
|
|
|
45
49
|
VarDoc = Data.define(:path, :source, :examples, :orphan_attachments)
|
|
46
50
|
end
|
data/lib/varar/core/cell_diff.rb
CHANGED
|
@@ -5,7 +5,11 @@ module Varar
|
|
|
5
5
|
# One checked column of one header-bound row: the cell text and its span.
|
|
6
6
|
RowCheck = Data.define(:column, :value, :span)
|
|
7
7
|
|
|
8
|
-
# The verdict for one
|
|
8
|
+
# The verdict for one comparison of one CELL — the atomic value a sensor
|
|
9
|
+
# checks against the document. A cell is a table cell, a header-bound row's
|
|
10
|
+
# cell, or a value captured from a paragraph by an expression parameter; all
|
|
11
|
+
# three land here. `column` labels the cell (a header cell's text, or `arg N`
|
|
12
|
+
# for an inline capture).
|
|
9
13
|
# expected_value/actual_value/formatted are adapter-facing, never serialized.
|
|
10
14
|
CellDiff = Data.define(:column, :span, :expected, :actual, :ok,
|
|
11
15
|
:expected_value, :actual_value, :formatted) do
|
|
@@ -18,7 +22,8 @@ module Varar
|
|
|
18
22
|
# The step returned the wrong type/shape — an author mistake, not a value diff.
|
|
19
23
|
class ReturnShapeError < StandardError; end
|
|
20
24
|
|
|
21
|
-
# Raised when
|
|
25
|
+
# Raised when one or more compared CELLS differ — an inline capture, a table
|
|
26
|
+
# cell, or a header-bound row's cell.
|
|
22
27
|
class CellMismatchError < StandardError
|
|
23
28
|
attr_reader :cells
|
|
24
29
|
|
|
@@ -93,7 +93,8 @@ module Varar
|
|
|
93
93
|
{
|
|
94
94
|
'scopeStack' => example.scope_stack,
|
|
95
95
|
'span' => span_hash(example.span),
|
|
96
|
-
'body' => example.body.map { |b| block_hash(b) }
|
|
96
|
+
'body' => example.body.map { |b| block_hash(b) },
|
|
97
|
+
'precededByDelimiter' => example.preceded_by_delimiter
|
|
97
98
|
}
|
|
98
99
|
end
|
|
99
100
|
|
|
@@ -195,16 +196,6 @@ module Varar
|
|
|
195
196
|
{ 'column' => c.column, 'expected' => c.expected, 'actual' => c.actual, 'span' => span_hash(c.span) }
|
|
196
197
|
end
|
|
197
198
|
}
|
|
198
|
-
when DocStringMismatchError
|
|
199
|
-
{
|
|
200
|
-
'kind' => 'doc-string-mismatch', 'line' => line, 'anchor' => anchor,
|
|
201
|
-
'message' => error.message,
|
|
202
|
-
'diff' => {
|
|
203
|
-
'expected' => error.diff.expected,
|
|
204
|
-
'actual' => error.diff.actual,
|
|
205
|
-
'span' => span_hash(error.diff.span)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
199
|
when ReturnShapeError
|
|
209
200
|
{ 'kind' => 'return-shape', 'line' => line, 'anchor' => anchor, 'message' => error.message }
|
|
210
201
|
when UnexpectedPassError
|
|
@@ -4,32 +4,30 @@ require 'varar/core/cell_diff'
|
|
|
4
4
|
|
|
5
5
|
module Varar
|
|
6
6
|
module Core
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
# Raised when a doc-string step's returned string differs from the content.
|
|
11
|
-
class DocStringMismatchError < StandardError
|
|
12
|
-
attr_reader :diff
|
|
13
|
-
|
|
14
|
-
def initialize(diff)
|
|
15
|
-
@diff = diff
|
|
16
|
-
super("doc string: expected #{diff.expected.inspect} but was #{diff.actual.inspect}")
|
|
17
|
-
end
|
|
18
|
-
end
|
|
7
|
+
# The column label a doc-string cell carries in a CellDiff, so its mismatch
|
|
8
|
+
# message reads `doc string: expected … but was …`.
|
|
9
|
+
DOC_STRING_COLUMN = 'doc string'
|
|
19
10
|
|
|
20
11
|
# Pure comparison of a doc-string step's return against the fence body.
|
|
21
12
|
# Port of doc-string-diff.ts.
|
|
22
13
|
module DocStringDiffs
|
|
23
14
|
module_function
|
|
24
15
|
|
|
25
|
-
#
|
|
16
|
+
# A doc string is ONE CELL, compared whole, so a difference is an ordinary
|
|
17
|
+
# CellDiff and the executor raises the same CellMismatchError as any other
|
|
18
|
+
# cell. `expected`/`actual` are quoted: a doc string routinely differs only
|
|
19
|
+
# in whitespace, and bare text would render a missing trailing newline as
|
|
20
|
+
# no difference at all.
|
|
21
|
+
#
|
|
22
|
+
# nil → no check; equal string → nil (pass); unequal → CellDiff;
|
|
26
23
|
# non-string → ReturnShapeError.
|
|
27
24
|
def compare_doc_string(returned, content, span)
|
|
28
25
|
return nil if returned.nil?
|
|
29
26
|
raise ReturnShapeError, "expected a doc string (string), got #{returned.class}" unless returned.is_a?(String)
|
|
30
27
|
return nil if returned == content
|
|
31
28
|
|
|
32
|
-
|
|
29
|
+
CellDiff.new(column: DOC_STRING_COLUMN, span: span, expected: content.inspect,
|
|
30
|
+
actual: returned.inspect, ok: false)
|
|
33
31
|
end
|
|
34
32
|
end
|
|
35
33
|
end
|
data/lib/varar/core/drift.rb
CHANGED
|
@@ -31,12 +31,20 @@ module Varar
|
|
|
31
31
|
|
|
32
32
|
module_function
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Do the two spans overlap at all (offset ranges intersect)? A candidate
|
|
35
|
+
# paragraph relates to its planned example either way round: a header-bound
|
|
36
|
+
# row sits inside its binding paragraph, while a merged example's span
|
|
37
|
+
# covers each candidate it absorbed (ADR 0012). Overlap catches both.
|
|
38
|
+
def overlaps?(span_a, span_b)
|
|
39
|
+
span_a.start_offset < span_b.end_offset && span_b.start_offset < span_a.end_offset
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
# A candidate is "live" (still an example) if it overlaps at least one
|
|
43
|
+
# planned example. A now-prose paragraph — one whose step def was renamed
|
|
44
|
+
# or deleted — overlaps none (it became a delimiter, splitting any example
|
|
45
|
+
# it was part of), so drift catches it.
|
|
38
46
|
def live?(candidate_span, plan)
|
|
39
|
-
plan.examples.any? { |pe|
|
|
47
|
+
plan.examples.any? { |pe| overlaps?(pe.span, candidate_span) }
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
# Lower-cased word tokens (letters/digits) — the unit of similarity.
|
data/lib/varar/core/execute.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'varar/core/span'
|
|
4
|
-
require 'varar/core/deep_freeze'
|
|
5
4
|
require 'varar/core/cell_diff'
|
|
6
5
|
require 'varar/core/doc_string_diff'
|
|
7
6
|
require 'varar/core/param_diff'
|
|
@@ -61,7 +60,7 @@ module Varar
|
|
|
61
60
|
|
|
62
61
|
ex.steps.each_with_index do |step, i|
|
|
63
62
|
file = step.step_def.expression_source_file
|
|
64
|
-
state_by_file[file] =
|
|
63
|
+
state_by_file[file] = create_ctx.call(file) unless state_by_file.key?(file)
|
|
65
64
|
state = state_by_file[file]
|
|
66
65
|
|
|
67
66
|
extra = []
|
|
@@ -76,9 +75,12 @@ module Varar
|
|
|
76
75
|
last_return = returned
|
|
77
76
|
case step.step_def.kind
|
|
78
77
|
when 'stimulus'
|
|
79
|
-
# Full replacement: the returned Hash IS the next state
|
|
80
|
-
#
|
|
81
|
-
#
|
|
78
|
+
# Full replacement: the returned Hash IS the next state. There is
|
|
79
|
+
# no merge — a return with fewer keys shrinks the state. nil is a
|
|
80
|
+
# no-op; any other type is a contract violation.
|
|
81
|
+
#
|
|
82
|
+
# The state is the author's own value, handed back untouched: we
|
|
83
|
+
# do not freeze it. Whether it is immutable is the author's call.
|
|
82
84
|
unless returned.nil?
|
|
83
85
|
unless returned.is_a?(Hash)
|
|
84
86
|
raise ReturnShapeError,
|
|
@@ -86,11 +88,11 @@ module Varar
|
|
|
86
88
|
'or nothing to leave it unchanged'
|
|
87
89
|
end
|
|
88
90
|
|
|
89
|
-
state =
|
|
91
|
+
state = returned
|
|
90
92
|
state_by_file[file] = state
|
|
91
93
|
end
|
|
92
94
|
when 'sensor'
|
|
93
|
-
compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil?
|
|
95
|
+
compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil?
|
|
94
96
|
else
|
|
95
97
|
raise ReturnShapeError, "unknown step kind: #{step.step_def.kind}"
|
|
96
98
|
end
|
|
@@ -106,10 +108,16 @@ module Varar
|
|
|
106
108
|
|
|
107
109
|
# Header-bound row checks (after all steps).
|
|
108
110
|
if thrown.nil? && ex.row_checks && !ex.row_checks.empty?
|
|
111
|
+
# Like a slotted sensor, a header-bound row step must answer the row
|
|
112
|
+
# it is bound to: no return means nothing was compared.
|
|
113
|
+
row_error = if last_return.nil?
|
|
114
|
+
ReturnShapeError.new('a header-bound row step must return a row object ' \
|
|
115
|
+
'with one value per bound cell, got nothing')
|
|
116
|
+
end
|
|
109
117
|
bad = CellDiffs.compare_row(last_return, ex.row_checks).reject(&:ok)
|
|
110
|
-
|
|
118
|
+
if row_error || !bad.empty?
|
|
111
119
|
last_step = ex.steps.last
|
|
112
|
-
augmented = augment_stack(CellMismatchError.new(bad), last_step, var_path)
|
|
120
|
+
augmented = augment_stack(row_error || CellMismatchError.new(bad), last_step, var_path)
|
|
113
121
|
observer&.call(observation(ex, example_index, ex.steps.length,
|
|
114
122
|
last_step.step_def.expression_source_file, 'fail', augmented))
|
|
115
123
|
thrown = augmented
|
|
@@ -133,20 +141,30 @@ module Varar
|
|
|
133
141
|
end
|
|
134
142
|
|
|
135
143
|
# Sensor slot contract: zero slots + a return is a mistake; one slot IS
|
|
136
|
-
# the return; two+ is a positional array.
|
|
144
|
+
# the return; two+ is a positional array. With one or more slots the
|
|
145
|
+
# return is REQUIRED — returning nothing used to skip the comparison
|
|
146
|
+
# silently, so a typo turned an assertion into a no-op. Raises the
|
|
147
|
+
# appropriate diff error.
|
|
137
148
|
def compare_sensor_return(plan, _ex, step, returned, extra)
|
|
138
149
|
slot_count = step.args.length + extra.length
|
|
139
150
|
if slot_count.zero?
|
|
151
|
+
return if returned.nil?
|
|
152
|
+
|
|
140
153
|
raise ReturnShapeError, 'this sensor has no parameters, data table or doc string — ' \
|
|
141
154
|
'nothing to compare a return value against (raise to fail, return nothing to pass)'
|
|
142
155
|
end
|
|
143
156
|
|
|
157
|
+
if returned.nil?
|
|
158
|
+
raise ReturnShapeError,
|
|
159
|
+
"a sensor with #{slot_count} slot(s) must return one value per slot, got nothing"
|
|
160
|
+
end
|
|
161
|
+
|
|
144
162
|
if slot_count == 1
|
|
145
163
|
slots = [returned]
|
|
146
164
|
else
|
|
147
165
|
unless returned.is_a?(Array)
|
|
148
166
|
raise ReturnShapeError,
|
|
149
|
-
"a sensor with #{slot_count}
|
|
167
|
+
"a sensor with #{slot_count} slots must return a list of " \
|
|
150
168
|
"#{slot_count} values, got #{returned.class}"
|
|
151
169
|
end
|
|
152
170
|
unless returned.length == slot_count
|
|
@@ -171,7 +189,7 @@ module Varar
|
|
|
171
189
|
elsif step.doc_string
|
|
172
190
|
diff = DocStringDiffs.compare_doc_string(slots[step.args.length], step.doc_string.content,
|
|
173
191
|
step.doc_string.span)
|
|
174
|
-
raise
|
|
192
|
+
raise CellMismatchError, [diff] unless diff.nil?
|
|
175
193
|
end
|
|
176
194
|
end
|
|
177
195
|
|
data/lib/varar/core/plan.rb
CHANGED
|
@@ -38,160 +38,237 @@ module Varar
|
|
|
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
55
|
def plan(var_doc, registry)
|
|
44
|
-
examples = []
|
|
45
56
|
diagnostics = []
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
steps_by_block = {}
|
|
50
|
-
|
|
51
|
-
# Pass 1: plan each text-bearing block.
|
|
52
|
-
ex.body.each_with_index do |block, idx|
|
|
53
|
-
next unless %w[paragraph list_item blockquote].include?(block.kind)
|
|
54
|
-
|
|
55
|
-
result = plan_block(block.text, registry)
|
|
56
|
-
|
|
57
|
-
result.ambiguities.each do |collision|
|
|
58
|
-
span = lift_span(var_doc.source, block, collision.match_start, collision.match_end)
|
|
59
|
-
cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
|
|
60
|
-
cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
|
|
61
|
-
diagnostics << Diagnostics.ambiguous_match(
|
|
62
|
-
AmbiguousInput.new(
|
|
63
|
-
text: block.text[cp_start...cp_end],
|
|
64
|
-
span: span,
|
|
65
|
-
candidates: collision.candidates.map do |c|
|
|
66
|
-
Candidate.new(
|
|
67
|
-
expression: c.expression,
|
|
68
|
-
source_file: c.step_def.expression_source_file,
|
|
69
|
-
source_line: c.step_def.expression_source_line
|
|
70
|
-
)
|
|
71
|
-
end
|
|
72
|
-
)
|
|
73
|
-
)
|
|
74
|
-
had_ambiguous = true
|
|
75
|
-
end
|
|
58
|
+
# Phase 1: plan each candidate paragraph independently into a "unit".
|
|
59
|
+
units = var_doc.examples.map { |ex| plan_candidate(ex, var_doc, registry, diagnostics) }
|
|
76
60
|
|
|
77
|
-
|
|
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, var_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(var_doc: var_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
|
|
112
|
+
|
|
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, var_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(var_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(var_doc.source, block, hit.match_start, hit.match_end),
|
|
163
|
+
param_spans: hit.param_spans.map { |p| lift_span(var_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, var_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?
|
|
173
|
-
|
|
174
|
-
next if final_steps.empty? && !had_ambiguous
|
|
250
|
+
runnable_steps = had_ambiguous ? [] : final_steps
|
|
175
251
|
|
|
176
|
-
|
|
177
|
-
expected_error_message = nil
|
|
178
|
-
if error_fence
|
|
179
|
-
expected_outcome = 'fail'
|
|
180
|
-
msg = error_fence.body.strip
|
|
181
|
-
expected_error_message = msg unless msg.empty?
|
|
182
|
-
end
|
|
252
|
+
diagnostics << Diagnostics.error_fence_without_step(error_fence.span) if error_fence && runnable_steps.empty?
|
|
183
253
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 specs 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
|
|
|
@@ -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,6 +62,7 @@ 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
|
|
data/lib/varar/core.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Varar
|
|
|
4
4
|
# The pure functional core: parse, match, plan, execute, diffs, drift, and
|
|
5
5
|
# the conformance projections. No filesystem, network, globals, or time.
|
|
6
6
|
module Core
|
|
7
|
-
VERSION = '0.
|
|
7
|
+
VERSION = '0.7.0'
|
|
8
8
|
end
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -21,7 +21,6 @@ require 'varar/core/diagnostics'
|
|
|
21
21
|
require 'varar/core/cell_diff'
|
|
22
22
|
require 'varar/core/matcher'
|
|
23
23
|
require 'varar/core/plan'
|
|
24
|
-
require 'varar/core/deep_freeze'
|
|
25
24
|
require 'varar/core/doc_string_diff'
|
|
26
25
|
require 'varar/core/param_diff'
|
|
27
26
|
require 'varar/core/failure_anchor'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: varar-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aslak Hellesøy
|
|
@@ -24,7 +24,7 @@ dependencies:
|
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: 20.0.0
|
|
26
26
|
description: The pure functional pipeline (parse, match, plan, execute, drift) behind
|
|
27
|
-
|
|
27
|
+
Varar.
|
|
28
28
|
email:
|
|
29
29
|
- aslak@oselvar.com
|
|
30
30
|
executables: []
|
|
@@ -36,7 +36,6 @@ files:
|
|
|
36
36
|
- lib/varar/core/canonical_json.rb
|
|
37
37
|
- lib/varar/core/cell_diff.rb
|
|
38
38
|
- lib/varar/core/conformance.rb
|
|
39
|
-
- lib/varar/core/deep_freeze.rb
|
|
40
39
|
- lib/varar/core/diagnostics.rb
|
|
41
40
|
- lib/varar/core/doc_string_diff.rb
|
|
42
41
|
- lib/varar/core/drift.rb
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Varar
|
|
4
|
-
module Core
|
|
5
|
-
# Recursively freeze plain Hash/Array so handler code mutating state raises
|
|
6
|
-
# FrozenError. Other objects (class instances, primitives, nil) pass
|
|
7
|
-
# through. Assumes acyclic input. Port of deep-freeze.ts.
|
|
8
|
-
module DeepFreeze
|
|
9
|
-
module_function
|
|
10
|
-
|
|
11
|
-
def deep_freeze(value)
|
|
12
|
-
case value
|
|
13
|
-
when Hash
|
|
14
|
-
value.each_value { |v| deep_freeze(v) }
|
|
15
|
-
value.freeze
|
|
16
|
-
when Array
|
|
17
|
-
value.each { |v| deep_freeze(v) }
|
|
18
|
-
value.freeze
|
|
19
|
-
else
|
|
20
|
-
value
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|