canon 0.3.26 → 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 +30 -2
- data/docs/features/regenerate-expected.adoc +157 -0
- data/lib/canon/comparison/diff_node_builder.rb +9 -0
- data/lib/canon/comparison/xml_comparator/attribute_filter.rb +34 -4
- data/lib/canon/config/env_schema.rb +6 -0
- data/lib/canon/diff_formatter/by_line/html_formatter.rb +4 -296
- 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 +10 -2
|
@@ -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
|