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,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oselvar/var/core/span'
4
+ require 'oselvar/var/core/ast'
5
+ require 'oselvar/var/core/cell_diff'
6
+ require 'oselvar/var/core/diagnostics'
7
+ require 'oselvar/var/core/matcher'
8
+ require 'oselvar/var/core/sentences'
9
+
10
+ module Oselvar
11
+ module Var
12
+ module Core
13
+ DocString = Data.define(:content, :content_type, :span)
14
+
15
+ PlannedStep = Data.define(:text, :match_span, :param_spans, :step_def, :args, :formats, :data_table,
16
+ :doc_string) do
17
+ def initialize(text:, match_span:, param_spans:, step_def:, args:, formats: [], data_table: nil,
18
+ doc_string: nil)
19
+ super
20
+ end
21
+ end
22
+
23
+ HeaderBinding = Data.define(:match_span, :param_spans, :step_def)
24
+
25
+ PlannedExample = Data.define(:name, :scope_stack, :span, :steps, :header_binding, :row_checks,
26
+ :expected_outcome, :expected_error_message) do
27
+ def initialize(name:, scope_stack:, span:, steps:, header_binding: nil, row_checks: nil,
28
+ expected_outcome: nil, expected_error_message: nil)
29
+ super
30
+ end
31
+ end
32
+
33
+ ExecutionPlan = Data.define(:var_doc, :examples, :diagnostics)
34
+
35
+ # Produce an ExecutionPlan from a VarDoc + Registry: match step expressions
36
+ # against every text block, attach trailing tables/fences, detect
37
+ # header-bound tables, and collect diagnostics. Port of plan.ts.
38
+ module Plan
39
+ BlockPlan = Data.define(:steps, :ambiguities)
40
+ Ambiguity = Data.define(:match_start, :match_end, :candidates)
41
+
42
+ module_function
43
+
44
+ def plan(var_doc, registry)
45
+ examples = []
46
+ diagnostics = []
47
+
48
+ var_doc.examples.each do |ex|
49
+ had_ambiguous = false
50
+ steps_by_block = {}
51
+
52
+ # Pass 1: plan each text-bearing block.
53
+ ex.body.each_with_index do |block, idx|
54
+ next unless %w[paragraph list_item blockquote].include?(block.kind)
55
+
56
+ result = plan_block(block.text, registry)
57
+
58
+ result.ambiguities.each do |collision|
59
+ span = lift_span(var_doc.source, block, collision.match_start, collision.match_end)
60
+ cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
61
+ cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
62
+ diagnostics << Diagnostics.ambiguous_match(
63
+ AmbiguousInput.new(
64
+ text: block.text[cp_start...cp_end],
65
+ span: span,
66
+ candidates: collision.candidates.map do |c|
67
+ Candidate.new(
68
+ expression: c.expression,
69
+ source_file: c.step_def.expression_source_file,
70
+ source_line: c.step_def.expression_source_line
71
+ )
72
+ end
73
+ )
74
+ )
75
+ had_ambiguous = true
76
+ end
77
+
78
+ next unless !had_ambiguous && !result.steps.empty?
79
+
80
+ steps_by_block[idx] = result.steps.map do |hit|
81
+ PlannedStep.new(
82
+ text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
83
+ match_span: lift_span(var_doc.source, block, hit.match_start, hit.match_end),
84
+ param_spans: hit.param_spans.map { |p| lift_span(var_doc.source, block, p.start, p.end) },
85
+ step_def: hit.step_def,
86
+ args: hit.args,
87
+ formats: hit.formats
88
+ )
89
+ end
90
+ end
91
+
92
+ # Header-bound table detection.
93
+ bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, var_doc.source)
94
+ if bound
95
+ table, binding_step, header_spans = bound
96
+ header_binding = HeaderBinding.new(
97
+ match_span: binding_step.match_span,
98
+ param_spans: header_spans,
99
+ step_def: binding_step.step_def
100
+ )
101
+ table.rows.each do |row|
102
+ row_object = {}
103
+ table.header.cells.each_with_index do |cell_name, i|
104
+ row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
105
+ end
106
+ row_step = PlannedStep.new(
107
+ text: binding_step.text,
108
+ match_span: row.span,
109
+ param_spans: binding_step.param_spans,
110
+ step_def: binding_step.step_def,
111
+ args: binding_step.args + [row_object],
112
+ formats: binding_step.formats
113
+ )
114
+ row_checks = table.header.cells.each_with_index.map do |cell_name, i|
115
+ RowCheck.new(
116
+ column: cell_name,
117
+ value: i < row.cells.length ? row.cells[i] : '',
118
+ span: i < row.cell_spans.length ? row.cell_spans[i] : row.span
119
+ )
120
+ end
121
+ examples << PlannedExample.new(
122
+ name: row.cells.join(' / '),
123
+ scope_stack: ex.scope_stack + [binding_step.text],
124
+ span: row.span,
125
+ steps: [row_step],
126
+ header_binding: header_binding,
127
+ row_checks: row_checks
128
+ )
129
+ end
130
+ next
131
+ end
132
+
133
+ # Error fence detection.
134
+ error_fence = ex.body.find { |b| b.kind == 'fence' && b.info == 'error' }
135
+
136
+ # Pass 2: attach trailing table / fence to the last step of a block.
137
+ attachments = {}
138
+ (1...ex.body.length).each do |idx|
139
+ here = ex.body[idx]
140
+ if here.kind == 'table' && steps_by_block.key?(idx - 1)
141
+ _prev_data, prev_doc = attachments[idx - 1] || [nil, nil]
142
+ attachments[idx - 1] = [here, prev_doc]
143
+ elsif here.kind == 'fence' && here.info != 'error' && steps_by_block.key?(idx - 1)
144
+ prev_data, = attachments[idx - 1] || [nil, nil]
145
+ attachments[idx - 1] = [
146
+ prev_data,
147
+ DocString.new(content: here.body, content_type: here.info, span: here.body_span)
148
+ ]
149
+ end
150
+ end
151
+
152
+ # Pass 3: rebuild the final step list, applying attachments.
153
+ final_steps = []
154
+ (0...ex.body.length).each do |idx|
155
+ block_steps = steps_by_block[idx] || []
156
+ attach = attachments[idx]
157
+ block_steps.each_with_index do |step, s_idx|
158
+ if s_idx == block_steps.length - 1 && attach
159
+ data_table, doc_string = attach
160
+ final_steps << PlannedStep.new(
161
+ text: step.text, match_span: step.match_span, param_spans: step.param_spans,
162
+ step_def: step.step_def, args: step.args, formats: step.formats,
163
+ data_table: data_table, doc_string: doc_string
164
+ )
165
+ else
166
+ final_steps << step
167
+ end
168
+ end
169
+ end
170
+
171
+ runnable_steps = had_ambiguous ? [] : final_steps
172
+
173
+ if error_fence && runnable_steps.empty?
174
+ diagnostics << Diagnostics.error_fence_without_step(error_fence.span)
175
+ end
176
+
177
+ next if final_steps.empty? && !had_ambiguous
178
+
179
+ expected_outcome = nil
180
+ expected_error_message = nil
181
+ if error_fence
182
+ expected_outcome = 'fail'
183
+ msg = error_fence.body.strip
184
+ expected_error_message = msg unless msg.empty?
185
+ end
186
+
187
+ examples << PlannedExample.new(
188
+ name: derive_example_name(ex.body),
189
+ scope_stack: ex.scope_stack,
190
+ span: ex.span,
191
+ steps: runnable_steps,
192
+ expected_outcome: expected_outcome,
193
+ expected_error_message: expected_error_message
194
+ )
195
+ end
196
+
197
+ ExecutionPlan.new(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
198
+ end
199
+
200
+ def plan_block(text, registry)
201
+ all_steps = []
202
+ all_ambiguities = []
203
+
204
+ Sentences.split_sentences(text).each do |sentence|
205
+ hits = Matcher.find_hits(sentence.text, registry)
206
+ adjusted = hits.map do |h|
207
+ Hit.new(
208
+ expression: h.expression,
209
+ step_def: h.step_def,
210
+ match_start: h.match_start + sentence.start_offset,
211
+ match_end: h.match_end + sentence.start_offset,
212
+ args: h.args,
213
+ param_spans: h.param_spans.map do |p|
214
+ ParamSpan.new(start: p.start + sentence.start_offset, end: p.end + sentence.start_offset)
215
+ end,
216
+ formats: h.formats
217
+ )
218
+ end
219
+ resolved = Matcher.resolve_hits(adjusted)
220
+ if resolved.kind == 'ambiguous'
221
+ resolved.collisions.each do |c|
222
+ all_ambiguities << Ambiguity.new(match_start: c.match_start, match_end: c.match_end,
223
+ candidates: c.candidates)
224
+ end
225
+ elsif !resolved.steps.empty?
226
+ all_steps.concat(resolved.steps)
227
+ end
228
+ end
229
+
230
+ BlockPlan.new(steps: all_steps, ambiguities: all_ambiguities)
231
+ end
232
+
233
+ # Whole-word, case-sensitive start index of +word+ in +haystack+, or nil.
234
+ def word_offset(haystack, word)
235
+ m = /(?<![^\W_])#{Regexp.escape(word)}(?![^\W_])/.match(haystack)
236
+ m&.begin(0)
237
+ end
238
+
239
+ def detect_header_bound(ex, steps_by_block, source)
240
+ body = ex.body
241
+ (1...body.length).each do |idx|
242
+ here = body[idx]
243
+ next unless here.kind == 'table'
244
+
245
+ above = body[idx - 1]
246
+ next unless %w[paragraph list_item blockquote].include?(above.kind)
247
+
248
+ steps = steps_by_block[idx - 1]
249
+ next if steps.nil? || steps.empty?
250
+
251
+ header_cells = here.header.cells
252
+ offsets = header_cells.map { |cell| word_offset(above.text, cell) }
253
+ next if offsets.any?(&:nil?)
254
+
255
+ utf16_offsets = offsets.map { |o| Offsets.to_utf16_offset(above.text, o) }
256
+ header_spans = header_cells.each_index.map do |i|
257
+ lift_span(source, above, utf16_offsets[i], utf16_offsets[i] + Offsets.utf16_len(header_cells[i]))
258
+ end
259
+ return [here, steps.last, header_spans]
260
+ end
261
+ nil
262
+ end
263
+
264
+ def derive_example_name(body)
265
+ primary = body.find { |b| %w[paragraph list_item blockquote].include?(b.kind) }
266
+ return '' if primary.nil?
267
+
268
+ name = primary.text.gsub(/\s+/, ' ').strip
269
+ name.sub(/[.!?]$/, '')
270
+ end
271
+
272
+ def lift_segment_offset(segment_map, text_offset)
273
+ best = segment_map.first
274
+ segment_map.each { |entry| best = entry if entry.text_offset <= text_offset }
275
+ raise 'empty segment_map' if best.nil?
276
+
277
+ best.source_offset + (text_offset - best.text_offset)
278
+ end
279
+
280
+ def lift_span(source, block, block_start, block_end)
281
+ return block.span unless %w[paragraph list_item blockquote].include?(block.kind)
282
+
283
+ start_src = lift_segment_offset(block.segment_map, block_start)
284
+ end_src = lift_segment_offset(block.segment_map, block_end)
285
+ Offsets.span_from_offsets(source, start_src, end_src)
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cucumber/cucumber_expressions/cucumber_expression'
4
+ require 'cucumber/cucumber_expressions/parameter_type'
5
+ require 'cucumber/cucumber_expressions/parameter_type_registry'
6
+
7
+ module Oselvar
8
+ module Var
9
+ module Core
10
+ # One registered step definition. `compiled` is the CucumberExpression.
11
+ StepRegistration = Data.define(
12
+ :expression, :expression_source_file, :expression_source_line,
13
+ :handler, :compiled, :kind
14
+ )
15
+
16
+ # A registry of step definitions plus the shared cucumber ParameterTypeRegistry
17
+ # and per-type display formatters (kept beside it because ParameterType
18
+ # can't carry one). Port of registry.ts.
19
+ Registry = Data.define(:steps, :parameter_types, :formats)
20
+
21
+ module Registries
22
+ module_function
23
+
24
+ def create_registry
25
+ Registry.new(
26
+ steps: [],
27
+ parameter_types: Cucumber::CucumberExpressions::ParameterTypeRegistry.new,
28
+ formats: {}
29
+ )
30
+ end
31
+
32
+ # Compile +expression+ and append it. Returns a new Registry (the
33
+ # ParameterTypeRegistry is shared by reference but never mutated here).
34
+ # Raises on duplicate expressions, mirroring the TS message.
35
+ def add_step(registry, expression:, expression_source_file:, expression_source_line:, handler:, kind: nil)
36
+ duplicate = registry.steps.find { |s| s.expression == expression }
37
+ if duplicate
38
+ raise "duplicate step definition for \"#{expression}\" at " \
39
+ "#{duplicate.expression_source_file}:#{duplicate.expression_source_line} and " \
40
+ "#{expression_source_file}:#{expression_source_line}"
41
+ end
42
+
43
+ compiled = Cucumber::CucumberExpressions::CucumberExpression.new(expression, registry.parameter_types)
44
+ reg = StepRegistration.new(
45
+ expression: expression,
46
+ expression_source_file: expression_source_file,
47
+ expression_source_line: expression_source_line,
48
+ handler: handler,
49
+ compiled: compiled,
50
+ kind: kind
51
+ )
52
+ registry.with(steps: registry.steps + [reg])
53
+ end
54
+
55
+ # Register a custom parameter type with the shared ParameterTypeRegistry
56
+ # (mutated in place, same as TS, so previously compiled expressions gain
57
+ # the new type). Returns the same Registry unless a +format+ is given.
58
+ def define_parameter_type(registry, name:, regexp:, parse: nil, use_for_snippets: true,
59
+ prefer_for_regexp_match: false, format: nil)
60
+ regexps = regexp.is_a?(Array) ? regexp : [regexp]
61
+ transformer = parse || ->(*groups) { groups[0] }
62
+ # `type` is return-type metadata only (used by snippet generation, never
63
+ # by matching, transformation, or any conformance artifact). The Ruby
64
+ # gem rejects a nil type (Python's accepts None), so pass Object.
65
+ pt = Cucumber::CucumberExpressions::ParameterType.new(
66
+ name, regexps, Object, transformer, use_for_snippets, prefer_for_regexp_match
67
+ )
68
+ registry.parameter_types.define_parameter_type(pt)
69
+ return registry if format.nil?
70
+
71
+ registry.with(formats: registry.formats.merge(name => format))
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end