canon 0.3.27 → 0.3.28
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/.rubocop.yml +2 -0
- data/README.adoc +3 -0
- data/docs/features/regenerate-expected.adoc +157 -0
- data/lib/canon/comparison/diff_node_builder.rb +9 -0
- data/lib/canon/config/env_schema.rb +6 -0
- data/lib/canon/rebaseliner/atomic_writer.rb +32 -0
- data/lib/canon/rebaseliner/call_site_resolver.rb +126 -0
- data/lib/canon/rebaseliner/heredoc_locator.rb +140 -0
- data/lib/canon/rebaseliner/heredoc_rewriter.rb +56 -0
- data/lib/canon/rebaseliner/heredoc_target.rb +19 -0
- data/lib/canon/rebaseliner/logger.rb +24 -0
- data/lib/canon/rebaseliner.rb +162 -0
- data/lib/canon/rspec_matchers.rb +101 -23
- data/lib/canon/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6aef461e6f80457ad8fd6c227effe22086b5692f2cfcec5f4226ab0268e4956d
|
|
4
|
+
data.tar.gz: da2c4ae39e5c8a3483c811fe9bd19c49d69542b637fd97e4b62a2d20fda8bc08
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8cd8327564623711f977aec6b31943433ab962bdec534b2ac107f0eb7ead31cb0854551f3ef614ae1f2e2cdd059b5ac6b3655b44e7cc0dd82d34cff0a2c4b045
|
|
7
|
+
data.tar.gz: fcaac6df39848f1fd387710e57de61533dac6543028f412b08bd36d9615157a8252a81b13f015606ce666da31d02a5eeea0896abfadafdca6543dc92ac4977c6
|
data/.rubocop.yml
CHANGED
data/README.adoc
CHANGED
|
@@ -189,6 +189,9 @@ $ canon help
|
|
|
189
189
|
* **link:docs/CHARACTER_VISUALIZATION[Character visualization]** -
|
|
190
190
|
Whitespace and special characters
|
|
191
191
|
* **link:docs/INPUT_VALIDATION[Input validation]** - Error handling
|
|
192
|
+
* **link:docs/features/regenerate-expected[Regenerating expected heredocs]** -
|
|
193
|
+
`CANON_REGENERATE_EXPECTED=true` to rewrite failing `be_*_equivalent_to`
|
|
194
|
+
heredocs in-place from the prettyprinted received value
|
|
192
195
|
|
|
193
196
|
=== Advanced topics
|
|
194
197
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Regenerating expected heredocs
|
|
3
|
+
parent: Features
|
|
4
|
+
nav_order: 50
|
|
5
|
+
---
|
|
6
|
+
= Regenerating expected heredocs
|
|
7
|
+
:toc:
|
|
8
|
+
:toclevels: 3
|
|
9
|
+
|
|
10
|
+
== Purpose
|
|
11
|
+
|
|
12
|
+
When a Canon matcher (`be_xml_equivalent_to`, `be_html_equivalent_to`, etc.)
|
|
13
|
+
asserts equivalence against a heredoc-literal `expected`, and the
|
|
14
|
+
upstream code legitimately changes its output, the fixture heredoc
|
|
15
|
+
needs to be updated to the new value. The default workflow is:
|
|
16
|
+
|
|
17
|
+
1. Run `bundle exec rspec` — see N failing assertions.
|
|
18
|
+
2. Copy the prettyprinted received from canon's diff display.
|
|
19
|
+
3. Paste into the `<<~XML ... XML` heredoc in the spec file.
|
|
20
|
+
4. Re-run, iterate.
|
|
21
|
+
|
|
22
|
+
For large fixture suites (e.g. the metanorma stack — 200+ heredocs per
|
|
23
|
+
rebaseline event) this is slow and error-prone (indent drift, partial
|
|
24
|
+
copies, missed trailing whitespace).
|
|
25
|
+
|
|
26
|
+
The `CANON_REGENERATE_EXPECTED=true` env var collapses steps 2-3 into
|
|
27
|
+
an automated rewrite.
|
|
28
|
+
|
|
29
|
+
== Workflow
|
|
30
|
+
|
|
31
|
+
[source,sh]
|
|
32
|
+
----
|
|
33
|
+
CANON_REGENERATE_EXPECTED=true bundle exec rspec
|
|
34
|
+
git diff # review what changed
|
|
35
|
+
git commit -am "rebaseline fixtures after upstream X change"
|
|
36
|
+
----
|
|
37
|
+
|
|
38
|
+
When the env var is set:
|
|
39
|
+
|
|
40
|
+
* Every failing `be_*_equivalent_to` assertion attempts to rewrite the
|
|
41
|
+
source heredoc that backs its `expected` argument.
|
|
42
|
+
* The rewrite uses the same `Canon::PrettyPrinter` that powers
|
|
43
|
+
`CANON_<FORMAT>_DIFF_SHOW_PRETTYPRINT_RECEIVED` — output is identical
|
|
44
|
+
to what would have appeared in the diff display.
|
|
45
|
+
* Successful rewrites mark the assertion as passing for the run, so CI
|
|
46
|
+
does not fail mid-rebaseline.
|
|
47
|
+
* Passing assertions are never touched.
|
|
48
|
+
* Negated matchers (`.not_to`) are never rewritten.
|
|
49
|
+
|
|
50
|
+
Each rewrite (or skip) emits a single-line log to stderr:
|
|
51
|
+
|
|
52
|
+
[source]
|
|
53
|
+
----
|
|
54
|
+
[canon:rebaseline] rewritten /path/to/spec.rb:123
|
|
55
|
+
[canon:rebaseline] skipped_interpolation /path/to/spec.rb:456
|
|
56
|
+
----
|
|
57
|
+
|
|
58
|
+
== Supported expected forms (v1)
|
|
59
|
+
|
|
60
|
+
The rebaseliner uses Prism to parse the caller spec file and locate the
|
|
61
|
+
heredoc backing the `expected` argument. It supports:
|
|
62
|
+
|
|
63
|
+
* `<<~XML` / `<<-XML` / `<<XML` heredoc assigned to a local variable in
|
|
64
|
+
the same `it` block.
|
|
65
|
+
* `<<~XML` / `<<-XML` / `<<XML` heredoc passed inline directly to the
|
|
66
|
+
matcher.
|
|
67
|
+
* *Multiple sequential assignments* to the same local variable within
|
|
68
|
+
one `it` block (the common metanorma pattern of reassigning `output`
|
|
69
|
+
before each format-specific expect). The rebaseliner walks backward
|
|
70
|
+
from the failing `expect` line to the *most-recent* assignment and
|
|
71
|
+
rewrites that one.
|
|
72
|
+
* Substitution chains on the actual side (e.g.
|
|
73
|
+
`expect(strip_guid(actual).gsub(...))`) — since the matcher receives
|
|
74
|
+
the post-substitution value, idempotency holds on the next run.
|
|
75
|
+
|
|
76
|
+
== Skipped with a warning (v1)
|
|
77
|
+
|
|
78
|
+
* Heredoc with `#{}` interpolation. Mechanical rewriting would lose
|
|
79
|
+
the interpolation; v2 (token preservation) is planned.
|
|
80
|
+
* Expected value from a method call (`load_fixture(...)`). The
|
|
81
|
+
expected is computed at runtime; the rebaseliner cannot identify
|
|
82
|
+
which on-disk fixture to rewrite.
|
|
83
|
+
* Expected value from a `let` or `shared_context` in a different
|
|
84
|
+
spec/support file. v1 does not cross file boundaries.
|
|
85
|
+
* Inline string literal expected (no heredoc). There is nothing to
|
|
86
|
+
re-indent into.
|
|
87
|
+
|
|
88
|
+
In each skipped case the file is left unchanged and the assertion
|
|
89
|
+
fails normally with the standard Canon diff display.
|
|
90
|
+
|
|
91
|
+
== Tracking line shifts across multiple rewrites
|
|
92
|
+
|
|
93
|
+
After a rewrite, the file's on-disk line numbering can shift if the new
|
|
94
|
+
heredoc body has a different line count from the old. Ruby's
|
|
95
|
+
`caller_locations` continues to report the *original* line numbers
|
|
96
|
+
during the same process run (the in-memory source is unchanged), so
|
|
97
|
+
the rebaseliner maintains a per-file cumulative line-shift tracker and
|
|
98
|
+
translates subsequent caller-reported lines accordingly. Multiple
|
|
99
|
+
rewrites within a single `it` block, or across multiple `it` blocks
|
|
100
|
+
in the same spec file, work transparently.
|
|
101
|
+
|
|
102
|
+
== Limitations and v2 roadmap
|
|
103
|
+
|
|
104
|
+
The following are intentionally out of v1 scope:
|
|
105
|
+
|
|
106
|
+
* `#{}` interpolation in heredoc bodies — v2 adds token-preserving
|
|
107
|
+
rewrites that re-anchor `#{var}` fragments in the prettyprinted
|
|
108
|
+
actual.
|
|
109
|
+
* JSON and YAML format prettyprinter wiring (v1 covers XML + HTML).
|
|
110
|
+
* A `canon regenerate SPEC_GLOB` Thor subcommand with a summary
|
|
111
|
+
report.
|
|
112
|
+
* File-locking for `parallel_rspec` safety. v1 documents "do not run
|
|
113
|
+
rebaseline mode in parallel."
|
|
114
|
+
* Optional `rubocop -A` post-rewrite formatter.
|
|
115
|
+
|
|
116
|
+
== Architecture
|
|
117
|
+
|
|
118
|
+
The rebaseliner is a self-contained module under
|
|
119
|
+
`lib/canon/rebaseliner/`:
|
|
120
|
+
|
|
121
|
+
`Canon::Rebaseliner`::
|
|
122
|
+
Top-level orchestrator. `enabled?` reads the env var.
|
|
123
|
+
`rewrite!` is the entry called by the matcher hook on failure.
|
|
124
|
+
|
|
125
|
+
`CallSiteResolver`::
|
|
126
|
+
Parses the spec file with Prism and locates the matcher invocation
|
|
127
|
+
on the failing line. Returns the AST node passed as `expected` and
|
|
128
|
+
the enclosing `it`/`example` block.
|
|
129
|
+
|
|
130
|
+
`HeredocLocator`::
|
|
131
|
+
Classifies the `expected` AST node. For local-variable references,
|
|
132
|
+
walks the enclosing block backward to find the most-recent
|
|
133
|
+
assignment. Returns a `HeredocSpec` describing the byte range to
|
|
134
|
+
rewrite, or a skip reason.
|
|
135
|
+
|
|
136
|
+
`HeredocRewriter`::
|
|
137
|
+
Re-indents the new body for `<<~` (squiggly) heredocs and writes the
|
|
138
|
+
new source via `AtomicWriter`.
|
|
139
|
+
|
|
140
|
+
`AtomicWriter`::
|
|
141
|
+
Tempfile-then-rename to avoid half-written files on Ctrl-C.
|
|
142
|
+
|
|
143
|
+
`Logger`::
|
|
144
|
+
Single-line stderr writes with the `[canon:rebaseline]` prefix.
|
|
145
|
+
|
|
146
|
+
The matcher hook lives in `lib/canon/rspec_matchers.rb`. It captures
|
|
147
|
+
`caller_locations` only when the env var is set, so passing
|
|
148
|
+
assertions pay no overhead.
|
|
149
|
+
|
|
150
|
+
== Testing the rebaseliner
|
|
151
|
+
|
|
152
|
+
`spec/canon/rebaseliner_spec.rb` runs each fixture under
|
|
153
|
+
`spec/fixtures/rebaseliner/` in a subprocess with the env var set,
|
|
154
|
+
then asserts the file content was (or was not) modified appropriately
|
|
155
|
+
and that the stderr log line is present. Add new fixture cases by
|
|
156
|
+
dropping `*_input.rb` into `spec/fixtures/rebaseliner/` and a new
|
|
157
|
+
example into `rebaseliner_spec.rb`.
|
|
@@ -179,6 +179,15 @@ module Canon
|
|
|
179
179
|
return "whitespace: #{describe_whitespace(text1)} vs #{describe_whitespace(text2)}"
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# Identical content modulo whitespace: two full visualized
|
|
183
|
+
# copies read as materially different text when the only
|
|
184
|
+
# delta is trailing/invisible whitespace (#94) — the compact
|
|
185
|
+
# character-count description says what actually differs.
|
|
186
|
+
if text1.strip == text2.strip
|
|
187
|
+
return "whitespace-only: #{describe_whitespace(text1)} vs " \
|
|
188
|
+
"#{describe_whitespace(text2)}"
|
|
189
|
+
end
|
|
190
|
+
|
|
182
191
|
"Text: \"#{visualize_whitespace(text1)}\" vs \"#{visualize_whitespace(text2)}\""
|
|
183
192
|
end
|
|
184
193
|
|
|
@@ -21,6 +21,12 @@ module Canon
|
|
|
21
21
|
|
|
22
22
|
FORMAT_ATTRIBUTE_TYPES = {
|
|
23
23
|
preprocessing: :string,
|
|
24
|
+
|
|
25
|
+
# Rebaseliner: opt-in mode that rewrites failing heredoc
|
|
26
|
+
# expectations in spec files with the prettyprinted received
|
|
27
|
+
# value. Read directly by Canon::Rebaseliner.enabled?; schema
|
|
28
|
+
# entry exists for --env-help discoverability.
|
|
29
|
+
regenerate_expected: :boolean,
|
|
24
30
|
}.freeze
|
|
25
31
|
|
|
26
32
|
class << self
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Canon
|
|
7
|
+
module Rebaseliner
|
|
8
|
+
# Write a file atomically by writing to a same-directory tempfile and
|
|
9
|
+
# renaming over the target. Preserves the original file's mode. Avoids
|
|
10
|
+
# half-written files on Ctrl-C.
|
|
11
|
+
module AtomicWriter
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param path [String] absolute path to write
|
|
15
|
+
# @param contents [String] new file contents
|
|
16
|
+
# @return [void]
|
|
17
|
+
def write(path, contents)
|
|
18
|
+
original_mode = File.stat(path).mode
|
|
19
|
+
dir = File.dirname(path)
|
|
20
|
+
Tempfile.create(["canon-rebaseline", ".tmp"], dir) do |tmp|
|
|
21
|
+
tmp.binmode
|
|
22
|
+
tmp.write(contents)
|
|
23
|
+
tmp.flush
|
|
24
|
+
tmp.fsync
|
|
25
|
+
tmp.close
|
|
26
|
+
File.chmod(original_mode, tmp.path)
|
|
27
|
+
File.rename(tmp.path, path)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Canon
|
|
6
|
+
module Rebaseliner
|
|
7
|
+
# Parse a spec file with Prism, locate the matcher invocation at a
|
|
8
|
+
# specific line, and return enough context (matcher AST + enclosing
|
|
9
|
+
# `it`/`example` block + full source) for the locator and rewriter to
|
|
10
|
+
# work against.
|
|
11
|
+
class CallSiteResolver
|
|
12
|
+
Result = Struct.new(
|
|
13
|
+
:source, # full file source (UTF-8 string)
|
|
14
|
+
:matcher_call_node, # Prism::CallNode for `be_*_equivalent_to(arg)`
|
|
15
|
+
:expected_node, # the `expected` argument node (first positional arg)
|
|
16
|
+
:enclosing_block, # the `it`/`example` block containing the call
|
|
17
|
+
:matcher_line, # 1-indexed line number of the matcher invocation
|
|
18
|
+
keyword_init: true,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
MATCHER_NAMES = %i[
|
|
22
|
+
be_equivalent_to
|
|
23
|
+
be_xml_equivalent_to
|
|
24
|
+
be_html_equivalent_to
|
|
25
|
+
be_json_equivalent_to
|
|
26
|
+
be_yaml_equivalent_to
|
|
27
|
+
be_serialization_equivalent_to
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
# @param spec_path [String]
|
|
31
|
+
# @param line [Integer] 1-indexed line number where the matcher
|
|
32
|
+
# invocation lives (typically from `caller_locations`)
|
|
33
|
+
# @return [Result, nil] nil if the file can't be parsed or no matcher
|
|
34
|
+
# call is found at that line
|
|
35
|
+
def self.resolve(spec_path:, line:)
|
|
36
|
+
source = File.read(spec_path)
|
|
37
|
+
parse_result = Prism.parse(source)
|
|
38
|
+
return nil unless parse_result.success?
|
|
39
|
+
|
|
40
|
+
new(source: source,
|
|
41
|
+
root: parse_result.value,
|
|
42
|
+
line: line).resolve
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(source:, root:, line:)
|
|
46
|
+
@source = source
|
|
47
|
+
@root = root
|
|
48
|
+
@line = line
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def resolve
|
|
52
|
+
call_node = find_matcher_call(@root)
|
|
53
|
+
return nil unless call_node
|
|
54
|
+
|
|
55
|
+
expected = call_node.arguments&.arguments&.first
|
|
56
|
+
return nil unless expected
|
|
57
|
+
|
|
58
|
+
enclosing = find_enclosing_block(@root, call_node)
|
|
59
|
+
return nil unless enclosing
|
|
60
|
+
|
|
61
|
+
Result.new(
|
|
62
|
+
source: @source,
|
|
63
|
+
matcher_call_node: call_node,
|
|
64
|
+
expected_node: expected,
|
|
65
|
+
enclosing_block: enclosing,
|
|
66
|
+
matcher_line: call_node.location.start_line,
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Locate a CallNode whose method name is one of MATCHER_NAMES and
|
|
73
|
+
# whose location encompasses the target line.
|
|
74
|
+
def find_matcher_call(root)
|
|
75
|
+
match = nil
|
|
76
|
+
walk(root) do |node|
|
|
77
|
+
next unless node.is_a?(Prism::CallNode)
|
|
78
|
+
next unless MATCHER_NAMES.include?(node.name)
|
|
79
|
+
|
|
80
|
+
loc = node.location
|
|
81
|
+
next unless @line.between?(loc.start_line, loc.end_line)
|
|
82
|
+
|
|
83
|
+
# Prefer the narrowest enclosing match (innermost matcher).
|
|
84
|
+
if match.nil? ||
|
|
85
|
+
(loc.end_line - loc.start_line) <
|
|
86
|
+
(match.location.end_line - match.location.start_line)
|
|
87
|
+
match = node
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
match
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Locate the enclosing `it { ... }` / `example { ... }` block. RSpec
|
|
94
|
+
# uses `it("...") do ... end` which Prism parses as a CallNode with
|
|
95
|
+
# an attached BlockNode. The BlockNode is the body we want.
|
|
96
|
+
def find_enclosing_block(root, target_call)
|
|
97
|
+
candidates = []
|
|
98
|
+
walk(root) do |node|
|
|
99
|
+
next unless node.is_a?(Prism::CallNode)
|
|
100
|
+
next if node.block.nil?
|
|
101
|
+
next unless %i[it example specify focus].include?(node.name)
|
|
102
|
+
|
|
103
|
+
loc = node.location
|
|
104
|
+
target_loc = target_call.location
|
|
105
|
+
next unless loc.start_line <= target_loc.start_line &&
|
|
106
|
+
loc.end_line >= target_loc.end_line
|
|
107
|
+
|
|
108
|
+
candidates << node.block
|
|
109
|
+
end
|
|
110
|
+
# Innermost wins.
|
|
111
|
+
candidates.min_by { |b| b.location.end_line - b.location.start_line }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def walk(node, &block)
|
|
115
|
+
return unless node
|
|
116
|
+
|
|
117
|
+
yield node
|
|
118
|
+
return unless node.is_a?(Prism::Node)
|
|
119
|
+
|
|
120
|
+
node.child_nodes.each do |child|
|
|
121
|
+
walk(child, &block) unless child.nil?
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Canon
|
|
6
|
+
module Rebaseliner
|
|
7
|
+
# Resolve a Prism AST node (the `expected` argument passed to a Canon
|
|
8
|
+
# matcher) to a {HeredocTarget} that can be rewritten in-place, or to a
|
|
9
|
+
# skip reason. Handles the metanorma-iso pattern of multiple sequential
|
|
10
|
+
# assignments to the same local var via "most-recent assignment before
|
|
11
|
+
# the matcher line" semantics.
|
|
12
|
+
class HeredocLocator
|
|
13
|
+
Result = Struct.new(:status, :heredoc_spec, keyword_init: true) do
|
|
14
|
+
def rewritable?
|
|
15
|
+
status == :ok
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param spec_path [String] absolute path of the spec file (passed through
|
|
20
|
+
# into any returned HeredocTarget)
|
|
21
|
+
# @param source [String] full file source string
|
|
22
|
+
# @param enclosing_block [Prism::Node] the `it`/`example` block node
|
|
23
|
+
# that contains the matcher invocation
|
|
24
|
+
# @param expected_node [Prism::Node] the AST node passed as `expected`
|
|
25
|
+
# to the matcher
|
|
26
|
+
# @param matcher_line [Integer] 1-indexed line of the matcher call;
|
|
27
|
+
# used as the upper bound when walking backward for the most recent
|
|
28
|
+
# local-var assignment
|
|
29
|
+
def initialize(spec_path:, source:, enclosing_block:, expected_node:,
|
|
30
|
+
matcher_line:)
|
|
31
|
+
@spec_path = spec_path
|
|
32
|
+
@source = source
|
|
33
|
+
@enclosing_block = enclosing_block
|
|
34
|
+
@expected_node = expected_node
|
|
35
|
+
@matcher_line = matcher_line
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Result] :ok with a HeredocTarget, or a :skipped_* status
|
|
39
|
+
def resolve
|
|
40
|
+
resolve_node(@expected_node)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def resolve_node(node)
|
|
46
|
+
case node
|
|
47
|
+
when Prism::StringNode
|
|
48
|
+
resolve_string_node(node)
|
|
49
|
+
when Prism::InterpolatedStringNode
|
|
50
|
+
resolve_interpolated_string_node(node)
|
|
51
|
+
when Prism::LocalVariableReadNode
|
|
52
|
+
resolve_local_variable(node)
|
|
53
|
+
else
|
|
54
|
+
# CallNode, ConstantReadNode, IndexReadNode, etc.
|
|
55
|
+
Result.new(status: :skipped_method_call)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_string_node(node)
|
|
60
|
+
opening = node.opening_loc&.slice
|
|
61
|
+
return Result.new(status: :skipped_inline_string) unless heredoc_opening?(opening)
|
|
62
|
+
|
|
63
|
+
spec = build_heredoc_spec(node, opening)
|
|
64
|
+
Result.new(status: :ok, heredoc_spec: spec)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def resolve_interpolated_string_node(node)
|
|
68
|
+
opening = node.opening_loc&.slice
|
|
69
|
+
return Result.new(status: :skipped_inline_string) unless heredoc_opening?(opening)
|
|
70
|
+
|
|
71
|
+
# Any interpolation part means we can't rewrite mechanically in v1.
|
|
72
|
+
Result.new(status: :skipped_interpolation)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_local_variable(node)
|
|
76
|
+
name = node.name
|
|
77
|
+
most_recent = find_most_recent_assignment(@enclosing_block, name,
|
|
78
|
+
@matcher_line)
|
|
79
|
+
return Result.new(status: :skipped_cross_file) unless most_recent
|
|
80
|
+
|
|
81
|
+
resolve_node(most_recent.value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Walk all statements inside the enclosing block recursively and
|
|
85
|
+
# collect every LocalVariableWriteNode whose name matches and whose
|
|
86
|
+
# line is strictly before `matcher_line`. Return the one with the
|
|
87
|
+
# greatest line number.
|
|
88
|
+
def find_most_recent_assignment(block_node, name, matcher_line)
|
|
89
|
+
candidates = []
|
|
90
|
+
walk(block_node) do |child|
|
|
91
|
+
next unless child.is_a?(Prism::LocalVariableWriteNode)
|
|
92
|
+
next unless child.name == name
|
|
93
|
+
next unless child.location.start_line < matcher_line
|
|
94
|
+
|
|
95
|
+
candidates << child
|
|
96
|
+
end
|
|
97
|
+
candidates.max_by { |c| c.location.start_line }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def walk(node, &block)
|
|
101
|
+
return unless node.is_a?(Prism::Node)
|
|
102
|
+
|
|
103
|
+
node.child_nodes.each do |child|
|
|
104
|
+
next if child.nil?
|
|
105
|
+
|
|
106
|
+
yield child
|
|
107
|
+
walk(child, &block)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def heredoc_opening?(opening)
|
|
112
|
+
opening&.start_with?("<<")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def heredoc_style(opening)
|
|
116
|
+
case opening
|
|
117
|
+
when /\A<<~/ then :squiggly
|
|
118
|
+
when /\A<<-/ then :dash
|
|
119
|
+
else :strict
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def build_heredoc_spec(node, opening)
|
|
124
|
+
content_loc = node.content_loc
|
|
125
|
+
closing_loc = node.closing_loc
|
|
126
|
+
style = heredoc_style(opening)
|
|
127
|
+
terminator_indent = closing_loc.start_column
|
|
128
|
+
|
|
129
|
+
HeredocTarget.new(
|
|
130
|
+
spec_path: @spec_path,
|
|
131
|
+
source: @source,
|
|
132
|
+
style: style,
|
|
133
|
+
content_start_offset: content_loc.start_offset,
|
|
134
|
+
content_end_offset: content_loc.end_offset,
|
|
135
|
+
terminator_indent: terminator_indent,
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
module Rebaseliner
|
|
5
|
+
# Replace a heredoc's body in a spec file with new content, preserving
|
|
6
|
+
# the heredoc's opening style (`<<~` re-indented, `<<-`/`<<` verbatim).
|
|
7
|
+
module HeredocRewriter
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param spec [HeredocTarget] description of the heredoc to rewrite
|
|
11
|
+
# @param new_body [String] new heredoc body (pretty-printed actual);
|
|
12
|
+
# may or may not have a trailing newline; the rewriter normalises.
|
|
13
|
+
# @return [void]
|
|
14
|
+
def rewrite!(spec, new_body)
|
|
15
|
+
body = format_body(new_body, spec.style, spec.terminator_indent)
|
|
16
|
+
new_source = spec.source.byteslice(0, spec.content_start_offset) +
|
|
17
|
+
body +
|
|
18
|
+
spec.source.byteslice(spec.content_end_offset..-1)
|
|
19
|
+
AtomicWriter.write(spec.spec_path, new_source)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Format the new body to fit the heredoc style.
|
|
23
|
+
# - `:squiggly` (`<<~`): re-indent each line to the terminator column.
|
|
24
|
+
# - `:dash` / `:strict`: write verbatim (the original code is
|
|
25
|
+
# indentation-sensitive, leave it alone).
|
|
26
|
+
# Always ensures a single trailing newline before the terminator line.
|
|
27
|
+
def format_body(new_body, style, terminator_indent)
|
|
28
|
+
normalised = new_body.dup
|
|
29
|
+
normalised << "\n" unless normalised.end_with?("\n")
|
|
30
|
+
|
|
31
|
+
case style
|
|
32
|
+
when :squiggly
|
|
33
|
+
indent = " " * (terminator_indent || 0)
|
|
34
|
+
stripped = strip_common_leading_whitespace(normalised)
|
|
35
|
+
stripped.lines.map { |line| line == "\n" ? line : "#{indent}#{line}" }.join
|
|
36
|
+
else
|
|
37
|
+
normalised
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Remove the largest common leading-whitespace prefix from a multi-line
|
|
42
|
+
# string, mirroring `<<~`'s own behaviour. Blank lines don't constrain
|
|
43
|
+
# the prefix.
|
|
44
|
+
def strip_common_leading_whitespace(text)
|
|
45
|
+
lines = text.lines
|
|
46
|
+
leading = lines
|
|
47
|
+
.reject { |l| l.chomp.empty? }
|
|
48
|
+
.map { |l| l[/\A[ \t]*/].length }
|
|
49
|
+
.min || 0
|
|
50
|
+
return text if leading.zero?
|
|
51
|
+
|
|
52
|
+
lines.map { |l| l.chomp.empty? ? l : l[leading..] }.join
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
module Rebaseliner
|
|
5
|
+
# Data struct describing a heredoc literal located in a spec file:
|
|
6
|
+
# where its body lives in the source (byte range), what style of heredoc
|
|
7
|
+
# delimiter opens it (`<<~`, `<<-`, `<<`), and what indent the
|
|
8
|
+
# terminator sits at (used by `<<~` re-indenting).
|
|
9
|
+
HeredocTarget = Struct.new(
|
|
10
|
+
:spec_path, # absolute path
|
|
11
|
+
:source, # full file source string (UTF-8)
|
|
12
|
+
:style, # :squiggly (<<~) | :dash (<<-) | :strict (<<)
|
|
13
|
+
:content_start_offset, # byte offset where body starts (after opening line's \n)
|
|
14
|
+
:content_end_offset, # byte offset where body ends (just before terminator line)
|
|
15
|
+
:terminator_indent, # integer column of the terminator (relevant for :squiggly)
|
|
16
|
+
keyword_init: true,
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
module Rebaseliner
|
|
5
|
+
# Single-line stderr writes prefixed with `[canon:rebaseline]`. CI- and
|
|
6
|
+
# grep-friendly. No color, no buffering.
|
|
7
|
+
module Logger
|
|
8
|
+
PREFIX = "[canon:rebaseline]"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param status [Symbol] :rewritten / :skipped_* / :error
|
|
13
|
+
# @param spec_path [String]
|
|
14
|
+
# @param line [Integer]
|
|
15
|
+
# @param detail [String, nil] short reason or contextual note
|
|
16
|
+
# @return [void]
|
|
17
|
+
def log(status, spec_path:, line:, detail: nil)
|
|
18
|
+
location = "#{spec_path}:#{line}"
|
|
19
|
+
suffix = detail ? " (#{detail})" : ""
|
|
20
|
+
warn "#{PREFIX} #{status} #{location}#{suffix}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Canon
|
|
4
|
+
# In-place rebaselining of `be_*_equivalent_to` heredoc expectations.
|
|
5
|
+
#
|
|
6
|
+
# Opt-in via `CANON_REGENERATE_EXPECTED=true`. When set, a failing
|
|
7
|
+
# matcher assertion has its expected heredoc body replaced with the
|
|
8
|
+
# prettyprinted received value, and the assertion is reported as
|
|
9
|
+
# passing for the run. Default OFF; passing assertions are never
|
|
10
|
+
# touched.
|
|
11
|
+
#
|
|
12
|
+
# See docs/features/regenerate-expected.adoc for the supported
|
|
13
|
+
# expected-argument forms and the recommended workflow.
|
|
14
|
+
module Rebaseliner
|
|
15
|
+
autoload :AtomicWriter, "canon/rebaseliner/atomic_writer"
|
|
16
|
+
autoload :Logger, "canon/rebaseliner/logger"
|
|
17
|
+
autoload :HeredocTarget, "canon/rebaseliner/heredoc_target"
|
|
18
|
+
autoload :HeredocRewriter, "canon/rebaseliner/heredoc_rewriter"
|
|
19
|
+
autoload :HeredocLocator, "canon/rebaseliner/heredoc_locator"
|
|
20
|
+
autoload :CallSiteResolver, "canon/rebaseliner/call_site_resolver"
|
|
21
|
+
ENV_VAR = "CANON_REGENERATE_EXPECTED"
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] true when the env var is set to a truthy value.
|
|
24
|
+
# Memoised per process.
|
|
25
|
+
def self.enabled?
|
|
26
|
+
return @enabled if defined?(@enabled)
|
|
27
|
+
|
|
28
|
+
@enabled = case ENV.fetch(ENV_VAR, "").to_s.downcase
|
|
29
|
+
when "1", "true", "yes", "on" then true
|
|
30
|
+
else false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Reset memoisation; used by tests.
|
|
35
|
+
def self.reset!
|
|
36
|
+
remove_instance_variable(:@enabled) if defined?(@enabled)
|
|
37
|
+
@line_shifts = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Per-file cumulative line-shift tracking. After a rewrite, the file
|
|
41
|
+
# on disk has different line numbering, but Ruby's caller_locations
|
|
42
|
+
# still reports the ORIGINAL line numbers (the in-memory source).
|
|
43
|
+
# For each spec_path we accumulate (threshold_line, delta) tuples,
|
|
44
|
+
# and use them to translate an original line number to its current
|
|
45
|
+
# post-rewrite line.
|
|
46
|
+
def self.line_shifts
|
|
47
|
+
@line_shifts ||= Hash.new { |h, k| h[k] = [] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Translate an original caller line to the current line in the file,
|
|
51
|
+
# given accumulated shifts.
|
|
52
|
+
def self.shifted_line(spec_path, original_line)
|
|
53
|
+
shifts = line_shifts[spec_path]
|
|
54
|
+
shifts.inject(original_line) do |line, (threshold, delta)|
|
|
55
|
+
original_line > threshold ? line + delta : line
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Record a line shift caused by a rewrite. `threshold` is the original
|
|
60
|
+
# source line where the rewrite began; any subsequent line in the
|
|
61
|
+
# original source after that threshold is offset by `delta`.
|
|
62
|
+
def self.record_shift(spec_path, threshold, delta)
|
|
63
|
+
line_shifts[spec_path] << [threshold, delta]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Attempt to rewrite the heredoc that backs a failing assertion.
|
|
67
|
+
#
|
|
68
|
+
# @param spec_path [String] absolute path of the spec file containing
|
|
69
|
+
# the matcher invocation
|
|
70
|
+
# @param line [Integer] 1-indexed line of the matcher invocation
|
|
71
|
+
# @param prettyprinted_actual [String] new body content
|
|
72
|
+
# @return [Symbol] :rewritten, :skipped_inline_string,
|
|
73
|
+
# :skipped_interpolation, :skipped_method_call,
|
|
74
|
+
# :skipped_cross_file, :skipped_unresolved, or :error
|
|
75
|
+
def self.rewrite!(spec_path:, line:, prettyprinted_actual:)
|
|
76
|
+
# Translate the caller-reported original line to its current
|
|
77
|
+
# position in the on-disk file, accounting for any previous
|
|
78
|
+
# rewrites that shifted subsequent lines.
|
|
79
|
+
effective_line = shifted_line(spec_path, line)
|
|
80
|
+
call_site = CallSiteResolver.resolve(spec_path: spec_path,
|
|
81
|
+
line: effective_line)
|
|
82
|
+
unless call_site
|
|
83
|
+
Logger.log(:skipped_unresolved, spec_path: spec_path, line: line,
|
|
84
|
+
detail: "no matcher call at line")
|
|
85
|
+
return :skipped_unresolved
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
locator = HeredocLocator.new(
|
|
89
|
+
spec_path: spec_path,
|
|
90
|
+
source: call_site.source,
|
|
91
|
+
enclosing_block: call_site.enclosing_block,
|
|
92
|
+
expected_node: call_site.expected_node,
|
|
93
|
+
matcher_line: call_site.matcher_line,
|
|
94
|
+
)
|
|
95
|
+
result = locator.resolve
|
|
96
|
+
unless result.rewritable?
|
|
97
|
+
Logger.log(result.status, spec_path: spec_path, line: line)
|
|
98
|
+
return result.status
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
old_line_count = line_count_in_range(call_site.source,
|
|
102
|
+
result.heredoc_spec)
|
|
103
|
+
HeredocRewriter.rewrite!(result.heredoc_spec, prettyprinted_actual)
|
|
104
|
+
count_newlines(File.read(spec_path)
|
|
105
|
+
.byteslice(result.heredoc_spec.content_start_offset,
|
|
106
|
+
File.size(spec_path) -
|
|
107
|
+
result.heredoc_spec.content_start_offset))
|
|
108
|
+
# Compute shift simply by re-reading the file size delta in lines.
|
|
109
|
+
record_shift_from_disk(spec_path, call_site, result.heredoc_spec,
|
|
110
|
+
old_line_count)
|
|
111
|
+
Logger.log(:rewritten, spec_path: spec_path, line: line)
|
|
112
|
+
:rewritten
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
Logger.log(:error, spec_path: spec_path, line: line,
|
|
115
|
+
detail: "#{e.class}: #{e.message}")
|
|
116
|
+
:error
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Count newlines in the original heredoc body (between content_start
|
|
120
|
+
# and content_end byte offsets) and in the rewritten file's same
|
|
121
|
+
# logical range; the difference is the line shift to record. We
|
|
122
|
+
# threshold the shift on the line where the heredoc opened, so that
|
|
123
|
+
# only lines AFTER the rewrite point are adjusted.
|
|
124
|
+
def self.line_count_in_range(source, heredoc_spec)
|
|
125
|
+
body = source.byteslice(heredoc_spec.content_start_offset,
|
|
126
|
+
heredoc_spec.content_end_offset -
|
|
127
|
+
heredoc_spec.content_start_offset)
|
|
128
|
+
count_newlines(body.to_s)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.count_newlines(str)
|
|
132
|
+
str.count("\n")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.record_shift_from_disk(spec_path, call_site, heredoc_spec,
|
|
136
|
+
old_line_count)
|
|
137
|
+
new_source = File.read(spec_path)
|
|
138
|
+
# The new body lives at the same content_start_offset (since the
|
|
139
|
+
# offset is computed pre-rewrite from the pre-rewrite source). We
|
|
140
|
+
# need to find where the heredoc body ends in the new source. The
|
|
141
|
+
# closing terminator is unchanged textually, so we can search for
|
|
142
|
+
# the next occurrence of the closing line from the start offset.
|
|
143
|
+
pre = new_source.byteslice(0, heredoc_spec.content_start_offset)
|
|
144
|
+
remainder = new_source.byteslice(heredoc_spec.content_start_offset..-1)
|
|
145
|
+
# Find the closing terminator: same text as in the original source.
|
|
146
|
+
original_close = call_site.source.byteslice(
|
|
147
|
+
heredoc_spec.content_end_offset,
|
|
148
|
+
call_site.source.bytesize - heredoc_spec.content_end_offset,
|
|
149
|
+
).lines.first.to_s
|
|
150
|
+
close_idx = remainder.index(original_close)
|
|
151
|
+
return unless close_idx
|
|
152
|
+
|
|
153
|
+
new_body = remainder.byteslice(0, close_idx)
|
|
154
|
+
new_line_count = count_newlines(new_body)
|
|
155
|
+
delta = new_line_count - old_line_count
|
|
156
|
+
return if delta.zero?
|
|
157
|
+
|
|
158
|
+
threshold = pre.count("\n")
|
|
159
|
+
record_shift(spec_path, threshold, delta)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/canon/rspec_matchers.rb
CHANGED
|
@@ -4,6 +4,9 @@ require "canon" unless defined?(Canon)
|
|
|
4
4
|
require "canon/comparison"
|
|
5
5
|
require "canon/diff_formatter"
|
|
6
6
|
require "canon/config"
|
|
7
|
+
require "canon/rebaseliner"
|
|
8
|
+
require "canon/pretty_printer/xml"
|
|
9
|
+
require "canon/pretty_printer/html"
|
|
7
10
|
|
|
8
11
|
begin
|
|
9
12
|
require "rspec/expectations"
|
|
@@ -88,60 +91,135 @@ module Canon
|
|
|
88
91
|
end
|
|
89
92
|
|
|
90
93
|
def matches?(target)
|
|
94
|
+
# Capture caller_locations only when rebaseliner is enabled so that
|
|
95
|
+
# passing assertions don't pay for stack walking.
|
|
96
|
+
@rebaseliner_caller = caller_locations(1, 12) if Canon::Rebaseliner.enabled?
|
|
97
|
+
equivalent = compute_equivalent(target)
|
|
98
|
+
return true if equivalent
|
|
99
|
+
|
|
100
|
+
attempt_rebaseline
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Skip the rebaseliner path for `.not_to`. RSpec invokes
|
|
104
|
+
# `does_not_match?` if defined, instead of negating `matches?`.
|
|
105
|
+
def does_not_match?(target)
|
|
106
|
+
!compute_equivalent(target)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def failure_message
|
|
110
|
+
"expected #{format_name} to be equivalent\n\n#{diff_output}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def failure_message_when_negated
|
|
114
|
+
"expected #{format_name} not to be equivalent"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def expected
|
|
118
|
+
@expected
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def actual
|
|
122
|
+
@target
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def diffable # rubocop:disable Naming/PredicateMethod
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Run the actual comparison and set @target / @comparison_result.
|
|
132
|
+
# Returns the boolean equivalence result.
|
|
133
|
+
def compute_equivalent(target)
|
|
91
134
|
@target = target
|
|
92
135
|
|
|
93
|
-
# Build comparison options from config and matcher params
|
|
94
136
|
opts = build_comparison_options
|
|
95
|
-
|
|
96
|
-
# Add format hint if explicitly provided
|
|
97
137
|
opts[:format] = @format if @format
|
|
98
138
|
|
|
99
|
-
# Delegate to Canon::Comparison.equivalent? - the SINGLE source of truth
|
|
100
|
-
# Comparison handles format detection, HTML parsing, and all business logic
|
|
101
139
|
@comparison_result = Canon::Comparison.equivalent?(
|
|
102
140
|
@expected,
|
|
103
141
|
@target,
|
|
104
142
|
opts,
|
|
105
143
|
)
|
|
106
144
|
|
|
107
|
-
# When verbose: true, result is a ComparisonResult object
|
|
108
|
-
# Use the equivalent? method to check for normative differences
|
|
109
145
|
case @comparison_result
|
|
110
146
|
when Canon::Comparison::ComparisonResult
|
|
111
147
|
@comparison_result.equivalent?
|
|
112
148
|
when Hash
|
|
113
|
-
# Legacy format - Hash with :differences array and :preprocessed strings
|
|
114
149
|
@comparison_result[:differences].empty?
|
|
115
150
|
when Array
|
|
116
|
-
# Legacy format - XML/JSON/YAML returns []
|
|
117
151
|
@comparison_result.empty?
|
|
118
152
|
else
|
|
119
|
-
# Boolean result
|
|
120
153
|
@comparison_result
|
|
121
154
|
end
|
|
122
155
|
end
|
|
123
156
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
157
|
+
# When the rebaseliner env var is on and the comparison failed, try to
|
|
158
|
+
# rewrite the heredoc that backs the expected value. Returns `true` to
|
|
159
|
+
# the matcher (so the assertion is treated as passing) when a rewrite
|
|
160
|
+
# succeeded; otherwise `false` so the normal failure path runs.
|
|
161
|
+
def attempt_rebaseline
|
|
162
|
+
return false unless Canon::Rebaseliner.enabled?
|
|
163
|
+
return false unless @rebaseliner_caller
|
|
127
164
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
end
|
|
165
|
+
frame = first_user_frame(@rebaseliner_caller)
|
|
166
|
+
return false unless frame
|
|
131
167
|
|
|
132
|
-
|
|
133
|
-
|
|
168
|
+
prettyprinted = pretty_print_actual
|
|
169
|
+
return false unless prettyprinted
|
|
170
|
+
|
|
171
|
+
status = Canon::Rebaseliner.rewrite!(
|
|
172
|
+
spec_path: frame.absolute_path || frame.path,
|
|
173
|
+
line: frame.lineno,
|
|
174
|
+
prettyprinted_actual: prettyprinted,
|
|
175
|
+
)
|
|
176
|
+
status == :rewritten
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
Canon::Rebaseliner::Logger.log(
|
|
179
|
+
:error,
|
|
180
|
+
spec_path: frame&.path.to_s,
|
|
181
|
+
line: frame&.lineno.to_i,
|
|
182
|
+
detail: "#{e.class}: #{e.message}",
|
|
183
|
+
)
|
|
184
|
+
false
|
|
134
185
|
end
|
|
135
186
|
|
|
136
|
-
|
|
137
|
-
|
|
187
|
+
# Drop frames inside RSpec internals and canon itself; the first
|
|
188
|
+
# remaining is the user's spec file.
|
|
189
|
+
def first_user_frame(locations)
|
|
190
|
+
locations.find do |loc|
|
|
191
|
+
path = loc.absolute_path || loc.path
|
|
192
|
+
next false unless path
|
|
193
|
+
next false if %r{/gems/rspec-(expectations|core|mocks|support)-}.match?(path)
|
|
194
|
+
next false if %r{/lib/rspec/(expectations|core|mocks|support)/}.match?(path)
|
|
195
|
+
next false if path.include?("/canon/lib/canon/")
|
|
196
|
+
next false if path.end_with?("/lib/canon/rspec_matchers.rb")
|
|
197
|
+
|
|
198
|
+
true
|
|
199
|
+
end
|
|
138
200
|
end
|
|
139
201
|
|
|
140
|
-
|
|
141
|
-
|
|
202
|
+
# Format the actual value with the appropriate pretty-printer for the
|
|
203
|
+
# heredoc rewrite. Returns nil when no prettyprinter is wired (e.g.
|
|
204
|
+
# JSON/YAML in v1).
|
|
205
|
+
def pretty_print_actual
|
|
206
|
+
fmt = @format || detect_format
|
|
207
|
+
case fmt
|
|
208
|
+
when :xml
|
|
209
|
+
Canon::PrettyPrinter::Xml.new.format(@target.to_s)
|
|
210
|
+
when :html, :html4, :html5
|
|
211
|
+
# fixture_ready: true emits actually-indented XHTML-shaped
|
|
212
|
+
# output suitable for direct paste into a heredoc.
|
|
213
|
+
Canon::PrettyPrinter::Html.new(fixture_ready: true)
|
|
214
|
+
.format(@target.to_s)
|
|
215
|
+
end
|
|
142
216
|
end
|
|
143
217
|
|
|
144
|
-
|
|
218
|
+
def detect_format
|
|
219
|
+
Canon::Comparison::FormatDetector.detect(@expected)
|
|
220
|
+
rescue StandardError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
145
223
|
|
|
146
224
|
def format_name
|
|
147
225
|
# Use explicitly provided format if available
|
data/lib/canon/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: canon
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.28
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -187,6 +187,7 @@ files:
|
|
|
187
187
|
- docs/features/match-options/index.adoc
|
|
188
188
|
- docs/features/match-options/pretty-printed-fixtures.adoc
|
|
189
189
|
- docs/features/performance.adoc
|
|
190
|
+
- docs/features/regenerate-expected.adoc
|
|
190
191
|
- docs/getting-started/index.adoc
|
|
191
192
|
- docs/getting-started/quick-start.adoc
|
|
192
193
|
- docs/guides/choosing-configuration.adoc
|
|
@@ -342,6 +343,13 @@ files:
|
|
|
342
343
|
- lib/canon/pretty_printer/json.rb
|
|
343
344
|
- lib/canon/pretty_printer/xml.rb
|
|
344
345
|
- lib/canon/pretty_printer/xml_normalized.rb
|
|
346
|
+
- lib/canon/rebaseliner.rb
|
|
347
|
+
- lib/canon/rebaseliner/atomic_writer.rb
|
|
348
|
+
- lib/canon/rebaseliner/call_site_resolver.rb
|
|
349
|
+
- lib/canon/rebaseliner/heredoc_locator.rb
|
|
350
|
+
- lib/canon/rebaseliner/heredoc_rewriter.rb
|
|
351
|
+
- lib/canon/rebaseliner/heredoc_target.rb
|
|
352
|
+
- lib/canon/rebaseliner/logger.rb
|
|
345
353
|
- lib/canon/rspec_matchers.rb
|
|
346
354
|
- lib/canon/tree_diff.rb
|
|
347
355
|
- lib/canon/tree_diff/adapters.rb
|