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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e14558a60af014ad7a1e7edfea61644a415408c5da1e384d6c34d4f191a2743
4
- data.tar.gz: f91c617e21f8affcd745676fffba8be6b1ab484ec9c2c4e17b7173d6d07e1733
3
+ metadata.gz: 76ca7dbd630ae0e9925114d31967993e9a0f366a83844da58778afffaa1bf555
4
+ data.tar.gz: e37e4f44a2469a038ba6c733b6098249501c6c8bcdbc9e6db84a4d0219872bed
5
5
  SHA512:
6
- metadata.gz: 4a89ff743d9d00027565cad5933ad28f2bc05af437a773feeac38f9c7c640ee25d548af761852343259afed64654205360e5e2983df9fbd82bc14a1167746013
7
- data.tar.gz: 66c9b893012bd279f337db321fbae0262802bf579f627a7d7e0db37ff4183b3ec3112c413af5614481521d61543ca2e634b966d54260032615cf0c97057fd89f
6
+ metadata.gz: 5e9e258f4c033d489f529ddccb3d460afbae2c064ac58de82bed5f25b166ecc295d3c3537ad257d1a88b06784f8f21e3df26d808c5d36f521c66c3a147af9881
7
+ data.tar.gz: 9dc4d46d4052654752e5736b1703236e517e95412c193bd7851d1c37d6c2a88e4eea6f77c9aee7617272f769a796eb608d85111d243125b1209227ff95320454
@@ -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
- Doc = Data.define(:path, :source, :examples, :orphan_attachments)
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, source)
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.param_spans.each_with_index.map do |s, i|
170
+ 'args' => step.param_texts.each_with_index.map do |value, i|
169
171
  {
170
- 'value' => Offsets.utf16_slice(source, s.start_offset, s.end_offset),
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". Port of
7
- # diagnostics.ts.
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
@@ -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
@@ -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
@@ -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
- 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)
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.map { |ex| plan_candidate(ex, doc, registry, diagnostics) }
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 = PlannedStep.new(
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 << PlannedStep.new(
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
@@ -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
- ExampleFailure = Data.define(:line, :message, :stack, :cells, :anchor) do
25
- def initialize(line:, message:, stack:, cells: nil, anchor: nil)
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
- OathResults = Data.define(:version, :oath_path, :source_hash, :examples)
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
@@ -4,7 +4,7 @@ module Varar
4
4
  # The pure functional core: parse, match, plan, execute, diffs, drift, and
5
5
  # the conformance projections. No filesystem, network, globals, or time.
6
6
  module Core
7
- VERSION = '0.8.0'
7
+ VERSION = '0.8.1'
8
8
  end
9
9
  end
10
10
 
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.0
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