Module: Scryer::CacheExtractor

Defined in:
lib/scryer/cache_extractor.rb

Overview

Finds Rails.cache.fetch(key) { value } and Rails.cache.write(key, value) call sites and extracts the value being cached as a CacheCallInfo, so DuplicateDetector can flag the same computed value being cached under different keys — redundant cache entries that should share one key, or a sign the keys are inconsistent copy-paste rather than intentionally distinct. Rails.cache.fetch(key) with no block is a plain read (nothing is computed/stored there) and isn't a candidate.

Constant Summary collapse

MIN_TOKENS =

Skip trivially small cached values (true, nil, a bare literal). Lower than MethodExtractor/QueryExtractor's thresholds: Ripper doesn't emit separate terminal tokens for parens/commas on every call shape (e.g. foo(a, b) via :arg_paren can normalize to as few as 3 :ID tokens), so a cached value that's just "call this one method" is already near its natural token-count floor, not unusually small.

3

Class Method Summary collapse

Class Method Details

.block_body(block_node) ⇒ Object



92
93
94
95
96
97
98
99
100
# File 'lib/scryer/cache_extractor.rb', line 92

def block_body(block_node)
  case block_node&.first
  when :brace_block
    block_node[2]
  when :do_block
    bodystmt = block_node[2]
    Ast.tagged?(bodystmt, :bodystmt) ? bodystmt[1] : bodystmt
  end
end

.build_info(name:, file:, source:, key_node:, value_node:) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/scryer/cache_extractor.rb', line 102

def build_info(name:, file:, source:, key_node:, value_node:)
  tokens = Ast.normalized_tokens(value_node)
  return nil if tokens.size < MIN_TOKENS

  start_line, end_line = Ast.line_range_of(value_node)
  return nil unless start_line

  CacheCallInfo.new(
    name: name,
    file: file,
    start_line: start_line,
    end_line: end_line,
    token_stream: tokens,
    source_snippet: Ast.source_text(source, value_node),
    cache_key: key_display(source, key_node)
  )
end

.cache_method_name(node) ⇒ Object

Returns "fetch"/"write" if node is a Rails.cache.<method>(...) call ([:method_add_arg, [:call, [:call, Rails_const, ".", "cache"], ".", method], args]), else nil.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/scryer/cache_extractor.rb', line 70

def cache_method_name(node)
  return nil unless Ast.tagged?(node, :method_add_arg)

  call_node = node[1]
  return nil unless Ast.tagged?(call_node, :call)

  method_name = Ast.ident_text(call_node[3])
  return nil unless %w[fetch write].include?(method_name)

  receiver = call_node[1]
  return nil unless Ast.tagged?(receiver, :call)
  return nil unless Ast.ident_text(receiver[3]) == "cache"

  const_node = receiver[1]
  return nil unless Ast.tagged?(const_node, :var_ref, :vcall)

  ident = const_node[1]
  return nil unless ident.is_a?(Array) && ident[0] == :@const && ident[1] == "Rails"

  method_name
end

.extract(file:, source:, sexp:) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/scryer/cache_extractor.rb', line 29

def extract(file:, source:, sexp:)
  infos = []

  Ast.each_node(sexp) do |node|
    info =
      if Ast.tagged?(node, :method_add_block)
        from_fetch_block(node, file: file, source: source)
      elsif Ast.tagged?(node, :method_add_arg)
        from_write(node, file: file, source: source)
      end

    infos << info if info
  end

  infos
end

.from_fetch_block(node, file:, source:) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/scryer/cache_extractor.rb', line 46

def from_fetch_block(node, file:, source:)
  call_node = node[1]
  return nil unless cache_method_name(call_node) == "fetch"

  key_node = Ast.call_arguments(call_node).first
  return nil unless key_node

  body = block_body(node[2])
  return nil unless body

  build_info(name: "Rails.cache.fetch", file: file, source: source, key_node: key_node, value_node: body)
end

.from_write(node, file:, source:) ⇒ Object



59
60
61
62
63
64
65
66
# File 'lib/scryer/cache_extractor.rb', line 59

def from_write(node, file:, source:)
  return nil unless cache_method_name(node) == "write"

  args = Ast.call_arguments(node)
  return nil if args.size < 2

  build_info(name: "Rails.cache.write", file: file, source: source, key_node: args[0], value_node: args[1])
end

.key_display(source, key_node) ⇒ Object



120
121
122
# File 'lib/scryer/cache_extractor.rb', line 120

def key_display(source, key_node)
  Ast.plain_string_value(key_node) || Ast.exact_source_text(source, key_node)
end