Module: Oselvar::Var::Core::Plan

Defined in:
lib/oselvar/var/core/plan.rb

Overview

Produce an ExecutionPlan from a VarDoc + Registry: match step expressions against every text block, attach trailing tables/fences, detect header-bound tables, and collect diagnostics. Port of plan.ts.

Defined Under Namespace

Classes: Ambiguity, BlockPlan

Class Method Summary collapse

Class Method Details

.derive_example_name(body) ⇒ Object



264
265
266
267
268
269
270
# File 'lib/oselvar/var/core/plan.rb', line 264

def derive_example_name(body)
  primary = body.find { |b| %w[paragraph list_item blockquote].include?(b.kind) }
  return '' if primary.nil?

  name = primary.text.gsub(/\s+/, ' ').strip
  name.sub(/[.!?]$/, '')
end

.detect_header_bound(ex, steps_by_block, source) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/oselvar/var/core/plan.rb', line 239

def detect_header_bound(ex, steps_by_block, source)
  body = ex.body
  (1...body.length).each do |idx|
    here = body[idx]
    next unless here.kind == 'table'

    above = body[idx - 1]
    next unless %w[paragraph list_item blockquote].include?(above.kind)

    steps = steps_by_block[idx - 1]
    next if steps.nil? || steps.empty?

    header_cells = here.header.cells
    offsets = header_cells.map { |cell| word_offset(above.text, cell) }
    next if offsets.any?(&:nil?)

    utf16_offsets = offsets.map { |o| Offsets.to_utf16_offset(above.text, o) }
    header_spans = header_cells.each_index.map do |i|
      lift_span(source, above, utf16_offsets[i], utf16_offsets[i] + Offsets.utf16_len(header_cells[i]))
    end
    return [here, steps.last, header_spans]
  end
  nil
end

.lift_segment_offset(segment_map, text_offset) ⇒ Object



272
273
274
275
276
277
278
# File 'lib/oselvar/var/core/plan.rb', line 272

def lift_segment_offset(segment_map, text_offset)
  best = segment_map.first
  segment_map.each { |entry| best = entry if entry.text_offset <= text_offset }
  raise 'empty segment_map' if best.nil?

  best.source_offset + (text_offset - best.text_offset)
end

.lift_span(source, block, block_start, block_end) ⇒ Object



280
281
282
283
284
285
286
# File 'lib/oselvar/var/core/plan.rb', line 280

def lift_span(source, block, block_start, block_end)
  return block.span unless %w[paragraph list_item blockquote].include?(block.kind)

  start_src = lift_segment_offset(block.segment_map, block_start)
  end_src = lift_segment_offset(block.segment_map, block_end)
  Offsets.span_from_offsets(source, start_src, end_src)
end

.plan(var_doc, registry) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/oselvar/var/core/plan.rb', line 44

