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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75263525e5c9f837c2308c800c6ddd0fa8efa1472dc32aaca3eab36d4ba20990
4
- data.tar.gz: 734123f5f1ea481c6d1d8c31cddfa074483e5dbd7289e6f8965f33a724b0eb46
3
+ metadata.gz: 0e4d3db4927ef94c29b221307dd31182497a8c8b0fa8ab4776f7117effb1ea87
4
+ data.tar.gz: 0b96c12e598d65cb1b76198f1a0a962eb02b41e4cc1d86baaa8850afbf67fc44
5
5
  SHA512:
6
- metadata.gz: 3c54c23fd47f85a99f53c3d7deead137ce5b5fcfaa2559ce957c440f2f142246091fde3aa1b6a0407029377519f28751c45ce0f42a8abd02c035843121a857d3
7
- data.tar.gz: d3f5337a74934922e9d889564dc67f18be1a7371a2c24df4699cedb764df30c5278cca25b7872192235c4b4a93b4f044cfbe6393ef4e12bcfa769d7f06dda3bd
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
- # Extract options (id) if present
34
- opts = args.last.is_a?(Hash) && args.last.key?(:id) ? args.pop : {}
35
- id = opts[:id]
36
- assertion = :assert_equal
37
- message = nil
38
- args.each { |arg| arg.is_a?(Symbol) ? assertion = arg : message = arg }
39
- if actual.nil? # use specific assert_nil after deprecation of assert_equal nil
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
- opts = args.last.is_a?(Hash) && args.last.key?(:id) ? args.pop : {}
50
- id = opts[:id]
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/store'
3
+ require 'yaml'
4
4
 
5
5
  class Rematch
6
- # Subclass of YAML::Store
7
- class Store < YAML::Store
8
- # Use unsafe load, because tests are trusted content
9
- def load(content)
10
- YAML.unsafe_load(content) || {}
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 'digest/sha1'
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 = '4.1.0'
10
+ VERSION = '5.0.0'
10
11
  CONFIG = { ext: '.yaml' } # rubocop:disable Style/MutableConstant
11
12
 
12
- @rebuild = false # rebuild the store?
13
- @rebuilt = [] # paths already 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
- # Instantiated at each test, stores the path and the unique id of the test being run
30
- def initialize(test)
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
- # Retrieve the stored value for the current assertion if its key is known; store the value otherwise
39
- def rematch(value, overwrite: nil, id: nil)
40
- key = assertion_key(id)
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
- protected
36
+ store_path = "#{path}#{CONFIG[:ext]}"
37
+ check_rebuild(store_path)
58
38
 
59
- # Generate the unique id for the test (SHA1)
60
- def test_uid(class_name, method_name)
61
- Digest::SHA1.hexdigest("#{class_name}#{method_name}")
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
- private
65
-
66
- # Generate the key based on the line number, optional id, and test ID
67
- def assertion_key(id)
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
- # Ensure the keys are sorted by the order of the tests in the file
73
- # and remove keys that do not match any existing test (orphans)
74
- def tidy_store(store)
75
- # Optimization: only if Minitest is loaded
76
- return unless defined?(Minitest::Runnable)
77
-
78
- # Get all valid SHA1s from all runnables in this file
79
- valid_ids = []
80
- Minitest::Runnable.runnables.each do |runnable|
81
- runnable.runnable_methods.each do |method_name|
82
- file, = runnable.instance_method(method_name).source_location
83
- next unless file == @path # @path is the test file path
84
-
85
- valid_ids << test_uid(runnable.name, method_name)
86
- end
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.1.0
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: