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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 840328108129bed1a48c4a3dc86fa1ca4d13937f0e313ccfa41fcaf0a98f89c3
4
- data.tar.gz: 10d2916bda1d520f5521a99ddfd524305170b711fe476359746d83f5fa3f943b
3
+ metadata.gz: 8d857a484c127ac8dc313cff7dc855f7924e9b90dd9935b3575e880a4abc5e9c
4
+ data.tar.gz: becbdbfe8eda5afc06b7506b719fafd3868e1506cec1e484e461766b7b39225a
5
5
  SHA512:
6
- metadata.gz: 579d8c9e3dbb19fb6b29165dd8ba075f9ec4d6cf75808648ffd0bbc49e9d5265c7864b784d5d79a29a1d66f530d4fc5b5b3739c434e60f4541e196421e5c402d
7
- data.tar.gz: 42376d608744ce23fd97dc740c024d9432addf7b039a0ddcaa73eceab320c159ea06797e2729c13b4005d76c82a4fefde45af7cdebcaa8eacb1c5cf93a1499cf
6
+ metadata.gz: f1f17d5bf2fef77cc8c8dbb4d0991c72232b08d0fe139f0c60768016c86b2eeeca33e54b66a4098ba1ab5bf15cbb00aa91f0a3e48e9ceadc7c457c630085d62c
7
+ data.tar.gz: 05404cecafaeca822ede078824c9bfad5d959236ea7c23f00a8b44a9d0ffe4a002ff9363d7c52bc86649d2d3bfd6b353f68c67c1131ac0c867172beac0e0913f
@@ -40,7 +40,11 @@ module Varar
40
40
  def kind = 'thematic_break'
41
41
  end
42
42
 
43
- Example = Data.define(:scope_stack, :span, :body)
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
@@ -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 checked column after comparing against the table.
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 a header-bound row's / a table's returned columns don't match.
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
- # A doc-string content difference: fence body span, expected, actual.
8
- DocStringDiff = Data.define(:span, :expected, :actual)
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
- # nil no check; equal string nil (pass); unequal DocStringDiff;
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
- DocStringDiff.new(span: span, expected: content, actual: returned)
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
@@ -31,12 +31,20 @@ module Varar
31
31
 
32
32
  module_function
33
33
 
34
- def within?(inner, outer)
35
- inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
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| within?(pe.span, candidate_span) }
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.
@@ -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] = DeepFreeze.deep_freeze(create_ctx.call(file)) unless state_by_file.key?(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 (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.
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 = DeepFreeze.deep_freeze(returned)
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? && !returned.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
- unless bad.empty?
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. Raises the appropriate diff error.
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} parameters must return a list of " \
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 DocStringMismatchError, diff unless diff.nil?
192
+ raise CellMismatchError, [diff] unless diff.nil?
175
193
  end
176
194
  end
177
195
 
@@ -17,8 +17,6 @@ module Varar
17
17
  when CellMismatchError
18
18
  failing = error.cells.find { |c| !c.ok }
19
19
  failing ? failing.span : fallback
20
- when DocStringMismatchError
21
- error.diff.span
22
20
  else
23
21
  fallback
24
22
  end
@@ -36,7 +36,7 @@ module Varar
36
36
  render_param_value(expected[i], format)[0]
37
37
  end
38
38
  CellDiff.new(
39
- column: "arg #{i + 1}",
39
+ column: "cell #{i + 1}",
40
40
  span: param_spans[i],
41
41
  expected: expected_text,
42
42
  actual: actual_text,
@@ -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
- var_doc.examples.each do |ex|
48
- had_ambiguous = false
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
- next unless !had_ambiguous && !result.steps.empty?
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
- steps_by_block[idx] = result.steps.map do |hit|
80
- PlannedStep.new(
81
- text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
82
- match_span: lift_span(var_doc.source, block, hit.match_start, hit.match_end),
83
- param_spans: hit.param_spans.map { |p| lift_span(var_doc.source, block, p.start, p.end) },
84
- step_def: hit.step_def,
85
- args: hit.args,
86
- formats: hit.formats
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
- end
153
+ )
154
+ had_ambiguous = true
89
155
  end
90
156
 
91
- # Header-bound table detection.
92
- bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, var_doc.source)
93
- if bound
94
- table, binding_step, header_spans = bound
95
- header_binding = HeaderBinding.new(
96
- match_span: binding_step.match_span,
97
- param_spans: header_spans,
98
- step_def: binding_step.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
- table.rows.each do |row|
101
- row_object = {}
102
- table.header.cells.each_with_index do |cell_name, i|
103
- row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
104
- end
105
- row_step = PlannedStep.new(
106
- text: binding_step.text,
107
- match_span: row.span,
108
- param_spans: binding_step.param_spans,
109
- step_def: binding_step.step_def,
110
- args: binding_step.args + [row_object],
111
- formats: binding_step.formats
112
- )
113
- row_checks = table.header.cells.each_with_index.map do |cell_name, i|
114
- RowCheck.new(
115
- column: cell_name,
116
- value: i < row.cells.length ? row.cells[i] : '',
117
- span: i < row.cell_spans.length ? row.cell_spans[i] : row.span
118
- )
119
- end
120
- examples << PlannedExample.new(
121
- name: row.cells.join(' / '),
122
- scope_stack: ex.scope_stack + [binding_step.text],
123
- span: row.span,
124
- steps: [row_step],
125
- header_binding: header_binding,
126
- row_checks: row_checks
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
- next
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
- # Error fence detection.
133
- error_fence = ex.body.find { |b| b.kind == 'fence' && b.info == 'error' }
134
-
135
- # Pass 2: attach trailing table / fence to the last step of a block.
136
- attachments = {}
137
- (1...ex.body.length).each do |idx|
138
- here = ex.body[idx]
139
- if here.kind == 'table' && steps_by_block.key?(idx - 1)
140
- _prev_data, prev_doc = attachments[idx - 1] || [nil, nil]
141
- attachments[idx - 1] = [here, prev_doc]
142
- elsif here.kind == 'fence' && here.info != 'error' && steps_by_block.key?(idx - 1)
143
- prev_data, = attachments[idx - 1] || [nil, nil]
144
- attachments[idx - 1] = [
145
- prev_data,
146
- DocString.new(content: here.body, content_type: here.info, span: here.body_span)
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
- # Pass 3: rebuild the final step list, applying attachments.
152
- final_steps = []
153
- (0...ex.body.length).each do |idx|
154
- block_steps = steps_by_block[idx] || []
155
- attach = attachments[idx]
156
- block_steps.each_with_index do |step, s_idx|
157
- if s_idx == block_steps.length - 1 && attach
158
- data_table, doc_string = attach
159
- final_steps << PlannedStep.new(
160
- text: step.text, match_span: step.match_span, param_spans: step.param_spans,
161
- step_def: step.step_def, args: step.args, formats: step.formats,
162
- data_table: data_table, doc_string: doc_string
163
- )
164
- else
165
- final_steps << step
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
- runnable_steps = had_ambiguous ? [] : final_steps
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
- expected_outcome = nil
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
- 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
- ExecutionPlan.new(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
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)
@@ -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
- Registry.new(
25
- steps: [],
26
- parameter_types: Cucumber::CucumberExpressions::ParameterTypeRegistry.new,
27
- formats: {}
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 orphan
9
- # attachments. Port of structurer.ts.
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.6.1'
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.6.1
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
- Vár.
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