Class: RuboCop::Cop::Gusto::UnreferencedLet

Inherits:
RSpec::Base
  • Object
show all
Extended by:
AutoCorrector
Includes:
RangeHelp
Defined in:
lib/rubocop/cop/gusto/unreferenced_let.rb

Overview

Flags lazy let declarations whose name is never referenced. A lazy let(:name) { ... } is only evaluated when name is called, so an unreferenced one is dead code -- its block never runs -- and is deleted.

Eager let! is intentionally out of scope: it runs its block before every example for its side effect even when unreferenced, so it cannot simply be deleted. Only plain let is handled here.

Detection is file-scoped: a let referenced only from another file (through a shared example or an included test harness) cannot be seen, so the cop stays conservative and prefers false negatives over false positives:

  • a name defined more than once in the file by let/let!/subject (an override / super chain, including a subject that overrides a let of the same name) is never flagged;
  • a let declared lexically inside a shared_examples / shared_examples_for / shared_context block is skipped (its consumers live in other files);
  • every let in a file that uses it_behaves_like / it_should_behave_like / include_examples / include_context is skipped, because an included shared block may reference the binding by a name we cannot follow statically;
  • any let whose name is also defined as a let/subject in a spec/support/** helper is skipped, because it is almost certainly overriding a contract an included harness consumes;
  • let(:cop_config) is skipped: it is a rubocop-rspec contract consumed by the :config shared context, not by a reference in the spec file; and
  • every let in a file that reflectively dispatches through a name we cannot resolve statically (e.g. send("expected_#{type}")) is skipped, since any let could be the target. A name counts as referenced if it is called bare (foo), appears as a symbol (:foo) anywhere but the let's own name argument, or appears as an identifier-shaped token inside any string/heredoc literal -- covering dynamic dispatch, :foo entries in data tables the spec later dispatches on, and bindings named only inside raw SQL/GraphQL text.

Because a bare :foo symbol anywhere counts as a reference, commonly-named lets (let(:user), let(:company), let(:id)) are essentially never flagged -- create(:user), :name hash keys, and the like saturate the file. This conservative bias means the cop realistically only deletes distinctively-named dead lets; it is not a complete dead-let finder.

Examples:

# bad (name never referenced -- deleted, the block never runs)
let(:unused) { create(:thing) }

# good
let(:thing) { create(:thing) }
it { expect(thing).to be_present }

Constant Summary collapse

DEFINITION_METHODS =
Set[:let, :let!, :subject].freeze
FRAMEWORK_RESERVED_NAMES =

lets consumed by a test framework rather than by a reference in the spec file. The rubocop-rspec :config shared context reads cop_config, so it is live even though the spec never names it.

i(cop_config).freeze
DYNAMIC_DISPATCH_METHODS =

Reflective dispatch methods whose target is the first argument. When that argument is not a statically-resolvable name (a sym or plain str) -- e.g. send("expected_#{type}") -- the called name cannot be known, so the whole file is left untouched.

i(send public_send __send__ try try! method public_method respond_to?).freeze
FRAMEWORK_LET_PATTERN =
/\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
IDENTIFIER_IN_STRING =

Identifier-shaped tokens inside a string/heredoc literal. A let whose name appears only inside string text -- e.g. a binding or column referenced in raw SQL/GraphQL the spec later executes -- counts as referenced, so it is not deleted.

/[A-Za-z_]\w*[!?]?/
MSG =
"Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
RESTRICT_ON_SEND =
i(let).freeze
SUPPORT_FILES_GLOB =
"**/spec/support/**/*.rb"

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.extract_let_names(source, names) ⇒ Object



103
104
105
106
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 103

def extract_let_names(source, names)
  source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym }
  names
end

.framework_let_namesObject

Names defined as let/subject anywhere under spec/support/**. Computed once per process (lazily, after boot) and shared across every file the cop inspects.



86
87
88
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 86

def framework_let_names
  @framework_let_names ||= scan_framework_let_names(support_file_paths)
end

.read_source(path) ⇒ Object



108
109
110
111
112
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 108

def read_source(path)
  return "" unless ::File.file?(path)

  ::File.read(path, encoding: "UTF-8")
end

.scan_framework_let_names(paths) ⇒ Object



97
98
99
100
101
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 97

def scan_framework_let_names(paths)
  paths.each_with_object(Set.new) do |path, names|
    extract_let_names(read_source(path), names)
  end
end

.support_file_pathsObject

Enumerate spec/support/**/*.rb. No git dependency: some environments (e.g. a build step's working directory) are not a git work tree at all, and shelling out to git there is unreliable and noisy.



93
94
95
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 93

def support_file_paths
  ::Dir.glob(SUPPORT_FILES_GLOB)
end

Instance Method Details

#definition_name(node) ⇒ Object

The name symbol of any definition (let/let!/subject) in any block form -- used to count how many times a name is defined, so override / super chains (including a subject that overrides a let of the same name) are never flagged.



79
80
81
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 79

def_node_matcher :definition_name, "(any_block (send nil? %DEFINITION_METHODS (sym $_) ...) ...)\n"

#on_send(node) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 115

def on_send(node)
  return unless node.receiver.nil?

  name_argument = node.first_argument
  return unless name_argument&.sym_type?

  block = node.block_node
  return unless block

  name = name_argument.value
  return if exempt_from_deletion?(name, block)

  add_offense(node.loc.selector, message: format(MSG, name:)) do |corrector|
    corrector.remove(removal_range(block))
  end
end