varar-core 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,17 +12,35 @@ module Varar
12
12
  module FailureAnchor
13
13
  module_function
14
14
 
15
+ # The anchor travels with the raised error, from the executor (which knows
16
+ # the step) to Failures.to_failure (which only sees the error) — the same
17
+ # job TS does with a global symbol on the Error. An instance variable on
18
+ # the exception, so it never shows up in `inspect` output the way an
19
+ # extra attribute would.
20
+ ANCHOR_IVAR = :@varar_failure_anchor
21
+
15
22
  def failure_anchor(error, fallback)
16
23
  case error
17
24
  when CellMismatchError
18
25
  failing = error.cells.find { |c| !c.ok }
19
26
  failing ? failing.span : fallback
20
- when DocStringMismatchError
21
- error.diff.span
22
27
  else
23
28
  fallback
24
29
  end
25
30
  end
31
+
32
+ # Record on the error itself where the failure points.
33
+ def attach_anchor(error, anchor)
34
+ error.instance_variable_set(ANCHOR_IVAR, anchor) if error.respond_to?(:instance_variable_set)
35
+ end
36
+
37
+ # The anchor the executor attached, or nil if there is none — then a
38
+ # renderer only has the failing line to go on.
39
+ def attached_anchor(error)
40
+ return nil unless error.respond_to?(:instance_variable_get)
41
+
42
+ error.instance_variable_get(ANCHOR_IVAR)
43
+ end
26
44
  end
27
45
  end
28
46
  end
@@ -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,
@@ -5,7 +5,7 @@ require 'varar/core/structurer'
5
5
 
6
6
  module Varar
7
7
  module Core
8
- # Parse +source+ into a VarDoc: scan blocks, then group into Examples.
8
+ # Parse +source+ into a Doc: scan blocks, then group into Examples.
9
9
  # Port of parse.ts.
10
10
  module Parse
11
11
  module_function
@@ -29,169 +29,246 @@ module Varar
29
29
  end
30
30
  end
31
31
 
32
- ExecutionPlan = Data.define(:var_doc, :examples, :diagnostics)
32
+ ExecutionPlan = Data.define(:doc, :examples, :diagnostics)
33
33
 
34
- # Produce an ExecutionPlan from a VarDoc + Registry: match step expressions
34
+ # Produce an ExecutionPlan from a Doc + Registry: match step expressions
35
35
  # against every text block, attach trailing tables/fences, detect
36
36
  # header-bound tables, and collect diagnostics. Port of plan.ts.
37
37
  module Plan
38
38
  BlockPlan = Data.define(:steps, :ambiguities)
39
39
  Ambiguity = Data.define(:match_start, :match_end, :candidates)
40
40
 
41
+ # A candidate paragraph, planned in isolation (Phase 1). Either a
42
+ # header-bound table (standalone rows) or a step-bearing candidate the
43
+ # grouping pass may merge into an open example.
44
+ HeaderBoundUnit = Data.define(:rows)
45
+ StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps,
46
+ :expected_outcome, :expected_error_message)
47
+
48
+ # An open, merging example being built up across adjacent matching
49
+ # candidates in Phase 2.
50
+ MergedExample = Struct.new(:name, :scope_stack, :start_offset, :end_offset, :steps,
51
+ :expected_outcome, :expected_error_message)
52
+
41
53
  module_function
42
54
 
