Module: RSpec::InlineSnapshot::Matchers

Extended by:
Matchers::DSL
Defined in:
lib/rspec/inline_snapshot/matchers.rb

Constant Summary collapse

UNDEFINED_EXPECTED_VALUE =
:_inline_snapshot_undefined
REWRITERS =

FIXME: there’s probably a way to do this without abusing a constant but it works for now file => [parsed_source, corrector]

{}
FALSE_VALUES =
[
  nil,
  '',
  '0',
  'f',
  'false',
  'off'
].to_set.freeze

Instance Method Summary collapse

Instance Method Details

#env_to_boolean(value) ⇒ Object



161
162
163
# File 'lib/rspec/inline_snapshot/matchers.rb', line 161

def env_to_boolean(value)
  !FALSE_VALUES.include?(value&.downcase)
end

#format_replacement(actual, node, parsed_source) ⇒ Object

Format the actual value so it can be injected into the source as the new argument.



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/rspec/inline_snapshot/matchers.rb', line 87

def format_replacement(actual, node, parsed_source)
  if actual.is_a?(String)
    if actual.include?("\n")
      indent = parsed_source.line_indentation(node.location.first_line)
      [
        '(<<~SNAP.chomp)',
        actual.split("\n", -1).map { |line| "#{' ' * (indent + 2)}#{line}" },
        "#{' ' * indent}SNAP"
      ].join("\n")
    else
      "(#{actual.inspect})"
    end
  elsif actual.is_a?(NilClass) || actual.is_a?(Integer) || actual.is_a?(TrueClass) || actual.is_a?(FalseClass)
    "(#{actual.inspect})"
  elsif actual.respond_to?(:as_json)
    "(#{actual.as_json.inspect})"
  else
    raise ArgumentError,
          "Cannot snapshot. Actual (#{actual.class}) is not a String and does not implement #as_json"
  end
end

#match_or_update_inline_snapshot(matcher_name, expected, actual) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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
# File 'lib/rspec/inline_snapshot/matchers.rb', line 119

def match_or_update_inline_snapshot(matcher_name, expected, actual)
  if should_update_inline_snapshot?(expected)
    return false if running_in_ci?

    # General algorithm:
    #   1. Get location. RSpec.current_example.location
    #   2. Crawl Kernel.caller_locations until first hit at #absolute_path from 1
    #   3. Use #lineno as heuristic to find call to :match_inline_snapshot (matcher_name) in AST
    #   4. Rewrite first argument of method call
    source_file_path = RSpec.current_example.[:absolute_file_path]
    caller_location = Kernel.caller_locations.detect do |cl|
      cl.absolute_path == RSpec.current_example.[:absolute_file_path]
    end
    matcher_call_line_number = caller_location.lineno

    # Parse the spec file.
    # See:
    #   https://www.rubydoc.info/github/whitequark/parser/Parser/TreeRewriter
    #   https://medium.com/flippengineering/using-rubocop-ast-to-transform-ruby-files-using-abstract-syntax-trees-3e352e9ac916
    REWRITERS[source_file_path] ||= begin
      parsed_source = RuboCop::AST::ProcessedSource.from_file(source_file_path,
                                                              RUBY_VERSION.match(/\d+\.\d+/).to_s.to_f)
      corrector = ::RuboCop::Cop::Corrector.new(parsed_source)
      [parsed_source, corrector]
    end

    parsed_source, corrector = REWRITERS[source_file_path]

    parsed_source.ast.each_node(:send) do |node|
      next unless node.location.first_line >= matcher_call_line_number && node.method_name == matcher_name

      # found it! well we hope anyway because we're about to blow something away!
      corrector.replace(replacement_range(node, parsed_source), format_replacement(actual, node, parsed_source))

      return true # we replaced the target argument and overwrote the file. no point going further in this spec file.
    end
    raise "possible bug in inline snapshot matcher. Did not locate call to #{matcher_name} in #{source_file_path}"
  else
    RSpec::Support::FuzzyMatcher.values_match?(expected, actual)
  end
end

#replacement_range(node, parsed_source) ⇒ Object

This is fiddly. The AST is a bit annoying in that the #location and/or #source_range of a SendNode are only the expression, and do not consistently include recursive children in the range. So we have to do the math ourselves… case by case. We aim for the range to start at the open parentheses (if there is one) and end at the close parentheses or heredoc terminator.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/rspec/inline_snapshot/matchers.rb', line 49

def replacement_range(node, parsed_source)
  matcher_arg = node.arguments.last

  if matcher_arg.nil? && node.location.begin.nil?
    # no-args command-style call. ie. "match_inline_snapshot"
    Parser::Source::Range.new(
      parsed_source.buffer,
      node.location.expression.end_pos,
      node.location.expression.end_pos
    )
  elsif matcher_arg.is_a?(RuboCop::AST::StrNode) && matcher_arg.heredoc?
    # with heredoc parameter ie. "match_inline_snapshot(<<~SNAP) ... SNAP"
    Parser::Source::Range.new(
      parsed_source.buffer,
      node.location.begin.begin_pos,
      node.arguments.last.location.heredoc_end.end_pos
    )
  elsif matcher_arg.is_a?(RuboCop::AST::SendNode) &&
        matcher_arg.receiver.heredoc? &&
        matcher_arg.method_name == :chomp
    # with chomped heredoc parameter ie. "match_inline_snapshot(<<~SNAP.chomp) ... SNAP"
    Parser::Source::Range.new(
      parsed_source.buffer,
      node.location.begin.begin_pos,
      matcher_arg.receiver.location.heredoc_end.end_pos
    )
  else
    # no-args call with parens. ie. "match_inline_snapshot()"
    # or with a plain old parameter ie. "match_inline_snapshot('foo')"
    Parser::Source::Range.new(
      parsed_source.buffer,
      node.location.begin.begin_pos,
      node.location.end.end_pos
    )
  end
end

#running_in_ci?Boolean

Returns:

  • (Boolean)


115
116
117
# File 'lib/rspec/inline_snapshot/matchers.rb', line 115

def running_in_ci?
  env_to_boolean(ENV['CI'])
end

#should_update_inline_snapshot?(expected) ⇒ Boolean

Returns:

  • (Boolean)


109
110
111
112
113
# File 'lib/rspec/inline_snapshot/matchers.rb', line 109

def should_update_inline_snapshot?(expected)
  env_to_boolean(ENV['UPDATE_MATCH_SNAPSHOT']) ||
    env_to_boolean(ENV['UPDATE_SNAPSHOTS']) ||
    expected == UNDEFINED_EXPECTED_VALUE
end