rematch 4.1.0 → 5.0.0
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/lib/minitest/rematch_plugin.rb +15 -15
- data/lib/rematch/source.rb +162 -0
- data/lib/rematch/store.rb +38 -6
- data/lib/rematch.rb +49 -65
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e4d3db4927ef94c29b221307dd31182497a8c8b0fa8ab4776f7117effb1ea87
|
|
4
|
+
data.tar.gz: 0b96c12e598d65cb1b76198f1a0a962eb02b41e4cc1d86baaa8850afbf67fc44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb9d8b792ae087b2e555f5d2260ab27ef795117d5636dc3d328a9abbf481bcbab4bc34100527bbf37ecab3df85fe961420a58f97f5ee0a44cd8d60d0839fd1ce
|
|
7
|
+
data.tar.gz: e6626b52c71bb840b320b99399bb601c6ad0650e63007b47a66e884a87caf7b2dceeb2c7ed3a82642f75c47a7b3c3a242a0a29d28e785b18b6c95253b038f1a8
|
|
@@ -24,32 +24,32 @@ module Minitest
|
|
|
24
24
|
super
|
|
25
25
|
@rematch = Rematch.new(self)
|
|
26
26
|
end
|
|
27
|
+
|
|
28
|
+
# Ensure store is tidied and saved after the test runs
|
|
29
|
+
def before_teardown
|
|
30
|
+
super
|
|
31
|
+
@rematch&.save
|
|
32
|
+
end
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
# Reopen the minitest module
|
|
30
36
|
module Assertions
|
|
31
37
|
# Main assertion
|
|
32
38
|
def assert_rematch(actual, *args)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
assert_nil @rematch.rematch(actual, id: id), message
|
|
41
|
-
else
|
|
42
|
-
# assert that the stored value is the same actual value
|
|
43
|
-
send assertion, @rematch.rematch(actual, id: id), actual, message
|
|
39
|
+
label = Rematch.extract_label(args)
|
|
40
|
+
assertion, message = args
|
|
41
|
+
assertion, message = message, assertion unless assertion.nil? || assertion.is_a?(Symbol)
|
|
42
|
+
if actual.nil? # use specific assert_nil after deprecation of assert_equal nil
|
|
43
|
+
assert_nil @rematch.rematch(actual, label:), message
|
|
44
|
+
else # assert that the stored value is the same actual value
|
|
45
|
+
send assertion || :assert_equal, @rematch.rematch(actual, label:), actual, message
|
|
44
46
|
end
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
# Temporarily used to store the actual value, useful for reconciliation of expected changed values
|
|
48
50
|
def store_assert_rematch(actual, *args)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
@rematch.rematch(actual, overwrite: true, id: id)
|
|
51
|
+
label = Rematch.extract_label(args)
|
|
52
|
+
@rematch.rematch(actual, overwrite: true, label:)
|
|
53
53
|
# Always fail after storing, forcing the restore of the original assertion/expectation
|
|
54
54
|
raise Minitest::Assertion, '[rematch] the value has been stored: remove the "store_" prefix to pass the test'
|
|
55
55
|
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ripper'
|
|
4
|
+
require 'digest/sha1'
|
|
5
|
+
|
|
6
|
+
class Rematch
|
|
7
|
+
# Parses a Ruby source file to statically identify the context (path) and content of every line.
|
|
8
|
+
class Source
|
|
9
|
+
attr_reader :index
|
|
10
|
+
|
|
11
|
+
REMATCH_METHODS = %w[assert_rematch store_assert_rematch
|
|
12
|
+
must_rematch to_rematch store_must_rematch store_to_rematch].freeze
|
|
13
|
+
IGNORED_TOKENS = (REMATCH_METHODS + %w[expect value _]).freeze
|
|
14
|
+
DOT_TOKENS = ['.', '&.'].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(source)
|
|
17
|
+
@source = source
|
|
18
|
+
@lines = source.lines
|
|
19
|
+
@index = Hash.new { |h, k| h[k] = [] } # { lineno => [id1, id2, ...] }
|
|
20
|
+
@context = [] # Context stack
|
|
21
|
+
@contexts = {} # { lineno => context_array }
|
|
22
|
+
if (sexp = Ripper.sexp(@source))
|
|
23
|
+
walk(sexp)
|
|
24
|
+
build_index
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def walk(node)
|
|
31
|
+
return unless node.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
if (lineno = extract_line(node))
|
|
34
|
+
@contexts[lineno] ||= @context.dup
|
|
35
|
+
end
|
|
36
|
+
case node.first
|
|
37
|
+
when :class, :module
|
|
38
|
+
name = extract_name(node[1])
|
|
39
|
+
@context.push(name)
|
|
40
|
+
walk(node.last)
|
|
41
|
+
@context.pop
|
|
42
|
+
when :def
|
|
43
|
+
name = node[1][1]
|
|
44
|
+
@context.push(name)
|
|
45
|
+
walk(node[3]) # body
|
|
46
|
+
@context.pop
|
|
47
|
+
when :method_add_block
|
|
48
|
+
handle_block(node)
|
|
49
|
+
else
|
|
50
|
+
node.each { |child| walk(child) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def handle_block(node)
|
|
55
|
+
call_node = node[1]
|
|
56
|
+
block_node = node[2]
|
|
57
|
+
method_name = extract_method_name(call_node)
|
|
58
|
+
if %w[describe context it specify].include?(method_name)
|
|
59
|
+
description = extract_arg_string(call_node)
|
|
60
|
+
@context.push(description)
|
|
61
|
+
walk(block_node)
|
|
62
|
+
@context.pop
|
|
63
|
+
else
|
|
64
|
+
walk(call_node)
|
|
65
|
+
walk(block_node)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_index
|
|
70
|
+
@lines.each_with_index do |line_content, i|
|
|
71
|
+
lineno = i + 1
|
|
72
|
+
tokens = tokenize(line_content)
|
|
73
|
+
next if tokens.empty?
|
|
74
|
+
|
|
75
|
+
context = @contexts[lineno] || [] # Use recorded context or fallback to empty (top-level)
|
|
76
|
+
@index[lineno] << Digest::SHA1.hexdigest("#{context.join('/')}#{tokens}")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tokenize(line_content)
|
|
81
|
+
raw_tokens = Ripper.lex(line_content).reject { |t| t[1] == :on_comment || t[1] =~ /sp|nl/ }
|
|
82
|
+
tokens = []
|
|
83
|
+
raw_tokens.each_with_index do |t, i|
|
|
84
|
+
token = t[2]
|
|
85
|
+
if DOT_TOKENS.include?(token)
|
|
86
|
+
next_token = raw_tokens[i + 1]
|
|
87
|
+
next if next_token && REMATCH_METHODS.include?(next_token[2]) # Ignore dot if rematch
|
|
88
|
+
end
|
|
89
|
+
next if IGNORED_TOKENS.include?(token)
|
|
90
|
+
|
|
91
|
+
tokens << token
|
|
92
|
+
end
|
|
93
|
+
tokens.join
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- Extractors ---
|
|
97
|
+
|
|
98
|
+
def extract_line(node)
|
|
99
|
+
# Terminals like [:@ident, "name", [line, col]]
|
|
100
|
+
node.last[0] if node.first.is_a?(Symbol) &&
|
|
101
|
+
node.first.to_s.start_with?('@') &&
|
|
102
|
+
node.last.is_a?(Array) &&
|
|
103
|
+
node.last.size == 2
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_name(node)
|
|
107
|
+
return '' unless node.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
case node.first
|
|
110
|
+
when :const_path_ref then "#{extract_name(node[1])}::#{extract_name(node[2])}"
|
|
111
|
+
when :var_ref, :const_ref then extract_name(node[1])
|
|
112
|
+
when :call then "#{extract_name(node[1])}.#{extract_name(node[3])}"
|
|
113
|
+
when :@const, :@ident, :@op then node[1]
|
|
114
|
+
else 'Unknown'
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_method_name(node)
|
|
119
|
+
return nil unless node.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
case node.first
|
|
122
|
+
when :command, :fcall then node[1][1]
|
|
123
|
+
when :call, :command_call then node[3][1]
|
|
124
|
+
when :method_add_arg, :method_add_block then extract_method_name(node[1])
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def extract_arg_string(node)
|
|
129
|
+
args_node = case node.first
|
|
130
|
+
when :command, :command_call then node[2]
|
|
131
|
+
when :method_add_arg, :method_add_block then node[1][2]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return 'anonymous' if args_node.nil? || args_node.empty?
|
|
135
|
+
|
|
136
|
+
args_list = args_node.first == :args_add_block ? args_node[1] : args_node
|
|
137
|
+
return 'anonymous' if args_list.nil? || args_list.empty?
|
|
138
|
+
|
|
139
|
+
arg = args_list.first
|
|
140
|
+
return 'anonymous' unless arg
|
|
141
|
+
|
|
142
|
+
case arg.first
|
|
143
|
+
when :string_literal
|
|
144
|
+
content_node = arg[1]
|
|
145
|
+
if content_node&.first == :string_content
|
|
146
|
+
content_node[1..].map { |p| p[1] if p.is_a?(Array) && p.first == :@tstring_content }.join
|
|
147
|
+
else
|
|
148
|
+
''
|
|
149
|
+
end
|
|
150
|
+
when :symbol_literal
|
|
151
|
+
begin
|
|
152
|
+
arg[1][1][1]
|
|
153
|
+
rescue StandardError
|
|
154
|
+
'unknown_sym'
|
|
155
|
+
end
|
|
156
|
+
else extract_name(arg)
|
|
157
|
+
end
|
|
158
|
+
rescue StandardError
|
|
159
|
+
'unknown_arg'
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
data/lib/rematch/store.rb
CHANGED
|
@@ -1,13 +1,45 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'yaml
|
|
3
|
+
require 'yaml'
|
|
4
4
|
|
|
5
5
|
class Rematch
|
|
6
|
-
#
|
|
7
|
-
class Store
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
# A simple Hash-based store that syncs with a static source map
|
|
7
|
+
class Store
|
|
8
|
+
attr_reader :path
|
|
9
|
+
|
|
10
|
+
def initialize(path, source_ids)
|
|
11
|
+
@path = path
|
|
12
|
+
@entries = (File.exist?(path) && YAML.unsafe_load_file(path)) || {}
|
|
13
|
+
# Prune dead keys (id not present ANYWHERE in the current source)
|
|
14
|
+
@entries.select! { |key, _| source_ids.include?(key.split.last) }
|
|
15
|
+
# Build Index: id => [key1, key2...] (FIFO queue for duplicates)
|
|
16
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
@entries.each_key { |key| @index[key.split.last] << key }
|
|
18
|
+
# Order the index queues by lineno to ensure FIFO claiming
|
|
19
|
+
@index.each_value { |keys| keys.sort_by! { |k| order_by_lineno(k) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def pull(id) = @index[id].shift # Remove and return the first key from the index id
|
|
23
|
+
def delete(key) = @entries.delete(key)
|
|
24
|
+
def [](key) = @entries[key]
|
|
25
|
+
|
|
26
|
+
def []=(key, value)
|
|
27
|
+
@entries[key] = value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def save
|
|
31
|
+
return FileUtils.rm_f(@path) if @entries.empty?
|
|
32
|
+
|
|
33
|
+
sorted = @entries.sort_by { |k, _| order_by_lineno(k) }.to_h
|
|
34
|
+
content = YAML.dump(sorted, line_width: -1)
|
|
35
|
+
return if File.exist?(@path) && File.read(@path) == content # Return if unchanged (preserves mtime)
|
|
36
|
+
|
|
37
|
+
File.write(@path, content)
|
|
11
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Order by L<num> and .<count> suffix
|
|
43
|
+
def order_by_lineno(key) = [key[/L(\d+)/, 1].to_i, key[/L\d+\.(\d+)/, 1].to_i]
|
|
12
44
|
end
|
|
13
45
|
end
|
data/lib/rematch.rb
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
-
require '
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'rematch/source'
|
|
5
6
|
require_relative 'rematch/store'
|
|
6
7
|
|
|
7
|
-
# Handles the key/value store for each test
|
|
8
|
+
# Handles the key/value store for each test file
|
|
8
9
|
class Rematch
|
|
9
|
-
VERSION = '
|
|
10
|
+
VERSION = '5.0.0'
|
|
10
11
|
CONFIG = { ext: '.yaml' } # rubocop:disable Style/MutableConstant
|
|
11
12
|
|
|
12
|
-
@rebuild = false
|
|
13
|
-
@rebuilt = []
|
|
13
|
+
@rebuild = false
|
|
14
|
+
@rebuilt = []
|
|
14
15
|
@skip_warning = false
|
|
16
|
+
@cache = {} # Cache per file path: { source: Source, store: Store }
|
|
15
17
|
|
|
16
18
|
class << self
|
|
17
|
-
attr_accessor :rebuild, :skip_warning
|
|
19
|
+
attr_accessor :rebuild, :skip_warning, :cache
|
|
18
20
|
|
|
19
|
-
# Check whether path requires rebuild and do it if required
|
|
20
21
|
def check_rebuild(path)
|
|
21
22
|
return unless @rebuild && !@rebuilt.include?(path)
|
|
22
23
|
|
|
@@ -24,72 +25,55 @@ class Rematch
|
|
|
24
25
|
@rebuilt << path
|
|
25
26
|
puts "Rebuilt #{path}"
|
|
26
27
|
end
|
|
27
|
-
end
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@path = test.method(test.name).source_location.first
|
|
32
|
-
store_path = "#{@path}#{CONFIG[:ext]}"
|
|
33
|
-
self.class.check_rebuild(store_path)
|
|
34
|
-
@store = Store.new(store_path, true)
|
|
35
|
-
@id = test_uid(test.class.name, test.name)
|
|
36
|
-
end
|
|
29
|
+
# Utility used by the plugin to extract label from args (kept for compatibility)
|
|
30
|
+
def extract_label(args) = ((args.last.is_a?(Hash) && args.last.key?(:label) && args.pop) || {})[:label]
|
|
37
31
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@store.transaction do |s|
|
|
42
|
-
if s.root?(key) && !overwrite # there is the key and not overwrite
|
|
43
|
-
s[key] # return it
|
|
44
|
-
else # no such key or overwrite
|
|
45
|
-
s[key] = value # set it
|
|
46
|
-
tidy_store(s) # sort keys and cleanup orphans
|
|
47
|
-
store_warning(key) unless overwrite
|
|
48
|
-
value
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def store_warning(key)
|
|
54
|
-
warn "Rematch stored new value for: #{key.inspect}\n#{@store.path}\n\n" unless Rematch.skip_warning
|
|
55
|
-
end
|
|
32
|
+
# Get source and store for the test file (cached because invoked for every test in the file)
|
|
33
|
+
def environment(path)
|
|
34
|
+
return cache[path] if cache[path]
|
|
56
35
|
|
|
57
|
-
|
|
36
|
+
store_path = "#{path}#{CONFIG[:ext]}"
|
|
37
|
+
check_rebuild(store_path)
|
|
58
38
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
39
|
+
source = Source.new(File.read(path))
|
|
40
|
+
source_ids = source.index.values.flatten.to_set
|
|
41
|
+
store = Store.new(store_path, source_ids)
|
|
42
|
+
cache[path] = { source: source, store: store }
|
|
43
|
+
end
|
|
62
44
|
end
|
|
63
45
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
line = caller_locations.find { |l| l.path == @path }&.lineno
|
|
69
|
-
%(L#{line}#{" [#{id}]" if id} #{@id})
|
|
46
|
+
def initialize(test)
|
|
47
|
+
@path = test.method(test.name).source_location.first
|
|
48
|
+
@env = self.class.environment(@path)
|
|
49
|
+
@counts = Hash.new(0) # Track rematch counts per line
|
|
70
50
|
end
|
|
71
51
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
52
|
+
# Retrieves the stored value for the current assertion, updating the store if the key is new or moved.
|
|
53
|
+
def rematch(value, overwrite: nil, label: nil)
|
|
54
|
+
lineno = caller_locations.find { |l| l.path == @path }&.lineno
|
|
55
|
+
ids = @env[:source].index[lineno]
|
|
56
|
+
raise "Rematch Error: No code detected at #{@path}:#{lineno}. Please check syntax." if ids.empty? # never happen
|
|
57
|
+
|
|
58
|
+
count = (@counts[lineno] += 1)
|
|
59
|
+
id = ids[(count - 1) % ids.size]
|
|
60
|
+
new_key = %(L#{lineno}#{".#{count}" if count > 1}#{" [#{label}]" if label} #{id})
|
|
61
|
+
store = @env[:store]
|
|
62
|
+
old_key = store.pull(id)
|
|
63
|
+
if old_key && old_key != new_key # Reconcile Move first to ensure clean state
|
|
64
|
+
store[new_key] = store.delete(old_key)
|
|
65
|
+
old_key = new_key # Normalize so we only check the verwrite logic below
|
|
66
|
+
end
|
|
67
|
+
case
|
|
68
|
+
when !old_key
|
|
69
|
+
warn "Rematch stored new value generated by: #{@path}:#{lineno}\n" unless overwrite || self.class.skip_warning
|
|
70
|
+
store[new_key] = value
|
|
71
|
+
when overwrite
|
|
72
|
+
store[new_key] = value
|
|
73
|
+
else
|
|
74
|
+
store[new_key]
|
|
87
75
|
end
|
|
88
|
-
# Extract all data
|
|
89
|
-
data = store.roots.to_h { |key| [key, store.delete(key)] }
|
|
90
|
-
# Re-add data in the correct order, filter orphans, and sort
|
|
91
|
-
data.select { |key, _| valid_ids.include?(key.split.last) }
|
|
92
|
-
.sort_by { |key, _| key[/L(\d+)/, 1].to_i }
|
|
93
|
-
.each { |key, value| store[key] = value }
|
|
94
76
|
end
|
|
77
|
+
|
|
78
|
+
def save = @env[:store].save
|
|
95
79
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rematch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 5.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Domizio Demichelis
|
|
@@ -49,6 +49,7 @@ files:
|
|
|
49
49
|
- LICENSE.txt
|
|
50
50
|
- lib/minitest/rematch_plugin.rb
|
|
51
51
|
- lib/rematch.rb
|
|
52
|
+
- lib/rematch/source.rb
|
|
52
53
|
- lib/rematch/store.rb
|
|
53
54
|
homepage: https://github.com/ddnexus/rematch
|
|
54
55
|
licenses:
|