43
- def plan(var_doc, registry)
44
- examples = []
55
+ def plan(doc, registry)
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 = doc.examples.map { |ex| plan_candidate(ex, doc, registry, diagnostics) }
60
+
61
+ # Phase 2: group adjacent candidates into examples. A matching candidate
62
+ # continues the open example when no delimiter (heading / `---`) precedes
63
+ # it; otherwise it starts a new one. A non-matching candidate (prose) is
64
+ # a delimiter: it closes the open example and is dropped. A header-bound
65
+ # table candidate is standalone — one example per row. See ADR 0012.
66
+ examples = []
67
+ open = nil
68
+ flush = lambda do
69
+ examples << finish_merged(open, doc.source) if open
70
+ open = nil
71
+ end
72
+ units.each do |unit|
73
+ if unit.is_a?(HeaderBoundUnit)
74
+ flush.call
75
+ examples.concat(unit.rows)
76
+ next
77
+ end
78
+ unless unit.matched
79
+ # Prose paragraph — a delimiter. Drop it and end the open example.
80
+ flush.call
81
+ next
82
+ end
83
+ if open && !unit.preceded_by_delimiter
84
+ merge_into(open, unit)
85
+ else
86
+ flush.call
87
+ open = start_merged(unit)
88
+ end
89
+ end
90
+ flush.call
91
+
92
+ ExecutionPlan.new(doc: doc, examples: examples, diagnostics: diagnostics)
93
+ end
94
+
95
+ def start_merged(unit)
96
+ MergedExample.new(unit.name, unit.scope_stack, unit.span.start_offset, unit.span.end_offset,
97
+ unit.steps.dup, unit.expected_outcome, unit.expected_error_message)
98
+ end
99
+
100
+ def merge_into(open, unit)
101
+ open.end_offset = unit.span.end_offset
102
+ open.steps.concat(unit.steps)
103
+ # Any error fence in a merged part marks the whole example
104
+ # expected-to-fail; keep the first message we see.
105
+ return unless unit.expected_outcome == 'fail'
106
+
107
+ open.expected_outcome = 'fail'
108
+ return unless open.expected_error_message.nil? && !unit.expected_error_message.nil?
109
+
110
+ open.expected_error_message = unit.expected_error_message
111
+ end
76
112
 
77
- next unless !had_ambiguous && !result.steps.empty?
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, doc, registry, diagnostics)
128
+ had_ambiguous = false
129
+ steps_by_block = {}
130
+
131
+ # Pass 1: plan each text-bearing block.
132
+ ex.body.each_with_index do |block, idx|
133
+ next unless %w[paragraph list_item blockquote].include?(block.kind)
134
+
135
+ result = plan_block(block.text, registry)
136
+
137
+ result.ambiguities.each do |collision|
138
+ span = lift_span(doc.source, block, collision.match_start, collision.match_end)
139
+ cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
140
+ cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
141
+ diagnostics << Diagnostics.ambiguous_match(
142
+ AmbiguousInput.new(
143
+ text: block.text[cp_start...cp_end],
144
+ span: span,
145
+ candidates: collision.candidates.map do |c|
146
+ Candidate.new(
147
+ expression: c.expression,
148
+ source_file: c.step_def.expression_source_file,
149
+ source_line: c.step_def.expression_source_line
150
+ )
151
+ end
87
152
  )
88
- 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(doc.source, block, hit.match_start, hit.match_end),
163
+ param_spans: hit.param_spans.map { |p| lift_span(doc.source, block, p.start, p.end) },
164
+ step_def: hit.step_def,
165
+ args: hit.args,
166
+ formats: hit.formats
99
167
  )
100
- 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, 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?
250
+ runnable_steps = had_ambiguous ? [] : final_steps
173
251
 
174
- next if final_steps.empty? && !had_ambiguous
252
+ diagnostics << Diagnostics.error_fence_without_step(error_fence.span) if error_fence && runnable_steps.empty?
175
253
 
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
183
-
184
- examples << PlannedExample.new(
185
- name: derive_example_name(ex.body),
186
- scope_stack: ex.scope_stack,
187
- span: ex.span,
188
- steps: runnable_steps,
189
- expected_outcome: expected_outcome,
190
- expected_error_message: expected_error_message
191
- )
254
+ expected_outcome = nil
255
+ expected_error_message = nil
256
+ if error_fence
257
+ expected_outcome = 'fail'
258
+ msg = error_fence.body.strip
259
+ expected_error_message = msg unless msg.empty?
192
260
  end
193
261
 
194
- 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 oaths match
43
+ # identically. Built-ins are NOT tracked as custom parameter types, so
44
+ # they never appear in the conformance registry.json projection.
45
+ def seed_builtins(registry)
46
+ define_parameter_type(
47
+ registry,
48
+ name: 'emph',
49
+ regexp: EMPH_REGEXP,
50
+ # Exactly one alternation branch matches, so exactly one group is set.
51
+ parse: ->(*groups) { groups.find { |g| !g.nil? } || '' },
52
+ # Emphasis is distinctive notation; don't auto-suggest it in snippets.
53
+ use_for_snippets: false,
54
+ # Mismatch display renders the value back in single-asterisk emphasis.
55
+ format: ->(value) { "*#{value}*" }
28
56
  )
