varar-core 0.8.0 → 0.8.1
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 +4 -4
- data/lib/varar/core/ast.rb +4 -1
- data/lib/varar/core/conformance.rb +11 -6
- data/lib/varar/core/diagnostics.rb +57 -2
- data/lib/varar/core/execute.rb +4 -0
- data/lib/varar/core/failure.rb +2 -1
- data/lib/varar/core/failure_anchor.rb +17 -0
- data/lib/varar/core/plan.rb +135 -22
- data/lib/varar/core/reference.rb +142 -0
- data/lib/varar/core/result.rb +22 -6
- data/lib/varar/core/structurer.rb +4 -1
- data/lib/varar/core.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 76ca7dbd630ae0e9925114d31967993e9a0f366a83844da58778afffaa1bf555
|
|
4
|
+
data.tar.gz: e37e4f44a2469a038ba6c733b6098249501c6c8bcdbc9e6db84a4d0219872bed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5e9e258f4c033d489f529ddccb3d460afbae2c064ac58de82bed5f25b166ecc295d3c3537ad257d1a88b06784f8f21e3df26d808c5d36f521c66c3a147af9881
|
|
7
|
+
data.tar.gz: 9dc4d46d4052654752e5736b1703236e517e95412c193bd7851d1c37d6c2a88e4eea6f77c9aee7617272f769a796eb608d85111d243125b1209227ff95320454
|
data/lib/varar/core/ast.rb
CHANGED
|
@@ -46,6 +46,9 @@ module Varar
|
|
|
46
46
|
# one example. See ADR 0012.
|
|
47
47
|
Example = Data.define(:scope_stack, :span, :body, :preceded_by_delimiter)
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
# +headings+ is every heading in the document in source order — the same
|
|
50
|
+
# Heading values the scanner produced. The planner uses it to tell whether
|
|
51
|
+
# a reference anchor names one section or several (ADR 0016).
|
|
52
|
+
Doc = Data.define(:path, :source, :examples, :orphan_attachments, :headings)
|
|
50
53
|
end
|
|
51
54
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'varar/core/ast'
|
|
4
4
|
require 'varar/core/plan'
|
|
5
|
+
require 'varar/core/reference'
|
|
5
6
|
require 'varar/core/execute'
|
|
6
7
|
require 'varar/core/failure_anchor'
|
|
7
8
|
|
|
@@ -103,7 +104,8 @@ module Varar
|
|
|
103
104
|
{
|
|
104
105
|
'path' => doc.path,
|
|
105
106
|
'examples' => doc.examples.map { |ex| example_hash(ex) },
|
|
106
|
-
'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) }
|
|
107
|
+
'orphanAttachments' => doc.orphan_attachments.map { |b| block_hash(b) },
|
|
108
|
+
'headings' => doc.headings.map { |h| block_hash(h) }
|
|
107
109
|
}
|
|
108
110
|
end
|
|
109
111
|
|
|
@@ -158,20 +160,23 @@ module Varar
|
|
|
158
160
|
result
|
|
159
161
|
end
|
|
160
162
|
|
|
161
|
-
def planned_step_hash(step,
|
|
163
|
+
def planned_step_hash(step, _source)
|
|
162
164
|
step_names = parameter_type_names(step.step_def.compiled)
|
|
163
165
|
result = {
|
|
164
166
|
'text' => step.text,
|
|
165
167
|
'matchSpan' => span_hash(step.match_span),
|
|
166
168
|
'paramSpans' => step.param_spans.map { |s| span_hash(s) },
|
|
167
169
|
'matchedExpression' => step.step_def.expression,
|
|
168
|
-
'args' => step.
|
|
170
|
+
'args' => step.param_texts.each_with_index.map do |value, i|
|
|
169
171
|
{
|
|
170
|
-
'value' =>
|
|
172
|
+
'value' => value,
|
|
171
173
|
'parameterType' => i < step_names.length ? step_names[i] : nil
|
|
172
174
|
}
|
|
173
175
|
end
|
|
174
176
|
}
|
|
177
|
+
# Present only on a step a reference block spliced in from another oath
|
|
178
|
+
# (ADR 0016): the document its spans belong to.
|
|
179
|
+
result['docPath'] = step.doc_path if step.doc_path
|
|
175
180
|
result['dataTable'] = block_hash(step.data_table) if step.data_table
|
|
176
181
|
result['docString'] = doc_string_hash(step.doc_string) if step.doc_string
|
|
177
182
|
result
|
|
@@ -206,8 +211,8 @@ module Varar
|
|
|
206
211
|
end
|
|
207
212
|
|
|
208
213
|
# Run all examples and return the four-artifact bundle. Port of runConformance.
|
|
209
|
-
def run_conformance(doc, registry, create_context, parameter_types = [])
|
|
210
|
-
execution = Plan.plan(doc, registry)
|
|
214
|
+
def run_conformance(doc, registry, create_context, parameter_types = [], workspace = nil)
|
|
215
|
+
execution = Plan.plan(doc, registry, workspace || Reference.empty_workspace)
|
|
211
216
|
observed = Hash.new { |h, k| h[k] = [] }
|
|
212
217
|
observer = ->(o) { observed[o.example_index] << o }
|
|
213
218
|
queue = Execute.collect_examples(execution, create_context: create_context, observer: observer)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
module Varar
|
|
4
4
|
module Core
|
|
5
5
|
# A planning/run diagnostic on the shared rail. code is one of
|
|
6
|
-
# "ambiguous-match", "error-fence-without-step", "drift"
|
|
7
|
-
#
|
|
6
|
+
# "ambiguous-match", "error-fence-without-step", "drift",
|
|
7
|
+
# "reference-not-found", "reference-empty", "reference-cycle",
|
|
8
|
+
# "ambiguous-anchor". Port of diagnostics.ts.
|
|
8
9
|
Diagnostic = Data.define(:code, :severity, :message, :span)
|
|
9
10
|
Candidate = Data.define(:expression, :source_file, :source_line)
|
|
10
11
|
AmbiguousInput = Data.define(:text, :span, :candidates)
|
|
@@ -43,6 +44,60 @@ module Varar
|
|
|
43
44
|
span: span
|
|
44
45
|
)
|
|
45
46
|
end
|
|
47
|
+
|
|
48
|
+
# A reference block (ADR 0016) points at an oath the workspace does not
|
|
49
|
+
# hold. Never prose: a link-only block that resolves to nothing has no
|
|
50
|
+
# other reading, so it fails the run rather than degrading silently.
|
|
51
|
+
def reference_not_found(text, path, span)
|
|
52
|
+
Diagnostic.new(
|
|
53
|
+
severity: 'error',
|
|
54
|
+
code: 'reference-not-found',
|
|
55
|
+
message: %(Reference to "#{text}" points at "#{path}", which is not an oath in this ) +
|
|
56
|
+
"workspace.\nCheck the path, and that the file is matched by the `docs` globs " \
|
|
57
|
+
'in varar.config.json.',
|
|
58
|
+
span: span
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The referenced document exists but the section contributes no steps — a
|
|
63
|
+
# mistyped anchor, or a section that is pure prose.
|
|
64
|
+
def reference_empty(text, path, slug, span)
|
|
65
|
+
where = slug.empty? ? path : "#{path}##{slug}"
|
|
66
|
+
Diagnostic.new(
|
|
67
|
+
severity: 'error',
|
|
68
|
+
code: 'reference-empty',
|
|
69
|
+
message: %(Reference to "#{text}" resolves to "#{where}", which contributes no steps.\n) +
|
|
70
|
+
'Check the heading the anchor names, and that its section contains a matching ' \
|
|
71
|
+
'paragraph.',
|
|
72
|
+
span: span
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The anchor names more than one heading in the referenced document:
|
|
77
|
+
# GitHub would disambiguate with a numeric suffix, but a reference that
|
|
78
|
+
# could mean either section is an error, not a guess (ADR 0016).
|
|
79
|
+
def ambiguous_anchor(text, path, slug, heading_lines, span)
|
|
80
|
+
Diagnostic.new(
|
|
81
|
+
severity: 'error',
|
|
82
|
+
code: 'ambiguous-anchor',
|
|
83
|
+
message: "Reference to \"#{text}\" is ambiguous: \"#{path}\" has #{heading_lines.length} headings " \
|
|
84
|
+
"with the anchor \"##{slug}\" (lines #{heading_lines.join(', ')}).\n" \
|
|
85
|
+
'Rename the headings so each has an anchor of its own.',
|
|
86
|
+
span: span
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# References may nest to any depth (depth is a style question, not a
|
|
91
|
+
# rule), so a chain that reaches a section already on it must be reported
|
|
92
|
+
# rather than recursed into.
|
|
93
|
+
def reference_cycle(chain, span)
|
|
94
|
+
Diagnostic.new(
|
|
95
|
+
severity: 'error',
|
|
96
|
+
code: 'reference-cycle',
|
|
97
|
+
message: "Reference cycle: #{chain.join(' → ')}.",
|
|
98
|
+
span: span
|
|
99
|
+
)
|
|
100
|
+
end
|
|
46
101
|
end
|
|
47
102
|
end
|
|
48
103
|
end
|
data/lib/varar/core/execute.rb
CHANGED
|
@@ -205,6 +205,10 @@ module Varar
|
|
|
205
205
|
# so a renderer underlines the step and not its whole line.
|
|
206
206
|
def augment_stack(error, step, _var_path)
|
|
207
207
|
FailureAnchor.attach_anchor(error, FailureAnchor.failure_anchor(error, step.match_span))
|
|
208
|
+
# A step spliced in by a reference block has spans in the document it
|
|
209
|
+
# was WRITTEN in, so the payload must name that file — otherwise a
|
|
210
|
+
# renderer points at the running oath's line N, some other sentence.
|
|
211
|
+
FailureAnchor.attach_doc_path(error, step.doc_path) if step.doc_path
|
|
208
212
|
error
|
|
209
213
|
end
|
|
210
214
|
end
|
data/lib/varar/core/failure.rb
CHANGED
|
@@ -29,7 +29,8 @@ module Varar
|
|
|
29
29
|
message: error.message,
|
|
30
30
|
stack: render_stack(error),
|
|
31
31
|
cells: failing_cells(error),
|
|
32
|
-
anchor: anchor && AnchorRange.new(from: anchor.start_offset, to: anchor.end_offset)
|
|
32
|
+
anchor: anchor && AnchorRange.new(from: anchor.start_offset, to: anchor.end_offset),
|
|
33
|
+
doc_path: FailureAnchor.attached_doc_path(error)
|
|
33
34
|
)
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -18,6 +18,7 @@ module Varar
|
|
|
18
18
|
# the exception, so it never shows up in `inspect` output the way an
|
|
19
19
|
# extra attribute would.
|
|
20
20
|
ANCHOR_IVAR = :@varar_failure_anchor
|
|
21
|
+
DOC_PATH_IVAR = :@varar_failure_doc_path
|
|
21
22
|
|
|
22
23
|
def failure_anchor(error, fallback)
|
|
23
24
|
case error
|
|
@@ -41,6 +42,22 @@ module Varar
|
|
|
41
42
|
|
|
42
43
|
error.instance_variable_get(ANCHOR_IVAR)
|
|
43
44
|
end
|
|
45
|
+
|
|
46
|
+
# The document the anchor's offsets belong to, for a step a reference
|
|
47
|
+
# block spliced in from another oath (ADR 0016). Travels the same way and
|
|
48
|
+
# for the same reason as the anchor: the executor knows the step, and
|
|
49
|
+
# whoever builds the failure payload sees only the error.
|
|
50
|
+
def attach_doc_path(error, doc_path)
|
|
51
|
+
return unless error.respond_to?(:instance_variable_set)
|
|
52
|
+
|
|
53
|
+
error.instance_variable_set(DOC_PATH_IVAR, doc_path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attached_doc_path(error)
|
|
57
|
+
return nil unless error.respond_to?(:instance_variable_get)
|
|
58
|
+
|
|
59
|
+
error.instance_variable_get(DOC_PATH_IVAR)
|
|
60
|
+
end
|
|
44
61
|
end
|
|
45
62
|
end
|
|
46
63
|
end
|
data/lib/varar/core/plan.rb
CHANGED
|
@@ -5,16 +5,22 @@ require 'varar/core/ast'
|
|
|
5
5
|
require 'varar/core/cell_diff'
|
|
6
6
|
require 'varar/core/diagnostics'
|
|
7
7
|
require 'varar/core/matcher'
|
|
8
|
+
require 'varar/core/reference'
|
|
8
9
|
require 'varar/core/sentences'
|
|
9
10
|
|
|
10
11
|
module Varar
|
|
11
12
|
module Core
|
|
12
13
|
DocString = Data.define(:content, :content_type, :span)
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
# param_texts: the notation each parameter matched, sliced at plan time from
|
|
16
|
+
# the document the step was WRITTEN in — consumers must use it rather than
|
|
17
|
+
# slicing the running oath's source, because a step a reference block
|
|
18
|
+
# spliced in (ADR 0016) has spans in a different document.
|
|
19
|
+
# doc_path: set only on such a spliced step — the document its spans belong to.
|
|
20
|
+
PlannedStep = Data.define(:text, :match_span, :param_spans, :param_texts, :step_def, :args, :formats,
|
|
21
|
+
:data_table, :doc_string, :doc_path) do
|
|
22
|
+
def initialize(text:, match_span:, param_spans:, step_def:, args:, param_texts: [], formats: [],
|
|
23
|
+
data_table: nil, doc_string: nil, doc_path: nil)
|
|
18
24
|
super
|
|
19
25
|
end
|
|
20
26
|
end
|
|
@@ -42,21 +48,40 @@ module Varar
|
|
|
42
48
|
# header-bound table (standalone rows) or a step-bearing candidate the
|
|
43
49
|
# grouping pass may merge into an open example.
|
|
44
50
|
HeaderBoundUnit = Data.define(:rows)
|
|
51
|
+
# A reference block: its whole text is a link to an oath section, whose
|
|
52
|
+
# steps are spliced in here (ADR 0016). Never prose, so it does not close
|
|
53
|
+
# the open example. Its span and scope stack are the referring
|
|
54
|
+
# document's: the example it opens lives here, not in the section.
|
|
55
|
+
ReferenceUnit = Data.define(:reference, :preceded_by_delimiter, :span, :scope_stack)
|
|
45
56
|
StepsUnit = Data.define(:matched, :preceded_by_delimiter, :name, :scope_stack, :span, :steps,
|
|
46
57
|
:expected_outcome, :expected_error_message)
|
|
47
58
|
|
|
48
59
|
# An open, merging example being built up across adjacent matching
|
|
49
60
|
# candidates in Phase 2.
|
|
61
|
+
# name_from_reference: true while the name came from a spliced
|
|
62
|
+
# (referenced) paragraph and is waiting to be replaced by the example's
|
|
63
|
+
# own first matching paragraph.
|
|
50
64
|
MergedExample = Struct.new(:name, :scope_stack, :start_offset, :end_offset, :steps,
|
|
51
|
-
:expected_outcome, :expected_error_message)
|
|
65
|
+
:expected_outcome, :expected_error_message, :name_from_reference)
|
|
52
66
|
|
|
53
67
|
module_function
|
|
54
68
|
|
|
55
|
-
def plan(doc, registry)
|
|
69
|
+
def plan(doc, registry, workspace)
|
|
56
70
|
diagnostics = []
|
|
57
71
|
|
|
72
|
+
# A section another oath references stops being a standalone example:
|
|
73
|
+
# it runs where it is referenced, not here (ADR 0016).
|
|
74
|
+
whole_file = Reference.section_key(doc.path, '')
|
|
75
|
+
consumed = lambda do |ex|
|
|
76
|
+
workspace.referenced.include?(whole_file) ||
|
|
77
|
+
ex.scope_stack.any? do |h|
|
|
78
|
+
workspace.referenced.include?(Reference.section_key(doc.path, Reference.slugify(h)))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
58
82
|
# Phase 1: plan each candidate paragraph independently into a "unit".
|
|
59
|
-
units = doc.examples.
|
|
83
|
+
units = doc.examples.reject { |ex| consumed.call(ex) }
|
|
84
|
+
.map { |ex| plan_candidate(ex, doc, registry, diagnostics) }
|
|
60
85
|
|
|
61
86
|
# Phase 2: group adjacent candidates into examples. A matching candidate
|
|
62
87
|
# continues the open example when no delimiter (heading / `---`) precedes
|
|
@@ -75,6 +100,31 @@ module Varar
|
|
|
75
100
|
examples.concat(unit.rows)
|
|
76
101
|
next
|
|
77
102
|
end
|
|
103
|
+
if unit.is_a?(ReferenceUnit)
|
|
104
|
+
# Splice the referenced section's steps in at this position. Only
|
|
105
|
+
# the reference block itself is subject to the delimiter rule;
|
|
106
|
+
# everything it splices in belongs to the same sequence, so a
|
|
107
|
+
# section of several paragraphs stays one example.
|
|
108
|
+
resolve_reference(unit, doc, registry, workspace, diagnostics, []).each_with_index do |spliced, i|
|
|
109
|
+
if open && (i.positive? || !unit.preceded_by_delimiter)
|
|
110
|
+
merge_into(open, spliced, from_reference: true)
|
|
111
|
+
else
|
|
112
|
+
flush.call
|
|
113
|
+
open = start_merged(spliced)
|
|
114
|
+
# An example that OPENS with a reference is named by its own
|
|
115
|
+
# first matching paragraph, not by the section it pulls in, and
|
|
116
|
+
# it sits under THIS document's headings, not the section's.
|
|
117
|
+
open.name_from_reference = true
|
|
118
|
+
open.scope_stack = unit.scope_stack
|
|
119
|
+
open.start_offset = unit.span.start_offset
|
|
120
|
+
end
|
|
121
|
+
# A spliced unit's span is in the referenced document; the
|
|
122
|
+
# example's span is in this one. It ends at the reference block
|
|
123
|
+
# until a later paragraph of the example's own extends it.
|
|
124
|
+
open.end_offset = unit.span.end_offset
|
|
125
|
+
end
|
|
126
|
+
next
|
|
127
|
+
end
|
|
78
128
|
unless unit.matched
|
|
79
129
|
# Prose paragraph — a delimiter. Drop it and end the open example.
|
|
80
130
|
flush.call
|
|
@@ -92,12 +142,74 @@ module Varar
|
|
|
92
142
|
ExecutionPlan.new(doc: doc, examples: examples, diagnostics: diagnostics)
|
|
93
143
|
end
|
|
94
144
|
|
|
145
|
+
# Resolve one reference block into the step-bearing units of the section
|
|
146
|
+
# it names, recursively: a referenced section may itself contain
|
|
147
|
+
# reference blocks, to any depth (ADR 0016 leaves depth to the author's
|
|
148
|
+
# judgement). `chain` carries the sections currently being resolved so a
|
|
149
|
+
# repeat is reported as a cycle instead of recursing forever.
|
|
150
|
+
def resolve_reference(unit, from_doc, registry, workspace, diagnostics, chain)
|
|
151
|
+
ref = unit.reference
|
|
152
|
+
key = Reference.section_key(ref.path, ref.slug)
|
|
153
|
+
if chain.include?(key)
|
|
154
|
+
diagnostics << Diagnostics.reference_cycle(chain + [key], unit.span)
|
|
155
|
+
return []
|
|
156
|
+
end
|
|
157
|
+
# A same-file reference resolves against the document being planned,
|
|
158
|
+
# which is not necessarily in the workspace.
|
|
159
|
+
target = ref.path == from_doc.path ? from_doc : workspace.docs[ref.path]
|
|
160
|
+
if target.nil?
|
|
161
|
+
diagnostics << Diagnostics.reference_not_found(ref.text, ref.path, unit.span)
|
|
162
|
+
return []
|
|
163
|
+
end
|
|
164
|
+
# An anchor that names more than one heading could mean either section:
|
|
165
|
+
# report it rather than splicing both. A whole-file reference ('' slug)
|
|
166
|
+
# names no heading, so it is never ambiguous.
|
|
167
|
+
unless ref.slug.empty?
|
|
168
|
+
named = target.headings.select { |h| Reference.slugify(h.text) == ref.slug }
|
|
169
|
+
if named.length > 1
|
|
170
|
+
diagnostics << Diagnostics.ambiguous_anchor(ref.text, ref.path, ref.slug,
|
|
171
|
+
named.map { |h| h.span.start_line }, unit.span)
|
|
172
|
+
return []
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
out = []
|
|
176
|
+
Reference.section_candidates(target, ref.slug).each do |candidate|
|
|
177
|
+
planned = plan_candidate(candidate, target, registry, diagnostics)
|
|
178
|
+
if planned.is_a?(ReferenceUnit)
|
|
179
|
+
out.concat(resolve_reference(planned, target, registry, workspace, diagnostics, chain + [key]))
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
# A header-bound table produces one example per row, which a spliced
|
|
183
|
+
# step list cannot express; an `error` fence declares an outcome for
|
|
184
|
+
# an example, not for a reusable fragment. Both are left out.
|
|
185
|
+
next unless planned.is_a?(StepsUnit) && planned.matched
|
|
186
|
+
|
|
187
|
+
out << tag_with_doc(planned, target.path, from_doc.path)
|
|
188
|
+
end
|
|
189
|
+
diagnostics << Diagnostics.reference_empty(ref.text, ref.path, ref.slug, unit.span) if out.empty?
|
|
190
|
+
out
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Carry the source document's identity on every spliced step, so a failure
|
|
194
|
+
# in a referenced section reports spans against the file they were written
|
|
195
|
+
# in rather than the file being run.
|
|
196
|
+
def tag_with_doc(unit, doc_path, host_path)
|
|
197
|
+
return unit if doc_path == host_path
|
|
198
|
+
|
|
199
|
+
unit.with(steps: unit.steps.map { |step| step.with(doc_path: doc_path) })
|
|
200
|
+
end
|
|
201
|
+
|
|
95
202
|
def start_merged(unit)
|
|
96
203
|
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)
|
|
204
|
+
unit.steps.dup, unit.expected_outcome, unit.expected_error_message, false)
|
|
98
205
|
end
|
|
99
206
|
|
|
100
|
-
def merge_into(open, unit)
|
|
207
|
+
def merge_into(open, unit, from_reference: false)
|
|
208
|
+
if open.name_from_reference && !from_reference
|
|
209
|
+
open.name = unit.name
|
|
210
|
+
open.scope_stack = unit.scope_stack
|
|
211
|
+
open.name_from_reference = false
|
|
212
|
+
end
|
|
101
213
|
open.end_offset = unit.span.end_offset
|
|
102
214
|
open.steps.concat(unit.steps)
|
|
103
215
|
# Any error fence in a merged part marks the whole example
|
|
@@ -125,6 +237,17 @@ module Varar
|
|
|
125
237
|
# Plan a single candidate paragraph (plus attached tables/fences) in
|
|
126
238
|
# isolation. Emits ambiguity / error-fence diagnostics into +diagnostics+.
|
|
127
239
|
def plan_candidate(ex, doc, registry, diagnostics)
|
|
240
|
+
# A block whose whole text is a link to an oath section is a reference,
|
|
241
|
+
# not content: never matched against step definitions, never prose.
|
|
242
|
+
primary = ex.body.first
|
|
243
|
+
if primary.respond_to?(:text)
|
|
244
|
+
ref = Reference.reference_of(primary.text, doc.path)
|
|
245
|
+
if ref
|
|
246
|
+
return ReferenceUnit.new(reference: ref, preceded_by_delimiter: ex.preceded_by_delimiter,
|
|
247
|
+
span: ex.span, scope_stack: ex.scope_stack)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
128
251
|
had_ambiguous = false
|
|
129
252
|
steps_by_block = {}
|
|
130
253
|
|
|
@@ -161,6 +284,7 @@ module Varar
|
|
|
161
284
|
text: Offsets.utf16_slice(block.text, hit.match_start, hit.match_end),
|
|
162
285
|
match_span: lift_span(doc.source, block, hit.match_start, hit.match_end),
|
|
163
286
|
param_spans: hit.param_spans.map { |p| lift_span(doc.source, block, p.start, p.end) },
|
|
287
|
+
param_texts: hit.param_spans.map { |p| Offsets.utf16_slice(block.text, p.start, p.end) },
|
|
164
288
|
step_def: hit.step_def,
|
|
165
289
|
args: hit.args,
|
|
166
290
|
formats: hit.formats
|
|
@@ -182,14 +306,7 @@ module Varar
|
|
|
182
306
|
table.header.cells.each_with_index do |cell_name, i|
|
|
183
307
|
row_object[cell_name] = i < row.cells.length ? row.cells[i] : ''
|
|
184
308
|
end
|
|
185
|
-
row_step =
|
|
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
|
-
)
|
|
309
|
+
row_step = binding_step.with(match_span: row.span, args: binding_step.args + [row_object])
|
|
193
310
|
row_checks = table.header.cells.each_with_index.map do |cell_name, i|
|
|
194
311
|
RowCheck.new(
|
|
195
312
|
column: cell_name,
|
|
@@ -236,11 +353,7 @@ module Varar
|
|
|
236
353
|
block_steps.each_with_index do |step, s_idx|
|
|
237
354
|
if s_idx == block_steps.length - 1 && attach
|
|
238
355
|
data_table, doc_string = attach
|
|
239
|
-
final_steps <<
|
|
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
|
-
)
|
|
356
|
+
final_steps << step.with(data_table: data_table, doc_string: doc_string)
|
|
244
357
|
else
|
|
245
358
|
final_steps << step
|
|
246
359
|
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'varar/core/ast'
|
|
4
|
+
|
|
5
|
+
module Varar
|
|
6
|
+
module Core
|
|
7
|
+
# Reuse is a link (ADR 0016). A candidate block whose entire content is a
|
|
8
|
+
# single Markdown link to an oath section is a REFERENCE BLOCK: it splices
|
|
9
|
+
# that section's steps in at its own position instead of being prose.
|
|
10
|
+
#
|
|
11
|
+
# Everything here is pure text and path arithmetic — no filesystem. The
|
|
12
|
+
# shell reads the documents; `references` tells it which ones to read, and
|
|
13
|
+
# `build_workspace` turns the collection into what `plan` needs.
|
|
14
|
+
module Reference
|
|
15
|
+
# The referenced oath's path (resolved against the referring doc's own
|
|
16
|
+
# path), the GFM slug of the heading, and the link's visible text.
|
|
17
|
+
Ref = Data.define(:path, :slug, :text)
|
|
18
|
+
|
|
19
|
+
# What `plan` needs to resolve references: every oath by path, plus which
|
|
20
|
+
# sections a reference block consumes somewhere in the project. A section
|
|
21
|
+
# that is referenced stops being a standalone example, so this is
|
|
22
|
+
# whole-project knowledge — see ADR 0016 on why each runner builds it at
|
|
23
|
+
# its once-per-run discovery pass.
|
|
24
|
+
Workspace = Data.define(:docs, :referenced)
|
|
25
|
+
|
|
26
|
+
# A candidate is a reference block iff its whole text is one Markdown link
|
|
27
|
+
# whose target is oath-shaped. Anything else — a link with surrounding
|
|
28
|
+
# words, a link to https://…, to a .rb file, to a mailto: — is ordinary
|
|
29
|
+
# content, so existing documents keep their meaning.
|
|
30
|
+
LINK_ONLY = /\A\[([^\]]*)\]\(\s*([^\s)]+)\s*\)\z/
|
|
31
|
+
PROTOCOL = /\A[a-z][a-z0-9+.-]*:/i
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
def reference_of(text, from_path)
|
|
36
|
+
m = LINK_ONLY.match(text.strip)
|
|
37
|
+
return nil if m.nil?
|
|
38
|
+
|
|
39
|
+
link_text = m[1]
|
|
40
|
+
target = m[2]
|
|
41
|
+
return Ref.new(path: from_path, slug: normalize_slug(target[1..]), text: link_text) if target.start_with?('#')
|
|
42
|
+
|
|
43
|
+
hash_at = target.index('#')
|
|
44
|
+
file_part = hash_at.nil? ? target : target[0...hash_at]
|
|
45
|
+
fragment = hash_at.nil? ? '' : target[(hash_at + 1)..]
|
|
46
|
+
# Only a relative Markdown path is a reference. A protocol (https:,
|
|
47
|
+
# mailto:) or any other extension is left alone — remote references are
|
|
48
|
+
# deliberately out of scope (ADR 0016).
|
|
49
|
+
return nil unless file_part.end_with?('.md')
|
|
50
|
+
return nil if PROTOCOL.match?(file_part) || file_part.start_with?('/')
|
|
51
|
+
|
|
52
|
+
Ref.new(path: join_posix(dirname_posix(from_path), file_part), slug: normalize_slug(fragment),
|
|
53
|
+
text: link_text)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GitHub's heading anchors: inline markup dropped, lowercased, spaces to
|
|
57
|
+
# hyphens, everything else that isn't a word character or hyphen removed.
|
|
58
|
+
# The same function produces the slug of a heading and normalizes the slug
|
|
59
|
+
# written in a link, so the two meet in the middle.
|
|
60
|
+
def slugify(heading_text)
|
|
61
|
+
stripped = heading_text.gsub(/`([^`]*)`/, '\1')
|
|
62
|
+
.gsub(/\*\*([^*]*)\*\*/, '\1')
|
|
63
|
+
.gsub(/\*([^*]*)\*/, '\1')
|
|
64
|
+
.gsub(/_([^_]*)_/, '\1')
|
|
65
|
+
normalize_slug(stripped)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One hyphen per space, not per run of them: GitHub leaves the gap where
|
|
69
|
+
# it dropped punctuation, so "Fees, VAT & rounding" slugs with a double
|
|
70
|
+
# hyphen.
|
|
71
|
+
def normalize_slug(str)
|
|
72
|
+
str.strip.downcase.gsub(/[^[[:word:]] -]/, '').tr(' ', '-')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dirname_posix(path)
|
|
76
|
+
i = path.rindex('/')
|
|
77
|
+
i.nil? ? '' : path[0...i]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# POSIX path arithmetic on oath paths (always '/'-separated, relative to
|
|
81
|
+
# the workspace root). The core may not touch the filesystem. A link that
|
|
82
|
+
# climbs above the root keeps its leading `../`, as the oath-path
|
|
83
|
+
# convention does for an oath outside the root.
|
|
84
|
+
def join_posix(dir, rel)
|
|
85
|
+
segments = dir.empty? ? [] : dir.split('/')
|
|
86
|
+
rel.split('/').each do |segment|
|
|
87
|
+
next if segment.empty? || segment == '.'
|
|
88
|
+
|
|
89
|
+
if segment != '..'
|
|
90
|
+
segments << segment
|
|
91
|
+
elsif !segments.empty? && segments.last != '..'
|
|
92
|
+
segments.pop
|
|
93
|
+
else
|
|
94
|
+
segments << '..'
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
segments.join('/')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every reference block in a document, in document order. The shell uses
|
|
101
|
+
# this to walk the closure of documents it must read before planning.
|
|
102
|
+
def references(doc)
|
|
103
|
+
doc.examples.filter_map do |ex|
|
|
104
|
+
primary = ex.body.first
|
|
105
|
+
next nil unless primary.respond_to?(:text)
|
|
106
|
+
|
|
107
|
+
reference_of(primary.text, doc.path)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def section_key(path, slug)
|
|
112
|
+
"#{path}##{slug}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The workspace with no references at all: what a caller planning a single
|
|
116
|
+
# document in isolation passes.
|
|
117
|
+
def empty_workspace
|
|
118
|
+
Workspace.new(docs: {}, referenced: Set.new)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def build_workspace(docs)
|
|
122
|
+
by_path = docs.to_h { |doc| [doc.path, doc] }
|
|
123
|
+
referenced = Set.new
|
|
124
|
+
docs.each do |doc|
|
|
125
|
+
references(doc).each { |ref| referenced << section_key(ref.path, ref.slug) }
|
|
126
|
+
end
|
|
127
|
+
Workspace.new(docs: by_path, referenced: referenced)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The candidates that make up a section: those whose heading chain
|
|
131
|
+
# contains the slug. A whole-file reference ('' slug) is every candidate.
|
|
132
|
+
# Section membership follows the document outline exactly — a heading's
|
|
133
|
+
# section runs until the next heading of the same or higher level, which
|
|
134
|
+
# is precisely the range over which it stays on the scope stack.
|
|
135
|
+
def section_candidates(doc, slug)
|
|
136
|
+
return doc.examples if slug.empty?
|
|
137
|
+
|
|
138
|
+
doc.examples.select { |ex| ex.scope_stack.any? { |h| slugify(h) == slug } }
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/varar/core/result.rb
CHANGED
|
@@ -21,8 +21,11 @@ module Varar
|
|
|
21
21
|
# nil when they do not apply, and serialize as absent (not null), so a
|
|
22
22
|
# reader that predates them still parses the file. `stack` is deliberately
|
|
23
23
|
# runtime-shaped — no consumer parses it.
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# `doc_path` is the document `line`, `cells` and `anchor` are offsets INTO.
|
|
25
|
+
# nil — the overwhelming majority — means the oath itself; set only for a
|
|
26
|
+
# step a reference block spliced in from another oath (ADR 0016).
|
|
27
|
+
ExampleFailure = Data.define(:line, :message, :stack, :cells, :anchor, :doc_path) do
|
|
28
|
+
def initialize(line:, message:, stack:, cells: nil, anchor: nil, doc_path: nil)
|
|
26
29
|
super
|
|
27
30
|
end
|
|
28
31
|
end
|
|
@@ -39,7 +42,15 @@ module Varar
|
|
|
39
42
|
# separators and is relative to the workspace root; `source_hash` is
|
|
40
43
|
# Hashing.hash_source over the oath as it was run, so a reader can tell
|
|
41
44
|
# whether the offsets still apply to the buffer in front of it.
|
|
42
|
-
|
|
45
|
+
# An oath other than this one that contributed steps to the run, with its
|
|
46
|
+
# source hash as run (ADR 0016).
|
|
47
|
+
ReferencedDocument = Data.define(:path, :source_hash)
|
|
48
|
+
|
|
49
|
+
OathResults = Data.define(:version, :oath_path, :source_hash, :examples, :documents) do
|
|
50
|
+
def initialize(version:, oath_path:, source_hash:, examples:, documents: [])
|
|
51
|
+
super
|
|
52
|
+
end
|
|
53
|
+
end
|
|
43
54
|
|
|
44
55
|
# Projection of OathResults onto the JSON shape of .varar/<oath_path>.json.
|
|
45
56
|
#
|
|
@@ -51,12 +62,16 @@ module Varar
|
|
|
51
62
|
module_function
|
|
52
63
|
|
|
53
64
|
def to_wire(results)
|
|
54
|
-
{
|
|
65
|
+
out = {
|
|
55
66
|
'version' => results.version,
|
|
56
67
|
'oathPath' => results.oath_path,
|
|
57
|
-
'sourceHash' => results.source_hash
|
|
58
|
-
'examples' => results.examples.map { |e| example_to_wire(e) }
|
|
68
|
+
'sourceHash' => results.source_hash
|
|
59
69
|
}
|
|
70
|
+
unless results.documents.empty?
|
|
71
|
+
out['documents'] = results.documents.map { |d| { 'path' => d.path, 'sourceHash' => d.source_hash } }
|
|
72
|
+
end
|
|
73
|
+
out['examples'] = results.examples.map { |e| example_to_wire(e) }
|
|
74
|
+
out
|
|
60
75
|
end
|
|
61
76
|
|
|
62
77
|
def example_to_wire(example)
|
|
@@ -71,6 +86,7 @@ module Varar
|
|
|
71
86
|
out['cells'] = failure.cells.map { |c| { 'from' => c.from, 'to' => c.to, 'actual' => c.actual } }
|
|
72
87
|
end
|
|
73
88
|
out['anchor'] = { 'from' => failure.anchor.from, 'to' => failure.anchor.to } if failure.anchor
|
|
89
|
+
out['docPath'] = failure.doc_path if failure.doc_path
|
|
74
90
|
out
|
|
75
91
|
end
|
|
76
92
|
end
|
|
@@ -17,6 +17,7 @@ module Varar
|
|
|
17
17
|
def structure(path, source, blocks)
|
|
18
18
|
examples = []
|
|
19
19
|
orphan_attachments = []
|
|
20
|
+
headings = []
|
|
20
21
|
scope_stack = [] # [[level, text], ...]
|
|
21
22
|
last_example_idx = -1
|
|
22
23
|
attachment_open = false
|
|
@@ -32,6 +33,7 @@ module Varar
|
|
|
32
33
|
# Pop deeper-or-equal-level entries before pushing the new heading.
|
|
33
34
|
scope_stack.pop while !scope_stack.empty? && scope_stack.last[0] >= block.level
|
|
34
35
|
scope_stack << [block.level, block.text]
|
|
36
|
+
headings << block
|
|
35
37
|
attachment_open = false
|
|
36
38
|
delimiter_pending = true
|
|
37
39
|
|
|
@@ -70,7 +72,8 @@ module Varar
|
|
|
70
72
|
path: path,
|
|
71
73
|
source: source,
|
|
72
74
|
examples: examples,
|
|
73
|
-
orphan_attachments: orphan_attachments
|
|
75
|
+
orphan_attachments: orphan_attachments,
|
|
76
|
+
headings: headings
|
|
74
77
|
)
|
|
75
78
|
end
|
|
76
79
|
end
|
data/lib/varar/core.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: varar-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aslak Hellesøy
|
|
@@ -47,6 +47,7 @@ files:
|
|
|
47
47
|
- lib/varar/core/param_diff.rb
|
|
48
48
|
- lib/varar/core/parse.rb
|
|
49
49
|
- lib/varar/core/plan.rb
|
|
50
|
+
- lib/varar/core/reference.rb
|
|
50
51
|
- lib/varar/core/registry.rb
|
|
51
52
|
- lib/varar/core/result.rb
|
|
52
53
|
- lib/varar/core/scanner.rb
|