varar-core 0.5.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.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'varar/core/hash'
5
+ require 'varar/core/diagnostics'
6
+ require 'varar/core/plan'
7
+ require 'varar/core/canonical_json'
8
+
9
+ module Varar
10
+ module Core
11
+ # One example-producing paragraph, as recorded in the baseline.
12
+ BaselineExample = Data.define(:name, :line)
13
+ # The committed baseline for one spec file.
14
+ SpecBaseline = Data.define(:source_hash, :examples)
15
+ # The whole varar.lock.json: every spec keyed by its POSIX path.
16
+ VarLock = Data.define(:version, :specs)
17
+ # A paragraph the baseline says was an example and now matches no step.
18
+ Drift = Data.define(:name, :line, :span)
19
+
20
+ # Spec drift detection: a paragraph the committed varar.lock.json baseline
21
+ # recorded as an example that now matches no step. Pure, byte-identical to
22
+ # the TS port so varar.lock.json is shared across languages. Port of drift.ts.
23
+ #
24
+ # BaselineStore is a duck-typed port: #read -> String|nil, #write(contents).
25
+ module Drifts
26
+ # A paragraph may be moved anywhere and reworded up to ~half its words
27
+ # and still be recognized; edit it past this and it reads as remove+add,
28
+ # not drift. Ported byte-identically.
29
+ SIMILARITY_THRESHOLD = 0.5
30
+ TOKEN_RE = /[[:alnum:]]+/
31
+
32
+ module_function
33
+
34
+ def within?(inner, outer)
35
+ inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
36
+ end
37
+
38
+ def live?(candidate_span, plan)
39
+ plan.examples.any? { |pe| within?(pe.span, candidate_span) }
40
+ end
41
+
42
+ # Lower-cased word tokens (letters/digits) — the unit of similarity.
43
+ def tokenize(text)
44
+ text.downcase.scan(TOKEN_RE).to_set
45
+ end
46
+
47
+ # Jaccard overlap |A∩B| / |A∪B|. 1 identical, 0 disjoint; two empty = 1.
48
+ def similarity(set_a, set_b)
49
+ return 1.0 if set_a.empty? && set_b.empty?
50
+
51
+ intersection = (set_a & set_b).size
52
+ union = set_a.size + set_b.size - intersection
53
+ union.zero? ? 0.0 : intersection.to_f / union
54
+ end
55
+
56
+ # The current example-producing paragraphs, in document order.
57
+ def live_examples(var_doc, plan)
58
+ var_doc.examples.filter_map do |candidate|
59
+ next unless live?(candidate.span, plan)
60
+
61
+ BaselineExample.new(name: Plan.derive_example_name(candidate.body), line: candidate.span.start_line)
62
+ end
63
+ end
64
+
65
+ def derive_spec_baseline(source, var_doc, plan)
66
+ SpecBaseline.new(source_hash: Hash32.hash_source(source), examples: live_examples(var_doc, plan))
67
+ end
68
+
69
+ # Paragraphs the baseline recorded as examples that now match zero steps.
70
+ # Each re-identified by the most word-similar current paragraph at/above
71
+ # the threshold (exact name scores 1; ties break toward the nearest line).
72
+ def detect_drift(baseline, var_doc, plan)
73
+ return [] if baseline.nil?
74
+
75
+ candidates = var_doc.examples
76
+ tokens = candidates.map { |c| tokenize(Plan.derive_example_name(c.body)) }
77
+ live = candidates.map { |c| live?(c.span, plan) }
78
+
79
+ baseline.examples.filter_map do |b|
80
+ b_tokens = tokenize(b.name)
81
+ best_idx = -1
82
+ best_score = 0.0
83
+ candidates.each_with_index do |candidate, i|
84
+ score = similarity(b_tokens, tokens[i])
85
+ next if score < SIMILARITY_THRESHOLD
86
+
87
+ line = candidate.span.start_line
88
+ best_line = best_idx >= 0 ? candidates[best_idx].span.start_line : 0
89
+ next unless best_idx.negative? || score > best_score ||
90
+ (score == best_score && (line - b.line).abs < (best_line - b.line).abs)
91
+
92
+ best_idx = i
93
+ best_score = score
94
+ end
95
+ next if best_idx.negative?
96
+ next if live[best_idx]
97
+
98
+ Drift.new(name: b.name, line: candidates[best_idx].span.start_line, span: candidates[best_idx].span)
99
+ end
100
+ end
101
+
102
+ def drift_diagnostics(drifts)
103
+ drifts.map { |d| Diagnostics.drift_detected(d.name, d.span) }
104
+ end
105
+
106
+ # One spec's baseline reconciliation against a BaselineStore. In update
107
+ # mode, accept all drift (re-record, report nothing); otherwise detect
108
+ # drift and rewrite the baseline only on a clean run, so an unacknowledged
109
+ # drift keeps its old entry (and stays red).
110
+ def reconcile_drift(store, spec_path, source, var_doc, plan, update: false)
111
+ text = store.read
112
+ lock = text ? parse_var_lock(text) : nil
113
+ baseline = lock ? lock.specs[spec_path] : nil
114
+ drifts = update ? [] : detect_drift(baseline, var_doc, plan)
115
+ if update || drifts.empty?
116
+ specs = lock ? lock.specs.dup : {}
117
+ specs[spec_path] = derive_spec_baseline(source, var_doc, plan)
118
+ store.write(stringify_var_lock(VarLock.new(version: 1, specs: specs)))
119
+ end
120
+ drifts
121
+ end
122
+
123
+ def parse_var_lock(text)
124
+ parsed = JSON.parse(text)
125
+ return nil unless parsed.is_a?(::Hash) && parsed['version'] == 1
126
+
127
+ specs_raw = parsed['specs']
128
+ return nil unless specs_raw.is_a?(::Hash)
129
+
130
+ specs = {}
131
+ specs_raw.each do |path, value|
132
+ baseline = parse_spec_baseline(value)
133
+ return nil if baseline.nil?
134
+
135
+ specs[path] = baseline
136
+ end
137
+ VarLock.new(version: 1, specs: specs)
138
+ rescue JSON::ParserError, TypeError
139
+ nil
140
+ end
141
+
142
+ def parse_spec_baseline(value)
143
+ return nil unless value.is_a?(::Hash)
144
+
145
+ source_hash = value['sourceHash']
146
+ examples_raw = value['examples']
147
+ return nil unless source_hash.is_a?(String) && examples_raw.is_a?(Array)
148
+
149
+ examples = []
150
+ examples_raw.each do |item|
151
+ parsed = parse_baseline_example(item)
152
+ return nil if parsed.nil?
153
+
154
+ examples << parsed
155
+ end
156
+ SpecBaseline.new(source_hash: source_hash, examples: examples)
157
+ end
158
+
159
+ def parse_baseline_example(value)
160
+ return nil unless value.is_a?(::Hash)
161
+
162
+ name = value['name']
163
+ line = value['line']
164
+ return nil unless name.is_a?(String) && line.is_a?(Integer)
165
+
166
+ BaselineExample.new(name: name, line: line)
167
+ end
168
+
169
+ # Serialize varar.lock.json deterministically: spec paths sorted, examples
170
+ # in document order, insertion-order keys otherwise (version, specs;
171
+ # sourceHash, examples; name, line) — NOT canonical JSON's key sort.
172
+ def stringify_var_lock(lock)
173
+ specs = {}
174
+ lock.specs.keys.sort.each do |path|
175
+ baseline = lock.specs[path]
176
+ specs[path] = {
177
+ 'sourceHash' => baseline.source_hash,
178
+ 'examples' => baseline.examples.map { |e| { 'name' => e.name, 'line' => e.line } }
179
+ }
180
+ end
181
+ CanonicalJson.ordered_stringify({ 'version' => 1, 'specs' => specs })
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+ require 'varar/core/deep_freeze'
5
+ require 'varar/core/cell_diff'
6
+ require 'varar/core/doc_string_diff'
7
+ require 'varar/core/param_diff'
8
+ require 'varar/core/failure_anchor'
9
+
10
+ module Varar
11
+ module Core
12
+ # Raised when an expected-to-fail example passes unexpectedly.
13
+ class UnexpectedPassError < StandardError
14
+ def initialize(message = 'expected the example to fail, but it passed')
15
+ super
16
+ end
17
+ end
18
+
19
+ # Per-step outcome emitted to the optional observer.
20
+ StepObservation = Data.define(:example_name, :example_index, :ordinal, :step_file, :outcome, :error) do
21
+ def initialize(example_name:, example_index:, ordinal:, step_file:, outcome:, error: nil)
22
+ super
23
+ end
24
+ end
25
+
26
+ # A named, runnable example returned by collect_examples.
27
+ QueuedExample = Data.define(:name, :run)
28
+
29
+ # Execute an ExecutionPlan: route stimulus/sensor returns, replace immutable
30
+ # state, compare sensor returns via the diff helpers, invert expected
31
+ # failures. Handlers are user callbacks (sync). Port of execute.ts.
32
+ module Execute
33
+ module_function
34
+
35
+ # Collect all examples into an ordered Array of QueuedExamples.
36
+ def collect_examples(plan, create_context:, observer: nil, reporter: nil)
37
+ queue = []
38
+ sink = ->(name, run, _info) { queue << QueuedExample.new(name: name, run: run) }
39
+ execute_plan(plan, sink: sink, create_context: create_context, observer: observer, reporter: reporter)
40
+ queue
41
+ end
42
+
43
+ def execute_plan(plan, sink:, create_context:, observer: nil, reporter: nil)
44
+ plan.diagnostics.each { |d| reporter.call(d) } if reporter
45
+ create_ctx = create_context || ->(_file) { {} }
46
+ var_path = plan.var_doc.path
47
+
48
+ plan.examples.each_with_index do |ex, example_index|
49
+ seen_lines = {}
50
+ ex.steps.each { |s| seen_lines[s.match_span.start_line] = true }
51
+ info = { lines: seen_lines.keys }
52
+ sink.call(ex.name, build_run(plan, ex, example_index, create_ctx, observer, var_path), info)
53
+ end
54
+ end
55
+
56
+ def build_run(plan, ex, example_index, create_ctx, observer, var_path)
57
+ lambda do
58
+ state_by_file = {}
59
+ last_return = nil
60
+ thrown = nil
61
+
62
+ ex.steps.each_with_index do |step, i|
63
+ file = step.step_def.expression_source_file
64
+ state_by_file[file] = DeepFreeze.deep_freeze(create_ctx.call(file)) unless state_by_file.key?(file)
65
+ state = state_by_file[file]
66
+
67
+ extra = []
68
+ if step.data_table
69
+ extra << ([step.data_table.header.cells] + step.data_table.rows.map(&:cells))
70
+ elsif step.doc_string
71
+ extra << step.doc_string.content
72
+ end
73
+
74
+ begin
75
+ returned = step.step_def.handler.call(state, *step.args, *extra)
76
+ last_return = returned
77
+ case step.step_def.kind
78
+ when 'stimulus'
79
+ # Full replacement: the returned Hash IS the next state (deep-frozen).
80
+ # There is no merge — a return with fewer keys shrinks the state.
81
+ # nil is a no-op; any other type is a contract violation.
82
+ unless returned.nil?
83
+ unless returned.is_a?(Hash)
84
+ raise ReturnShapeError,
85
+ 'a stimulus must return the complete next state, ' \
86
+ 'or nothing to leave it unchanged'
87
+ end
88
+
89
+ state = DeepFreeze.deep_freeze(returned)
90
+ state_by_file[file] = state
91
+ end
92
+ when 'sensor'
93
+ compare_sensor_return(plan, ex, step, returned, extra) if ex.row_checks.nil? && !returned.nil?
94
+ else
95
+ raise ReturnShapeError, "unknown step kind: #{step.step_def.kind}"
96
+ end
97
+ rescue StandardError => e
98
+ augmented = augment_stack(e, step, var_path)
99
+ observer&.call(observation(ex, example_index, i + 1, file, 'fail', augmented))
100
+ thrown = augmented
101
+ break
102
+ end
103
+
104
+ observer&.call(observation(ex, example_index, i + 1, file, 'pass'))
105
+ end
106
+
107
+ # Header-bound row checks (after all steps).
108
+ if thrown.nil? && ex.row_checks && !ex.row_checks.empty?
109
+ bad = CellDiffs.compare_row(last_return, ex.row_checks).reject(&:ok)
110
+ unless bad.empty?
111
+ last_step = ex.steps.last
112
+ augmented = augment_stack(CellMismatchError.new(bad), last_step, var_path)
113
+ observer&.call(observation(ex, example_index, ex.steps.length,
114
+ last_step.step_def.expression_source_file, 'fail', augmented))
115
+ thrown = augmented
116
+ end
117
+ end
118
+
119
+ # Expected-failure inversion.
120
+ if ex.expected_outcome == 'fail'
121
+ if thrown.nil?
122
+ error = UnexpectedPassError.new
123
+ last = ex.steps.last
124
+ raise(last ? augment_stack(error, last, var_path) : error)
125
+ end
126
+ raise thrown if ex.expected_error_message && !thrown.message.include?(ex.expected_error_message)
127
+
128
+ return # satisfied expected-failure → pass
129
+ end
130
+
131
+ raise thrown if thrown
132
+ end
133
+ end
134
+
135
+ # Sensor slot contract: zero slots + a return is a mistake; one slot IS
136
+ # the return; two+ is a positional array. Raises the appropriate diff error.
137
+ def compare_sensor_return(plan, _ex, step, returned, extra)
138
+ slot_count = step.args.length + extra.length
139
+ if slot_count.zero?
140
+ raise ReturnShapeError, 'this sensor has no parameters, data table or doc string — ' \
141
+ 'nothing to compare a return value against (raise to fail, return nothing to pass)'
142
+ end
143
+
144
+ if slot_count == 1
145
+ slots = [returned]
146
+ else
147
+ unless returned.is_a?(Array)
148
+ raise ReturnShapeError,
149
+ "a sensor with #{slot_count} parameters must return a list of " \
150
+ "#{slot_count} values, got #{returned.class}"
151
+ end
152
+ unless returned.length == slot_count
153
+ raise ReturnShapeError,
154
+ "sensor return must have #{slot_count} element(s), got #{returned.length}"
155
+ end
156
+
157
+ slots = returned
158
+ end
159
+
160
+ inline_returned = slots[0...step.args.length]
161
+ source_texts = step.param_spans.map do |s|
162
+ Offsets.utf16_slice(plan.var_doc.source, s.start_offset, s.end_offset)
163
+ end
164
+ param_diffs = ParamDiff.compare_params(inline_returned, step.args, step.param_spans, source_texts,
165
+ step.formats).reject(&:ok)
166
+ raise CellMismatchError, param_diffs unless param_diffs.empty?
167
+
168
+ if step.data_table
169
+ bad = CellDiffs.compare_table(slots[step.args.length], step.data_table).reject(&:ok)
170
+ raise CellMismatchError, bad unless bad.empty?
171
+ elsif step.doc_string
172
+ diff = DocStringDiffs.compare_doc_string(slots[step.args.length], step.doc_string.content,
173
+ step.doc_string.span)
174
+ raise DocStringMismatchError, diff unless diff.nil?
175
+ end
176
+ end
177
+
178
+ def observation(ex, example_index, ordinal, file, outcome, error = nil)
179
+ StepObservation.new(example_name: ex.name, example_index: example_index, ordinal: ordinal,
180
+ step_file: file, outcome: outcome, error: error)
181
+ end
182
+
183
+ # In TS this injects a synthetic `at <text> (path:line:col)` frame for
184
+ # editor navigation; the conformance trace derives the anchor separately
185
+ # via failure_anchor, so here it is a no-op that returns the error.
186
+ def augment_stack(error, _step, _var_path)
187
+ error
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/cell_diff'
4
+ require 'varar/core/doc_string_diff'
5
+
6
+ module Varar
7
+ module Core
8
+ # Where a failure points in the .md source: a mismatch anchors at its first
9
+ # failing span (cell / doc-string body), anything else at the fallback (the
10
+ # step's match span). The single source of truth for failure locations,
11
+ # pinned as failure.anchor in the conformance trace. Port of failure-anchor.ts.
12
+ module FailureAnchor
13
+ module_function
14
+
15
+ def failure_anchor(error, fallback)
16
+ case error
17
+ when CellMismatchError
18
+ failing = error.cells.find { |c| !c.ok }
19
+ failing ? failing.span : fallback
20
+ when DocStringMismatchError
21
+ error.diff.span
22
+ else
23
+ fallback
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ module Core
5
+ # FNV-1a (32-bit) change-detector over UTF-16 code units. Not a security
6
+ # hash: tiny and byte-identical to the TS/Python/JVM ports so varar.lock.json
7
+ # fingerprints match everywhere. The "fnv1a:" prefix namespaces the algorithm.
8
+ # Port of hash.ts.
9
+ module Hash32
10
+ FNV_OFFSET = 0x811c9dc5
11
+ FNV_PRIME = 0x01000193
12
+ MASK = 0xffffffff
13
+
14
+ module_function
15
+
16
+ def hash_source(source)
17
+ h = FNV_OFFSET
18
+ data = source.encode('UTF-16LE').bytes
19
+ (0...data.length).step(2) do |i|
20
+ unit = data[i] | (data[i + 1] << 8)
21
+ h = ((h ^ unit) * FNV_PRIME) & MASK
22
+ end
23
+ format('fnv1a:%08x', h)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+
5
+ module Varar
6
+ module Core
7
+ # UTF-16 start/end of one captured parameter within a sentence.
8
+ ParamSpan = Data.define(:start, :end)
9
+ # One successful expression match inside a sentence. Offsets are UTF-16.
10
+ Hit = Data.define(:expression, :step_def, :match_start, :match_end, :args, :param_spans, :formats) do
11
+ def initialize(expression:, step_def:, match_start:, match_end:, args:, param_spans:, formats: [])
12
+ super
13
+ end
14
+ end
15
+ # Two or more hits starting at the same position with equal length.
16
+ AmbiguityCollision = Data.define(:match_start, :match_end, :candidates)
17
+ # Tagged result of resolve_hits: kind "ok" (steps) or "ambiguous" (collisions).
18
+ ResolvedSteps = Data.define(:kind, :steps, :collisions) do
19
+ def initialize(kind:, steps: [], collisions: [])
20
+ super
21
+ end
22
+ end
23
+
24
+ # Cucumber-expression matching. Port of matcher.ts. cucumber-expressions'
25
+ # regexps are anchored (^...$) and its group offsets are code-point based,
26
+ # so we strip anchors for substring search and convert offsets to UTF-16.
27
+ module Matcher
28
+ module_function
29
+
30
+ # A compiled, un-anchored pattern from the step's CucumberExpression.
31
+ def unanchored_pattern(step)
32
+ regexp = step.compiled.instance_variable_get(:@tree_regexp).regexp
33
+ source = regexp.source
34
+ source = source[1..] if source.start_with?('^')
35
+ source = source[0...-1] if source.end_with?('$')
36
+ Regexp.new(source, regexp.options)
37
+ end
38
+
39
+ # Every expression match found anywhere in +sentence+.
40
+ def find_hits(sentence, registry)
41
+ hits = []
42
+ registry.steps.each do |step|
43
+ pattern = unanchored_pattern(step)
44
+ pos = 0
45
+ while pos <= sentence.length
46
+ m = pattern.match(sentence, pos)
47
+ break if m.nil?
48
+
49
+ matched_text = m[0]
50
+ arguments = step.compiled.match(matched_text) || []
51
+ args = arguments.map { |arg| arg.value(nil) }
52
+ formats = arguments.map { |arg| registry.formats[arg.parameter_type.name] }
53
+
54
+ # group.start/.end are code-point offsets within matched_text; add
55
+ # m.begin(0) for the sentence-absolute code-point index, then to UTF-16.
56
+ param_spans = arguments.filter_map do |arg|
57
+ g = arg.group
58
+ next unless g.start.is_a?(Integer) && g.end.is_a?(Integer)
59
+
60
+ ParamSpan.new(
61
+ start: Offsets.to_utf16_offset(sentence, m.begin(0) + g.start),
62
+ end: Offsets.to_utf16_offset(sentence, m.begin(0) + g.end)
63
+ )
64
+ end
65
+
66
+ hits << Hit.new(
67
+ expression: step.expression,
68
+ step_def: step,
69
+ match_start: Offsets.to_utf16_offset(sentence, m.begin(0)),
70
+ match_end: Offsets.to_utf16_offset(sentence, m.end(0)),
71
+ args: args,
72
+ param_spans: param_spans,
73
+ formats: formats
74
+ )
75
+
76
+ pos = matched_text.empty? ? m.begin(0) + 1 : m.end(0)
77
+ end
78
+ end
79
+ hits
80
+ end
81
+
82
+ # Select the best non-overlapping hits, or report ambiguities.
83
+ def resolve_hits(hits)
84
+ return ResolvedSteps.new(kind: 'ok') if hits.empty?
85
+
86
+ # Stable sort by (match_start asc, length desc); the original index
87
+ # breaks ties so equal-key order follows registration order (Ruby's
88
+ # sort_by is not stable, Python's sorted is).
89
+ sorted = hits.each_with_index.sort_by do |h, i|
90
+ [h.match_start, -(h.match_end - h.match_start), i]
91
+ end.map(&:first)
92
+
93
+ collisions = []
94
+ i = 0
95
+ while i < sorted.length
96
+ here = sorted[i]
97
+ here_len = here.match_end - here.match_start
98
+ tied = [here]
99
+ j = i + 1
100
+ while j < sorted.length
101
+ candidate = sorted[j]
102
+ if candidate.match_start == here.match_start &&
103
+ candidate.match_end - candidate.match_start == here_len
104
+ tied << candidate
105
+ j += 1
106
+ else
107
+ break
108
+ end
109
+ end
110
+ if tied.length > 1
111
+ collisions << AmbiguityCollision.new(
112
+ match_start: here.match_start, match_end: here.match_end, candidates: tied
113
+ )
114
+ end
115
+ i = j
116
+ end
117
+
118
+ return ResolvedSteps.new(kind: 'ambiguous', collisions: collisions) unless collisions.empty?
119
+
120
+ steps = []
121
+ cursor = -1
122
+ sorted.each do |hit|
123
+ next if hit.match_start < cursor
124
+
125
+ steps << hit
126
+ cursor = hit.match_end
127
+ end
128
+ ResolvedSteps.new(kind: 'ok', steps: steps)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/cell_diff'
4
+
5
+ module Varar
6
+ module Core
7
+ # Compare a sensor's returned inline actuals against captured document
8
+ # values. Port of param-diff.ts.
9
+ module ParamDiff
10
+ module_function
11
+
12
+ # Render one side of a parameter diff as [text, via_format]. The
13
+ # parameter type's format wins (document notation), else the shared
14
+ # string/primitive/inspect chain; a raising formatter falls through.
15
+ def render_param_value(value, format)
16
+ if format
17
+ begin
18
+ return [format.call(value), true]
19
+ rescue StandardError
20
+ # fall through to the native rendering
21
+ end
22
+ end
23
+ [CellDiffs.render_cell_value(value), false]
24
+ end
25
+
26
+ # Compare returned actuals against expected document values. Arrays align
27
+ # 1:1; structural equality (==) compares by value across references.
28
+ def compare_params(returned, expected, param_spans, source_texts, formats = nil)
29
+ expected.each_index.map do |i|
30
+ ok = returned[i] == expected[i]
31
+ format = formats && i < formats.length ? formats[i] : nil
32
+ actual_text, via_format = render_param_value(returned[i], format)
33
+ expected_text = if i < source_texts.length
34
+ source_texts[i]
35
+ else
36
+ render_param_value(expected[i], format)[0]
37
+ end
38
+ CellDiff.new(
39
+ column: "arg #{i + 1}",
40
+ span: param_spans[i],
41
+ expected: expected_text,
42
+ actual: actual_text,
43
+ ok: ok,
44
+ expected_value: expected[i],
45
+ actual_value: returned[i],
46
+ formatted: via_format
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/scanner'
4
+ require 'varar/core/structurer'
5
+
6
+ module Varar
7
+ module Core
8
+ # Parse +source+ into a VarDoc: scan blocks, then group into Examples.
9
+ # Port of parse.ts.
10
+ module Parse
11
+ module_function
12
+
13
+ def parse(path, source, plugins = [])
14
+ Structurer.structure(path, source, Scanner.scan(source, plugins))
15
+ end
16
+ end
17
+ end
18
+ end