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