def plan(var_doc, registry)
  examples = []
  diagnostics = []

  var_doc.examples.each do |ex|
    had_ambiguous = false
    steps_by_block = {}

    # Pass 1: plan each text-bearing block.
    ex.body.each_with_index do |block, idx|
      next unless %w[paragraph list_item blockquote].include?(block.kind)

      result = plan_block(block.text, registry)

      result.ambiguities.each do |collision|
        span = lift_span(var_doc.source, block, collision.match_start, collision.match_end)
        cp_start = Offsets.cp_index_for_utf16(block.text, collision.match_start)
        cp_end = Offsets.cp_index_for_utf16(block.text, collision.match_end)
        diagnostics << Diagnostics.ambiguous_match(
          AmbiguousInput.new(
            text: block.text[cp_start...cp_end],
            span: span,
            candidates: collision.candidates.map do |c|
              Candidate.new(
                expression: c.expression,
                source_file: c.step_def.expression_source_file,
                source_line: c.step_def.expression_source_line
              )
            end
          )
        )
        had_ambiguous = true
      end

      next unless !had_ambiguous && !result.steps.empty?

      steps_by_block[idx] = result.steps.map do |hit|
        PlannedStep.new(
          text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
          match_span: lift_span(var_doc.source, block, hit.match_start, hit.match_end),
          param_spans: hit.param_spans.map { |p| lift_span(var_doc.source, block, p.start, p.end) },
          step_def: hit.step_def,
          args: hit.args,
          formats: hit.formats
        )
      end
    end

    # Header-bound table detection.
    bound = had_ambiguous ? nil : detect_header_bound(ex, steps_by_block, var_doc.source)
    if bound
      table, binding_step, header_spans = bound
      header_binding = HeaderBinding.new(
        match_span: binding_step.match_span,
        param_spans: header_spans,
        step_def: binding_step.step_def
      )
      table.rows.each do |row|
        row_object = {}
        table.header.cells.each_with_index do |cell_name, i|
          row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
        end
        row_step = PlannedStep.new(
          text: binding_step.text,
          match_span: row.span,
          param_spans: binding_step.param_spans,
          step_def: binding_step.step_def,
          args: binding_step.args + [row_object],
          formats: binding_step.formats
        )
        row_checks = table.header.cells.each_with_index.map do |cell_name, i|
          RowCheck.new(
            column: cell_name,
            value: i < row.cells.length ? row.cells[i] : '',
            span: i < row.cell_spans.length ? row.cell_spans[i] : row.span
          )
        end
        examples << PlannedExample.new(
          name: row.cells.join(' / '),
          scope_stack: ex.scope_stack + [binding_step.text],
          span: row.span,
          steps: [row_step],
          header_binding: header_binding,
          row_checks: row_checks
        )
      end
      next
    end

    # Error fence detection.
    error_fence = ex.body.find { |b| b.kind == 'fence' && b.info == 'error' }

    # Pass 2: attach trailing table / fence to the last step of a block.
    attachments = {}
    (1...ex.body.length).each do |idx|
      here = ex.body[idx]
      if here.kind == 'table' && steps_by_block.key?(idx - 1)
        _prev_data, prev_doc = attachments[idx - 1] || [nil, nil]
        attachments[idx - 1] = [here, prev_doc]
      elsif here.kind == 'fence' && here.info != 'error' && steps_by_block.key?(idx - 1)
        prev_data, = attachments[idx - 1] || [nil, nil]
        attachments[idx - 1] = [
          prev_data,
          DocString.new(content: here.body, content_type: here.info, span: here.body_span)
        ]
      end
    end

    # Pass 3: rebuild the final step list, applying attachments.
    final_steps = []
    (0...ex.body.length).each do |idx|
      block_steps = steps_by_block[idx] || []
      attach = attachments[idx]
      block_steps.each_with_index do |step, s_idx|
        if s_idx == block_steps.length - 1 && attach
          data_table, doc_string = attach
          final_steps << PlannedStep.new(
            text: step.text, match_span: step.match_span, param_spans: step.param_spans,
            step_def: step.step_def, args: step.args, formats: step.formats,
            data_table: data_table, doc_string: doc_string
          )
        else
          final_steps << step
        end
      end
    end

    runnable_steps = had_ambiguous ? [] : final_steps

    if error_fence && runnable_steps.empty?
      diagnostics << Diagnostics.error_fence_without_step(error_fence.span)
    end

    next if final_steps.empty? && !had_ambiguous

    expected_outcome = nil
    expected_error_message = nil
    if error_fence
      expected_outcome = 'fail'
      msg = error_fence.body.strip
      expected_error_message = msg unless msg.empty?
    end

    examples << PlannedExample.new(
      name: derive_example_name(ex.body),
      scope_stack: ex.scope_stack,
      span: ex.span,
      steps: runnable_steps,
      expected_outcome: expected_outcome,
      expected_error_message: expected_error_message
    )
  end

  ExecutionPlan.new(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
end

.plan_block(text, registry) ⇒ Object



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/oselvar/var/core/plan.rb', line 200

def plan_block(text, registry)
  all_steps = []
  all_ambiguities = []

  Sentences.split_sentences(text).each do |sentence|
    hits = Matcher.find_hits(sentence.text, registry)
    adjusted = hits.map do |h|
      Hit.new(
        expression: h.expression,
        step_def: h.step_def,
        match_start: h.match_start + sentence.start_offset,
        match_end: h.match_end + sentence.start_offset,
        args: h.args,
        param_spans: h.param_spans.map do |p|
          ParamSpan.new(start: p.start + sentence.start_offset, end: p.end + sentence.start_offset)
        end,
        formats: h.formats
      )
    end
    resolved = Matcher.resolve_hits(adjusted)
    if resolved.kind == 'ambiguous'
      resolved.collisions.each do |c|
        all_ambiguities << Ambiguity.new(match_start: c.match_start, match_end: c.match_end,
                                         candidates: c.candidates)
      end
    elsif !resolved.steps.empty?
      all_steps.concat(resolved.steps)
    end
  end

  BlockPlan.new(steps: all_steps, ambiguities: all_ambiguities)
end

.word_offset(haystack, word) ⇒ Object

Whole-word, case-sensitive start index of word in haystack, or nil.



234
235
236
237
# File 'lib/oselvar/var/core/plan.rb', line 234

def word_offset(haystack, word)
  m = /(?<![^\W_])#{Regexp.escape(word)}(?![^\W_])/.match(haystack)
  m&.begin(0)
end