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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8de5d7008a348037ef44fdd8bd5ee722e8077e058ab1e53cd2995b0dd5f0e2c
4
- data.tar.gz: 930b0efd7c7302968a6ff2f4fd9af8a6261a1a57fc71707d9206333a380f47a8
3
+ metadata.gz: 6aef461e6f80457ad8fd6c227effe22086b5692f2cfcec5f4226ab0268e4956d
4
+ data.tar.gz: da2c4ae39e5c8a3483c811fe9bd19c49d69542b637fd97e4b62a2d20fda8bc08
5
5
  SHA512:
6
- metadata.gz: 570bd7072c65d9c87316becd43334255bb176ee4260c1d40738cd51ee3d030c4ea3df9d93e923a2f6062c493271fb122bb252e1f41c6c0e40836d7fbe675fd5b
7
- data.tar.gz: a7d60c235f74cdf466a35b4ef6204c9943d306a1167dead26123314326936f90f7d96a5d2ee3bd2ace308556655c2e6492d2beb2ebab25933d4ce796dfd84e9f
6
+ metadata.gz: 8cd8327564623711f977aec6b31943433ab962bdec534b2ac107f0eb7ead31cb0854551f3ef614ae1f2e2cdd059b5ac6b3655b44e7cc0dd82d34cff0a2c4b045
7
+ data.tar.gz: fcaac6df39848f1fd387710e57de61533dac6543028f412b08bd36d9615157a8252a81b13f015606ce666da31d02a5eeea0896abfadafdca6543dc92ac4977c6
data/.rubocop.yml CHANGED
@@ -17,3 +17,5 @@ plugins:
17
17
 
18
18
  AllCops:
19
19
  TargetRubyVersion: 3.0
20
+ Exclude:
21
+ - "spec/fixtures/**/*"
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
@@ -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
- def failure_message
125
- "expected #{format_name} to be equivalent\n\n#{diff_output}"
126
- end
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
- def failure_message_when_negated
129
- "expected #{format_name} not to be equivalent"
130
- end
165
+ frame = first_user_frame(@rebaseliner_caller)
166
+ return false unless frame
131
167
 
132
- def expected
133
- @expected
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
- def actual
137
- @target
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
- def diffable # rubocop:disable Naming/PredicateMethod
141
- false
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
- private
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Canon
4
- VERSION = "0.3.27"
4
+ VERSION = "0.3.28"
5
5
  end
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.27
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