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 / `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.
Constant Summary collapse
- DEFINITION_METHODS =
%i(let let! subject).freeze
- FRAMEWORK_RESERVED_NAMES =
‘let`s 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 =
The glob and the pathspec encode the SAME set of files two ways: ‘Dir.glob` (fallback) and a regexp filter over `git ls-files` output. 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`/`subject` anywhere under `spec/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 / `super` chains (including a `subject` that overrides a `let` of the same name) are never flagged.
- #on_send(node) ⇒ Object
Class Method Details
.extract_let_names(source, names) ⇒ Object
120 121 122 123 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 120 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.
89 90 91 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 89 def framework_let_names @framework_let_names ||= scan_framework_let_names(support_file_paths) end |
.git_tracked_support_files ⇒ Object
105 106 107 108 109 110 111 112 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 105 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
125 126 127 128 129 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 125 def read_source(path) return "" unless ::File.file?(path) ::File.read(path) end |
.scan_framework_let_names(paths) ⇒ Object
114 115 116 117 118 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 114 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.
101 102 103 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 101 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.
82 83 84 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 82 def_node_matcher :definition_name, <<~PATTERN (any_block (send nil? {#{DEFINITION_METHODS.map { ":#{it}" }.join(' ')}} (sym $_) ...) ...) PATTERN |
#on_send(node) ⇒ Object
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 132 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 |