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.
@@ -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