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.
- checksums.yaml +7 -0
- data/lib/varar/core/ast.rb +47 -0
- data/lib/varar/core/canonical_json.rb +47 -0
- data/lib/varar/core/cell_diff.rb +105 -0
- data/lib/varar/core/conformance.rb +264 -0
- data/lib/varar/core/deep_freeze.rb +25 -0
- data/lib/varar/core/diagnostics.rb +48 -0
- data/lib/varar/core/doc_string_diff.rb +36 -0
- data/lib/varar/core/drift.rb +185 -0
- data/lib/varar/core/execute.rb +191 -0
- data/lib/varar/core/failure_anchor.rb +28 -0
- data/lib/varar/core/hash.rb +27 -0
- data/lib/varar/core/matcher.rb +132 -0
- data/lib/varar/core/param_diff.rb +52 -0
- data/lib/varar/core/parse.rb +18 -0
- data/lib/varar/core/plan.rb +286 -0
- data/lib/varar/core/registry.rb +74 -0
- data/lib/varar/core/scanner.rb +346 -0
- data/lib/varar/core/sentences.rb +114 -0
- data/lib/varar/core/span.rb +84 -0
- data/lib/varar/core/step_role.rb +23 -0
- data/lib/varar/core/structurer.rb +84 -0
- data/lib/varar/core/table_cells.rb +44 -0
- data/lib/varar/core.rb +32 -0
- metadata +79 -0
|
@@ -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
|