varar-core 0.5.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.
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+ require 'varar/core/ast'
5
+ require 'varar/core/cell_diff'
6
+ require 'varar/core/diagnostics'
7
+ require 'varar/core/matcher'
8
+ require 'varar/core/sentences'
9
+
10
+ module Varar
11
+ module Core
12
+ DocString = Data.define(:content, :content_type, :span)
13
+
14
+ PlannedStep = Data.define(:text, :match_span, :param_spans, :step_def, :args, :formats, :data_table,
15
+ :doc_string) do
16
+ def initialize(text:, match_span:, param_spans:, step_def:, args:, formats: [], data_table: nil,
17
+ doc_string: nil)
18
+ super
19
+ end
20
+ end
21
+
22
+ HeaderBinding = Data.define(:match_span, :param_spans, :step_def)
23
+
24
+ PlannedExample = Data.define(:name, :scope_stack, :span, :steps, :header_binding, :row_checks,
25
+ :expected_outcome, :expected_error_message) do
26
+ def initialize(name:, scope_stack:, span:, steps:, header_binding: nil, row_checks: nil,
27
+ expected_outcome: nil, expected_error_message: nil)
28
+ super
29
+ end
30
+ end
31
+
32
+ ExecutionPlan = Data.define(:var_doc, :examples, :diagnostics)
33
+
34
+ # Produce an ExecutionPlan from a VarDoc + Registry: match step expressions
35
+ # against every text block, attach trailing tables/fences, detect
36
+ # header-bound tables, and collect diagnostics. Port of plan.ts.
37
+ module Plan
38
+ BlockPlan = Data.define(:steps, :ambiguities)
39
+ Ambiguity = Data.define(:match_start, :match_end, :candidates)
40
+
41
+ module_function
42
+
43
+ def plan(var_doc, registry)
44
+ examples = []
45
+ diagnostics = []
46
+
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
76
+
77
+ next unless !had_ambiguous && !result.steps.empty?
78
+
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
87
+ )
88
+ end
89
+ end
90
+
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
99
+ )
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
127
+ )
128
+ end
129
+ next
130
+ end
131
+
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
149
+ end
150
+
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
167
+ end
168
+ end
169
+
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
175
+
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
+ )
192
+ end
193
+
194
+ ExecutionPlan.new(var_doc: var_doc, examples: examples, diagnostics: diagnostics)
195
+ end
196
+
197
+ def plan_block(text, registry)
198
+ all_steps = []
199
+ all_ambiguities = []
200
+
201
+ Sentences.split_sentences(text).each do |sentence|
202
+ hits = Matcher.find_hits(sentence.text, registry)
203
+ adjusted = hits.map do |h|
204
+ Hit.new(
205
+ expression: h.expression,
206
+ step_def: h.step_def,
207
+ match_start: h.match_start + sentence.start_offset,
208
+ match_end: h.match_end + sentence.start_offset,
209
+ args: h.args,
210
+ param_spans: h.param_spans.map do |p|
211
+ ParamSpan.new(start: p.start + sentence.start_offset, end: p.end + sentence.start_offset)
212
+ end,
213
+ formats: h.formats
214
+ )
215
+ end
216
+ resolved = Matcher.resolve_hits(adjusted)
217
+ if resolved.kind == 'ambiguous'
218
+ resolved.collisions.each do |c|
219
+ all_ambiguities << Ambiguity.new(match_start: c.match_start, match_end: c.match_end,
220
+ candidates: c.candidates)
221
+ end
222
+ elsif !resolved.steps.empty?
223
+ all_steps.concat(resolved.steps)
224
+ end
225
+ end
226
+
227
+ BlockPlan.new(steps: all_steps, ambiguities: all_ambiguities)
228
+ end
229
+
230
+ # Whole-word, case-sensitive start index of +word+ in +haystack+, or nil.
231
+ def word_offset(haystack, word)
232
+ m = /(?<![^\W_])#{Regexp.escape(word)}(?![^\W_])/.match(haystack)
233
+ m&.begin(0)
234
+ end
235
+
236
+ def detect_header_bound(ex, steps_by_block, source)
237
+ body = ex.body
238
+ (1...body.length).each do |idx|
239
+ here = body[idx]
240
+ next unless here.kind == 'table'
241
+
242
+ above = body[idx - 1]
243
+ next unless %w[paragraph list_item blockquote].include?(above.kind)
244
+
245
+ steps = steps_by_block[idx - 1]
246
+ next if steps.nil? || steps.empty?
247
+
248
+ header_cells = here.header.cells
249
+ offsets = header_cells.map { |cell| word_offset(above.text, cell) }
250
+ next if offsets.any?(&:nil?)
251
+
252
+ utf16_offsets = offsets.map { |o| Offsets.to_utf16_offset(above.text, o) }
253
+ header_spans = header_cells.each_index.map do |i|
254
+ lift_span(source, above, utf16_offsets[i], utf16_offsets[i] + Offsets.utf16_len(header_cells[i]))
255
+ end
256
+ return [here, steps.last, header_spans]
257
+ end
258
+ nil
259
+ end
260
+
261
+ def derive_example_name(body)
262
+ primary = body.find { |b| %w[paragraph list_item blockquote].include?(b.kind) }
263
+ return '' if primary.nil?
264
+
265
+ name = primary.text.gsub(/\s+/, ' ').strip
266
+ name.sub(/[.!?]$/, '')
267
+ end
268
+
269
+ def lift_segment_offset(segment_map, text_offset)
270
+ best = segment_map.first
271
+ segment_map.each { |entry| best = entry if entry.text_offset <= text_offset }
272
+ raise 'empty segment_map' if best.nil?
273
+
274
+ best.source_offset + (text_offset - best.text_offset)
275
+ end
276
+
277
+ def lift_span(source, block, block_start, block_end)
278
+ return block.span unless %w[paragraph list_item blockquote].include?(block.kind)
279
+
280
+ start_src = lift_segment_offset(block.segment_map, block_start)
281
+ end_src = lift_segment_offset(block.segment_map, block_end)
282
+ Offsets.span_from_offsets(source, start_src, end_src)
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,74 @@
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 Varar
8
+ module Core
9
+ # One registered step definition. `compiled` is the CucumberExpression.
10
+ StepRegistration = Data.define(
11
+ :expression, :expression_source_file, :expression_source_line,
12
+ :handler, :compiled, :kind
13
+ )
14
+
15
+ # A registry of step definitions plus the shared cucumber ParameterTypeRegistry
16
+ # and per-type display formatters (kept beside it because ParameterType
17
+ # can't carry one). Port of registry.ts.
18
+ Registry = Data.define(:steps, :parameter_types, :formats)
19
+
20
+ module Registries
21
+ module_function
22
+
23
+ def create_registry
24
+ Registry.new(
25
+ steps: [],
26
+ parameter_types: Cucumber::CucumberExpressions::ParameterTypeRegistry.new,
27
+ formats: {}
28
+ )
29
+ end
30
+
31
+ # Compile +expression+ and append it. Returns a new Registry (the
32
+ # ParameterTypeRegistry is shared by reference but never mutated here).
33
+ # Raises on duplicate expressions, mirroring the TS message.
34
+ def add_step(registry, expression:, expression_source_file:, expression_source_line:, handler:, kind: nil)
35
+ duplicate = registry.steps.find { |s| s.expression == expression }
36
+ if duplicate
37
+ raise "duplicate step definition for \"#{expression}\" at " \
38
+ "#{duplicate.expression_source_file}:#{duplicate.expression_source_line} and " \
39
+ "#{expression_source_file}:#{expression_source_line}"
40
+ end
41
+
42
+ compiled = Cucumber::CucumberExpressions::CucumberExpression.new(expression, registry.parameter_types)
43
+ reg = StepRegistration.new(
44
+ expression: expression,
45
+ expression_source_file: expression_source_file,
46
+ expression_source_line: expression_source_line,
47
+ handler: handler,
48
+ compiled: compiled,
49
+ kind: kind
50
+ )
51
+ registry.with(steps: registry.steps + [reg])
52
+ end
53
+
54
+ # Register a custom parameter type with the shared ParameterTypeRegistry
55
+ # (mutated in place, same as TS, so previously compiled expressions gain
56
+ # the new type). Returns the same Registry unless a +format+ is given.
57
+ def define_parameter_type(registry, name:, regexp:, parse: nil, use_for_snippets: true,
58
+ prefer_for_regexp_match: false, format: nil)
59
+ regexps = regexp.is_a?(Array) ? regexp : [regexp]
60
+ transformer = parse || ->(*groups) { groups[0] }
61
+ # `type` is return-type metadata only (used by snippet generation, never
62
+ # by matching, transformation, or any conformance artifact). The Ruby
63
+ # gem rejects a nil type (Python's accepts None), so pass Object.
64
+ pt = Cucumber::CucumberExpressions::ParameterType.new(
65
+ name, regexps, Object, transformer, use_for_snippets, prefer_for_regexp_match
66
+ )
67
+ registry.parameter_types.define_parameter_type(pt)
68
+ return registry if format.nil?
69
+
70
+ registry.with(formats: registry.formats.merge(name => format))
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'varar/core/span'
4
+ require 'varar/core/ast'
5
+ require 'varar/core/table_cells'
6
+
7
+ module Varar
8
+ module Core
9
+ # Markdown block scanner. Port of scanner.ts. All offsets count UTF-16
10
+ # code units; split_lines advances by utf16_len(line) + 1 per newline, and
11
+ # code-point indices from String#index are converted to UTF-16 before use.
12
+ module Scanner
13
+ RawLine = Data.define(:text, :start_offset, :end_offset)
14
+
15
+ # Regexes — verbatim ports of the TS constants (`#` escaped as `\#` so
16
+ # Ruby does not read `#{...}` as interpolation).
17
+ THEMATIC_RE = /^\s*([-*_])(\s*\1){2,}\s*$/
18
+ UL_RE = /^(\s*)([-*+])\s+(.*)$/
19
+ OL_RE = /^(\s*)(\d+)([.)])\s+(.*)$/
20
+ BQ_RE = /^>\s?(.*)$/
21
+ FENCE_RE = /^(`{3,})\s*(\S*)\s*$/
22
+ ROW_RE = /^\|(.+)\|\s*$/
23
+ DELIM_RE = /^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|\s*$/
24
+ HEADING_RE = /^(\#{1,6})\s+(.*?)(?:\s+\#+)?\s*$/
25
+ PARA_HEADING_RE = /^\#{1,6}\s+/
26
+
27
+ module_function
28
+
29
+ # Scan +source+ into an immutable Array of Block nodes.
30
+ def scan(source, plugins = [])
31
+ blocks = []
32
+ lines = split_lines(source)
33
+
34
+ i = 0
35
+ while i < lines.length
36
+ line = lines[i]
37
+ if line.text.strip.empty?
38
+ i += 1
39
+ next
40
+ end
41
+
42
+ matched = run_plugins(source, lines, i, plugins)
43
+ if matched
44
+ blocks << matched[0]
45
+ i = matched[1]
46
+ next
47
+ end
48
+
49
+ fence_result = try_fence(source, lines, i)
50
+ if fence_result
51
+ blocks << fence_result[0]
52
+ i = fence_result[1]
53
+ next
54
+ end
55
+
56
+ table_result = try_table(source, lines, i)
57
+ if table_result
58
+ blocks << table_result[0]
59
+ i = table_result[1]
60
+ next
61
+ end
62
+
63
+ thematic = try_thematic(source, line)
64
+ if thematic
65
+ blocks << thematic
66
+ i += 1
67
+ next
68
+ end
69
+
70
+ bq_result = try_blockquote(source, lines, i)
71
+ if bq_result
72
+ blocks << bq_result[0]
73
+ i = bq_result[1]
74
+ next
75
+ end
76
+
77
+ heading = try_heading(source, line)
78
+ if heading
79
+ blocks << heading
80
+ i += 1
81
+ next
82
+ end
83
+
84
+ list_item = try_list_item(source, line)
85
+ if list_item
86
+ blocks << list_item
87
+ i += 1
88
+ next
89
+ end
90
+
91
+ paragraph, next_i = consume_paragraph(source, lines, i, plugins)
92
+ blocks << paragraph
93
+ i = next_i
94
+ end
95
+
96
+ blocks
97
+ end
98
+
99
+ def run_plugins(source, lines, start_idx, plugins)
100
+ plugins.each do |p|
101
+ r = p.try_scan(source: source, lines: lines, start_idx: start_idx)
102
+ return r if r
103
+ end
104
+ nil
105
+ end
106
+
107
+ # Split +source+ into RawLines with UTF-16 start/end offsets.
108
+ def split_lines(source)
109
+ out = []
110
+ start_u16 = 0
111
+ current_u16 = 0
112
+ start_cp = 0
113
+
114
+ source.each_char.with_index do |ch, cp_i|
115
+ if ch == "\n"
116
+ out << RawLine.new(text: source[start_cp...cp_i], start_offset: start_u16, end_offset: current_u16)
117
+ start_u16 = current_u16 + 1 # '\n' is BMP → 1 UTF-16 unit
118
+ start_cp = cp_i + 1
119
+ end
120
+ current_u16 += ch.ord > 0xFFFF ? 2 : 1
121
+ end
122
+
123
+ out << RawLine.new(text: source[start_cp..] || '', start_offset: start_u16, end_offset: current_u16)
124
+ out
125
+ end
126
+
127
+ def try_thematic(source, line)
128
+ return nil unless THEMATIC_RE.match?(line.text)
129
+
130
+ ThematicBreak.new(span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset))
131
+ end
132
+
133
+ def try_heading(source, line)
134
+ m = HEADING_RE.match(line.text)
135
+ return nil unless m
136
+
137
+ Heading.new(
138
+ level: m[1].length,
139
+ text: (m[2] || '').strip,
140
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset)
141
+ )
142
+ end
143
+
144
+ def try_list_item(source, line)
145
+ if (ul = UL_RE.match(line.text))
146
+ text = ul[3] || ''
147
+ marker_start = line.start_offset + Offsets.utf16_len(ul[1] || '')
148
+ marker_end = marker_start + Offsets.utf16_len(ul[2] || '')
149
+ cp_idx = line.text.index(text)
150
+ text_start = line.start_offset + Offsets.to_utf16_offset(line.text, cp_idx)
151
+ return ListItem.new(
152
+ text: text,
153
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset),
154
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: text_start)],
155
+ ordered: false,
156
+ marker_span: Offsets.span_from_offsets(source, marker_start, marker_end)
157
+ )
158
+ end
159
+
160
+ if (ol = OL_RE.match(line.text))
161
+ text = ol[4] || ''
162
+ marker_start = line.start_offset + Offsets.utf16_len(ol[1] || '')
163
+ marker_end = marker_start + Offsets.utf16_len(ol[2] || '') + Offsets.utf16_len(ol[3] || '')
164
+ cp_idx = line.text.index(text)
165
+ text_start = line.start_offset + Offsets.to_utf16_offset(line.text, cp_idx)
166
+ return ListItem.new(
167
+ text: text,
168
+ span: Offsets.span_from_offsets(source, line.start_offset, line.end_offset),
169
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: text_start)],
170
+ ordered: true,
171
+ marker_span: Offsets.span_from_offsets(source, marker_start, marker_end)
172
+ )
173
+ end
174
+
175
+ nil
176
+ end
177
+
178
+ def try_blockquote(source, lines, start_idx)
179
+ return nil if start_idx >= lines.length
180
+
181
+ first = lines[start_idx]
182
+ m = BQ_RE.match(first.text)
183
+ return nil unless m
184
+
185
+ first_segment = m[1] || ''
186
+ cp_idx = first.text.index(first_segment)
187
+ segments = [first_segment]
188
+ segment_map = [
189
+ SegmentOffset.new(
190
+ text_offset: 0,
191
+ source_offset: first.start_offset + Offsets.to_utf16_offset(first.text, cp_idx)
192
+ )
193
+ ]
194
+ joined_text_offset = Offsets.utf16_len(first_segment)
195
+
196
+ i = start_idx + 1
197
+ end_offset = first.end_offset
198
+ while i < lines.length
199
+ ln = lines[i]
200
+ next_m = BQ_RE.match(ln.text)
201
+ break unless next_m
202
+
203
+ segment = next_m[1] || ''
204
+ cp_idx2 = ln.text.index(segment)
205
+ joined_text_offset += 1 # newline separator
206
+ segment_map << SegmentOffset.new(
207
+ text_offset: joined_text_offset,
208
+ source_offset: ln.start_offset + Offsets.to_utf16_offset(ln.text, cp_idx2)
209
+ )
210
+ segments << segment
211
+ joined_text_offset += Offsets.utf16_len(segment)
212
+ end_offset = ln.end_offset
213
+ i += 1
214
+ end
215
+
216
+ [
217
+ Blockquote.new(
218
+ text: segments.join("\n"),
219
+ span: Offsets.span_from_offsets(source, first.start_offset, end_offset),
220
+ segment_map: segment_map
221
+ ),
222
+ i
223
+ ]
224
+ end
225
+
226
+ def consume_paragraph(source, lines, start_idx, plugins)
227
+ raise 'invariant: start_idx out of range' if start_idx >= lines.length
228
+
229
+ first = lines[start_idx]
230
+ end_idx = start_idx
231
+ while end_idx + 1 < lines.length
232
+ candidate_idx = end_idx + 1
233
+ candidate = lines[candidate_idx]
234
+ break if candidate.text.strip.empty?
235
+ break if PARA_HEADING_RE.match?(candidate.text)
236
+ break if UL_RE.match?(candidate.text)
237
+ break if OL_RE.match?(candidate.text)
238
+ break if BQ_RE.match?(candidate.text)
239
+ break if FENCE_RE.match?(candidate.text)
240
+ break if ROW_RE.match?(candidate.text)
241
+ break if THEMATIC_RE.match?(candidate.text)
242
+ break if run_plugins(source, lines, candidate_idx, plugins)
243
+
244
+ end_idx += 1
245
+ end
246
+
247
+ last = lines[end_idx]
248
+ start_offset = first.start_offset
249
+ end_offset = last.end_offset
250
+ [
251
+ Paragraph.new(
252
+ text: Offsets.utf16_slice(source, start_offset, end_offset),
253
+ span: Offsets.span_from_offsets(source, start_offset, end_offset),
254
+ segment_map: [SegmentOffset.new(text_offset: 0, source_offset: start_offset)]
255
+ ),
256
+ end_idx + 1
257
+ ]
258
+ end
259
+
260
+ def try_fence(source, lines, start_idx)
261
+ return nil if start_idx >= lines.length
262
+
263
+ start = lines[start_idx]
264
+ open_m = FENCE_RE.match(start.text)
265
+ return nil unless open_m
266
+
267
+ fence_marker = open_m[1] || ''
268
+ info = (open_m[2] || '').strip
269
+
270
+ i = start_idx + 1
271
+ body_start = nil
272
+ body_end = nil
273
+ end_offset = start.end_offset
274
+
275
+ while i < lines.length
276
+ ln = lines[i]
277
+ close_m = FENCE_RE.match(ln.text)
278
+ if close_m && (close_m[1] || '').length >= fence_marker.length
279
+ end_offset = ln.end_offset
280
+ break
281
+ end
282
+ body_start = ln.start_offset if body_start.nil?
283
+ body_end = ln.end_offset + 1 # +1 to include the '\n' after this line
284
+ i += 1
285
+ end
286
+
287
+ body = body_start.nil? || body_end.nil? ? '' : Offsets.utf16_slice(source, body_start, body_end)
288
+
289
+ fallback = start.end_offset
290
+ body_span = Offsets.span_from_offsets(source, body_start || fallback, body_end || fallback)
291
+ [
292
+ Fence.new(
293
+ info: info,
294
+ body: body,
295
+ body_span: body_span,
296
+ span: Offsets.span_from_offsets(source, start.start_offset, end_offset)
297
+ ),
298
+ i + 1
299
+ ]
300
+ end
301
+
302
+ def try_table(source, lines, start_idx)
303
+ return nil if start_idx + 1 >= lines.length
304
+
305
+ header_line = lines[start_idx]
306
+ delim_line = lines[start_idx + 1]
307
+ return nil unless ROW_RE.match?(header_line.text)
308
+ return nil unless DELIM_RE.match?(delim_line.text)
309
+
310
+ header_cells, header_cell_spans = TableCells.parse_row_cells(header_line.text, header_line.start_offset,
311
+ source)
312
+ header = Row.new(
313
+ cells: header_cells,
314
+ cell_spans: header_cell_spans,
315
+ span: Offsets.span_from_offsets(source, header_line.start_offset, header_line.end_offset)
316
+ )
317
+
318
+ rows = []
319
+ i = start_idx + 2
320
+ while i < lines.length
321
+ ln = lines[i]
322
+ break unless ROW_RE.match?(ln.text)
323
+
324
+ cells, cell_spans = TableCells.parse_row_cells(ln.text, ln.start_offset, source)
325
+ rows << Row.new(
326
+ cells: cells,
327
+ cell_spans: cell_spans,
328
+ span: Offsets.span_from_offsets(source, ln.start_offset, ln.end_offset)
329
+ )
330
+ i += 1
331
+ end
332
+
333
+ last_row = rows.last
334
+ end_offset = last_row ? last_row.span.end_offset : delim_line.end_offset
335
+ [
336
+ Table.new(
337
+ span: Offsets.span_from_offsets(source, header_line.start_offset, end_offset),
338
+ header: header,
339
+ rows: rows
340
+ ),
341
+ i
342
+ ]
343
+ end
344
+ end
345
+ end
346
+ end