Module: Canon::Rebaseliner

Defined in:
lib/canon/rebaseliner.rb,
lib/canon/rebaseliner/logger.rb,
lib/canon/rebaseliner/atomic_writer.rb,
lib/canon/rebaseliner/heredoc_target.rb,
lib/canon/rebaseliner/heredoc_locator.rb,
lib/canon/rebaseliner/heredoc_rewriter.rb,
lib/canon/rebaseliner/call_site_resolver.rb

Overview

In-place rebaselining of be_*_equivalent_to heredoc expectations.

Opt-in via CANON_REGENERATE_EXPECTED=true. When set, a failing matcher assertion has its expected heredoc body replaced with the prettyprinted received value, and the assertion is reported as passing for the run. Default OFF; passing assertions are never touched.

See docs/features/regenerate-expected.adoc for the supported expected-argument forms and the recommended workflow.

Defined Under Namespace

Modules: AtomicWriter, HeredocRewriter, Logger Classes: CallSiteResolver, HeredocLocator, HeredocTarget

Constant Summary collapse

ENV_VAR =
"CANON_REGENERATE_EXPECTED"

Class Method Summary collapse

Class Method Details

.count_newlines(str) ⇒ Object



131
132
133
# File 'lib/canon/rebaseliner.rb', line 131

def self.count_newlines(str)
  str.count("\n")
end

.enabled?Boolean

Returns true when the env var is set to a truthy value. Memoised per process.

Returns:

  • (Boolean)

    true when the env var is set to a truthy value. Memoised per process.



25
26
27
28
29
30
31
32
# File 'lib/canon/rebaseliner.rb', line 25

def self.enabled?
  return @enabled if defined?(@enabled)

  @enabled = case ENV.fetch(ENV_VAR, "").to_s.downcase
             when "1", "true", "yes", "on" then true
             else false
             end
end

.line_count_in_range(source, heredoc_spec) ⇒ Object

Count newlines in the original heredoc body (between content_start and content_end byte offsets) and in the rewritten file's same logical range; the difference is the line shift to record. We threshold the shift on the line where the heredoc opened, so that only lines AFTER the rewrite point are adjusted.



124
125
126
127
128
129
# File 'lib/canon/rebaseliner.rb', line 124

def self.line_count_in_range(source, heredoc_spec)
  body = source.byteslice(heredoc_spec.content_start_offset,
                          heredoc_spec.content_end_offset -
                          heredoc_spec.content_start_offset)
  count_newlines(body.to_s)
end

.line_shiftsObject

Per-file cumulative line-shift tracking. After a rewrite, the file on disk has different line numbering, but Ruby's caller_locations still reports the ORIGINAL line numbers (the in-memory source). For each spec_path we accumulate (threshold_line, delta) tuples, and use them to translate an original line number to its current post-rewrite line.



46
47
48
# File 'lib/canon/rebaseliner.rb', line 46

def self.line_shifts
  @line_shifts ||= Hash.new { |h, k| h[k] = [] }
end

.record_shift(spec_path, threshold, delta) ⇒ Object

Record a line shift caused by a rewrite. threshold is the original source line where the rewrite began; any subsequent line in the original source after that threshold is offset by delta.



62
63
64
# File 'lib/canon/rebaseliner.rb', line 62

def self.record_shift(spec_path, threshold, delta)
  line_shifts[spec_path] << [threshold, delta]
end

.record_shift_from_disk(spec_path, call_site, heredoc_spec, old_line_count) ⇒ Object



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/canon/rebaseliner.rb', line 135

def self.record_shift_from_disk(spec_path, call_site, heredoc_spec,
                                old_line_count)
  new_source = File.read(spec_path)
  # The new body lives at the same content_start_offset (since the
  # offset is computed pre-rewrite from the pre-rewrite source). We
  # need to find where the heredoc body ends in the new source. The
  # closing terminator is unchanged textually, so we can search for
  # the next occurrence of the closing line from the start offset.
  pre = new_source.byteslice(0, heredoc_spec.content_start_offset)
  remainder = new_source.byteslice(heredoc_spec.content_start_offset..-1)
  # Find the closing terminator: same text as in the original source.
  original_close = call_site.source.byteslice(
    heredoc_spec.content_end_offset,
    call_site.source.bytesize - heredoc_spec.content_end_offset,
  ).lines.first.to_s
  close_idx = remainder.index(original_close)
  return unless close_idx

  new_body = remainder.byteslice(0, close_idx)
  new_line_count = count_newlines(new_body)
  delta = new_line_count - old_line_count
  return if delta.zero?

  threshold = pre.count("\n")
  record_shift(spec_path, threshold, delta)
end

.reset!Object

Reset memoisation; used by tests.



35
36
37
38
# File 'lib/canon/rebaseliner.rb', line 35

def self.reset!
  remove_instance_variable(:@enabled) if defined?(@enabled)
  @line_shifts = nil
end

.rewrite!(spec_path:, line:, prettyprinted_actual:) ⇒ Symbol

Attempt to rewrite the heredoc that backs a failing assertion.

Parameters:

  • spec_path (String)

    absolute path of the spec file containing the matcher invocation

  • line (Integer)

    1-indexed line of the matcher invocation

  • prettyprinted_actual (String)

    new body content

Returns:

  • (Symbol)

    :rewritten, :skipped_inline_string, :skipped_interpolation, :skipped_method_call, :skipped_cross_file, :skipped_unresolved, or :error



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/canon/rebaseliner.rb', line 75

def self.rewrite!(spec_path:, line:, prettyprinted_actual:)
  # Translate the caller-reported original line to its current
  # position in the on-disk file, accounting for any previous
  # rewrites that shifted subsequent lines.
  effective_line = shifted_line(spec_path, line)
  call_site = CallSiteResolver.resolve(spec_path: spec_path,
                                       line: effective_line)
  unless call_site
    Logger.log(:skipped_unresolved, spec_path: spec_path, line: line,
                                    detail: "no matcher call at line")
    return :skipped_unresolved
  end

  locator = HeredocLocator.new(
    spec_path: spec_path,
    source: call_site.source,
    enclosing_block: call_site.enclosing_block,
    expected_node: call_site.expected_node,
    matcher_line: call_site.matcher_line,
  )
  result = locator.resolve
  unless result.rewritable?
    Logger.log(result.status, spec_path: spec_path, line: line)
    return result.status
  end

  old_line_count = line_count_in_range(call_site.source,
                                       result.heredoc_spec)
  HeredocRewriter.rewrite!(result.heredoc_spec, prettyprinted_actual)
  count_newlines(File.read(spec_path)
    .byteslice(result.heredoc_spec.content_start_offset,
               File.size(spec_path) -
               result.heredoc_spec.content_start_offset))
  # Compute shift simply by re-reading the file size delta in lines.
  record_shift_from_disk(spec_path, call_site, result.heredoc_spec,
                         old_line_count)
  Logger.log(:rewritten, spec_path: spec_path, line: line)
  :rewritten
rescue StandardError => e
  Logger.log(:error, spec_path: spec_path, line: line,
                     detail: "#{e.class}: #{e.message}")
  :error
end

.shifted_line(spec_path, original_line) ⇒ Object

Translate an original caller line to the current line in the file, given accumulated shifts.



52
53
54
55
56
57
# File 'lib/canon/rebaseliner.rb', line 52

def self.shifted_line(spec_path, original_line)
  shifts = line_shifts[spec_path]
  shifts.inject(original_line) do |line, (threshold, delta)|
    original_line > threshold ? line + delta : line
  end
end