Class: RuboCop::Cop::Gusto::UnreferencedLet
- Inherits:
-
RSpec::Base
- Object
- RSpec::Base
- RuboCop::Cop::Gusto::UnreferencedLet
- 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 /superchain, including asubjectthat overrides aletof the same name) is never flagged; - a
letdeclared lexically inside ashared_examples/shared_examples_for/shared_contextblock is skipped (its consumers live in other files); - every
letin a file that usesit_behaves_like/it_should_behave_like/include_examples/include_contextis skipped, because an included shared block may reference the binding by a name we cannot follow statically; - any
letwhose name is also defined as alet/subjectin aspec/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:configshared context, not by a reference in the spec file; and- every
letin a file that reflectively dispatches through a name we cannot resolve statically (e.g.send("expected_#{type}")) is skipped, since anyletcould 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,:fooentries 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.
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:configshared context readscop_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
symor plainstr) -- 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
letwhose 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 =
The glob and the pathspec encode the SAME set of files two ways:
Dir.glob(fallback) and a regexp filter overgit ls-filesoutput. Keep them in sync if either changes. "**/spec/support/**/*.rb"- SUPPORT_FILES_PATHSPEC =
%r{(?:\A|/)spec/support/.+\.rb\z}
Class Method Summary collapse
- .extract_let_names(source, names) ⇒ Object
-
.framework_let_names ⇒ Object
Names defined as
let/subjectanywhere underspec/support/**. - .git_tracked_support_files ⇒ Object
- .read_source(path) ⇒ Object
- .scan_framework_let_names(paths) ⇒ Object
-
.support_file_paths ⇒ Object
Enumerate
spec/support/**/*.rb.
Instance Method Summary collapse
-
#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 /superchains (including asubjectthat overrides aletof the same name) are never flagged. - #on_send(node) ⇒ Object
Class Method Details
.extract_let_names(source, names) ⇒ Object
121 122 123 124 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 121 def extract_let_names(source, names) source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym } names end |
.framework_let_names ⇒ Object
Names defined as let/subject anywhere under spec/support/**. Computed once per
process (lazily, after boot) and shared across every file the cop inspects.
90 91 92 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 90 def framework_let_names @framework_let_names ||= scan_framework_let_names(support_file_paths) end |
.git_tracked_support_files ⇒ Object
106 107 108 109 110 111 112 113 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 106 def git_tracked_support_files output, status = ::Open3.capture2("git", "ls-files", "-z") return nil unless status.success? output.split("\x0").grep(SUPPORT_FILES_PATHSPEC) rescue ::SystemCallError nil end |
.read_source(path) ⇒ Object
126 127 128 129 130 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 126 def read_source(path) return "" unless ::File.file?(path) ::File.read(path, encoding: "UTF-8") end |
.scan_framework_let_names(paths) ⇒ Object
115 116 117 118 119 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 115 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_paths ⇒ Object
Enumerate spec/support/**/*.rb. Prefer git ls-files (reads the git index, skipping
untracked trees like node_modules): a leading-** Dir.glob walks the entire
repository and costs seconds, while reading the index costs tens of milliseconds. Fall
back to Dir.glob when not in a git work tree or git is unavailable.
Tradeoff: an untracked (brand-new, uncommitted) spec/support/*.rb override is invisible
to git ls-files. In that narrow window its contract names are not exempted; once
committed it is seen like any other support file.
102 103 104 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 102 def support_file_paths git_tracked_support_files || ::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.
83 84 85 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 83 def_node_matcher :definition_name, <<~PATTERN (any_block (send nil? %DEFINITION_METHODS (sym $_) ...) ...) PATTERN |
#on_send(node) ⇒ Object
133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 133 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 |