29
57
  end
30
58
 
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Varar
4
+ module Core
5
+ # Run-result records — port of result.ts / result.rb's siblings in every
6
+ # other port. The persisted .varar/<oath_path>.json file is a serialized
7
+ # OathResults, read by the language server to place run diagnostics in the
8
+ # editor (ADR 0014).
9
+
10
+ # One mismatched CELL as a source-offset range plus the runtime value.
11
+ # `from`/`to` are absolute UTF-16 source offsets; `to` is exclusive.
12
+ CellFailure = Data.define(:from, :to, :actual)
13
+
14
+ # Where a failure points in the source: an offset range, `to` exclusive.
15
+ # The failing step's match span, or the first mismatched cell's span (the
16
+ # failure_anchor rule) — what lets a renderer underline the step that
17
+ # failed rather than the whole line it sits on.
18
+ AnchorRange = Data.define(:from, :to)
19
+
20
+ # The failure payload of a failed ExampleResult. `cells` and `anchor` are
21
+ # nil when they do not apply, and serialize as absent (not null), so a
22
+ # reader that predates them still parses the file. `stack` is deliberately
23
+ # runtime-shaped — no consumer parses it.
24
+ ExampleFailure = Data.define(:line, :message, :stack, :cells, :anchor) do
25
+ def initialize(line:, message:, stack:, cells: nil, anchor: nil)
26
+ super
27
+ end
28
+ end
29
+
30
+ # The run result for one BDD example. `lines` are the 1-based source lines
31
+ # of its steps (the editor's line-wash anchors).
32
+ ExampleResult = Data.define(:name, :status, :lines, :failure) do
33
+ def initialize(name:, status:, lines:, failure: nil)
34
+ super
35
+ end
36
+ end
37
+
38
+ # The persisted run result for one oath file. `oath_path` uses POSIX
39
+ # separators and is relative to the workspace root; `source_hash` is
40
+ # Hashing.hash_source over the oath as it was run, so a reader can tell
41
+ # whether the offsets still apply to the buffer in front of it.
42
+ OathResults = Data.define(:version, :oath_path, :source_hash, :examples)
43
+
44
+ # Projection of OathResults onto the JSON shape of .varar/<oath_path>.json.
45
+ #
46
+ # The wire format is the TypeScript one (ADR 0014): camelCase names,
47
+ # declaration order, and the optional members absent rather than null so a
48
+ # reader that predates them still parses the file. Pure — writing the file
49
+ # is the shell's job.
50
+ module Results
51
+ module_function
52
+
53
+ def to_wire(results)
54
+ {
55
+ 'version' => results.version,
56
+ 'oathPath' => results.oath_path,
57
+ 'sourceHash' => results.source_hash,
58
+ 'examples' => results.examples.map { |e| example_to_wire(e) }
59
+ }
60
+ end
61
+
62
+ def example_to_wire(example)
63
+ out = { 'name' => example.name, 'status' => example.status, 'lines' => example.lines.to_a }
64
+ out['failure'] = failure_to_wire(example.failure) if example.failure
65
+ out
66
+ end
67
+
68
+ def failure_to_wire(failure)
69
+ out = { 'line' => failure.line, 'message' => failure.message, 'stack' => failure.stack }
70
+ if failure.cells && !failure.cells.empty?
71
+ out['cells'] = failure.cells.map { |c| { 'from' => c.from, 'to' => c.to, 'actual' => c.actual } }
72
+ end
73
+ out['anchor'] = { 'from' => failure.anchor.from, 'to' => failure.anchor.to } if failure.anchor
74
+ out
75
+ end
76
+ end
77
+ end
78
+ end
@@ -5,8 +5,12 @@ require 'varar/core/ast'
5
5
 
6
6
  module Varar
7
7
  module Core
8
- # Group scanned blocks into Examples, tracking heading scope and 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,10 +62,11 @@ module Varar
69
62
 
70
63
  when 'thematic_break'
71
64
  attachment_open = false
65
+ delimiter_pending = true
72
66
  end
73
67
  end
74
68
 
75
- VarDoc.new(
69
+ Doc.new(
76
70
  path: path,
77
71
  source: source,
78
72
  examples: examples,