Class: RuboCop::Cop::RSpec::UnusedLet::ScopeBuilder

Inherits:
Object
  • Object
show all
Includes:
Matchers, References
Defined in:
lib/rubocop/cop/rspec/unused_let/scope_builder.rb,
sig/rubocop/cop/rspec/unused_let/scope_builder.rbs

Overview

Turns an example/shared group AST node into a Scope, collecting the group's own definitions and references (but not those of nested groups, which become their own scopes). Kept out of both the cop (RuboCop lifecycle) and Scope (pure data and queries).

Constant Summary collapse

HELPER_SPEC_PATH =

rspec-rails infers type: :helper for spec files under spec/helpers.

Returns:

  • (Object)
%r{(?:^|/)spec/helpers/}.freeze
IMPLICIT_REFS_BY_TYPE =

let names that well-known gems' shared contexts implicitly reference, hidden from single-file analysis, keyed by the type: metadata that pulls the shared context in. When a group carries a matching type:, the listed names are recorded as references so that lets of those names (here or in a descendant) count as used.

Returns:

  • (Object)
{
  # rspec-validator_spec_helper
  # https://github.com/izumin5210/rspec-validator_spec_helper
  # `type: :validator` triggers a shared subject that dereferences
  # these names via `eval`, hidden from static analysis.
  validator: %i[
    value attribute_names options
    validator_name validator_class validator_type validation_name
    model_class
  ].freeze
}.freeze
IMPLICIT_REFS_BY_TAG =

The same, keyed by a bare symbol an example group carries as metadata (an RSpec tag) instead of by its type:. A tag and a type: of the same name are different metadata, so the two maps stay apart.

Returns:

  • (Object)
{
  # RuboCop's own cop spec support
  # https://github.com/rubocop/rubocop/blob/master/lib/rubocop/rspec/support.rb
  # Requiring it maps the `:config` tag to the `config` shared
  # context, whose `let`s a cop spec overrides, and unconditionally
  # includes `CopHelper`, whose `let`s it overrides too. Only the tag
  # is visible from the spec file, so it stands in for both.
  config: %i[
    cop_class cop_config other_cops cop_options gem_versions
    processed_source source_buffer all_cops_config cur_cop_config config cop
    source ruby_version parser_engine rails_version
  ].freeze
}.freeze

Constants included from References

References::DYNAMIC_DISPATCH_METHODS, References::SUBJECT_ALIASES

Constants included from Matchers

Matchers::BANG_HELPERS, Matchers::BLOCK_TYPES, Matchers::DEFINEE_SCOPE_TYPES, Matchers::ENCLOSING_BODY_TYPES, Matchers::SUBJECT_HELPERS

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from References

references_in, #self?.references_in

Methods included from Matchers

#anonymous_subject_definition, #carries_examples?, #definee_scope?, #example_group?, #example_of?, #example_send?, #inclusion_call?, #inclusion_name, #inline_inclusion?, #keyword_definee?, #let_definition, #named_subject_definition, #nested_inclusion?, #own_block_of?, #rspec_scope_block?, #shared_group_name, #spec_group?, #subject_definition, #subject_definition_head?

Constructor Details

#initialize(spec_filename, registry) ⇒ ScopeBuilder

Returns a new instance of ScopeBuilder.

RBS:

  • spec_filename: String?

  • registry: SharedExampleRegistry

Parameters:



58
59
60
61
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 58

def initialize(spec_filename, registry) #: void
  @spec_filename = spec_filename
  @registry = registry
end

Instance Attribute Details

#registrySharedExampleRegistry (readonly)

Signature:

  • SharedExampleRegistry

Returns:



83
84
85
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 83

def registry
  @registry
end

#spec_filenameString? (readonly)

Signature:

  • String?

Returns:

  • (String, nil)


82
83
84
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 82

def spec_filename
  @spec_filename
end

Instance Method Details

#build_from(node) ⇒ Scope

Build the Scope for node from its own region alone; nested groups are left for their own traversal.

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:



67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 67

def build_from(node) #: Scope
  kind = example_group?(node) ? :example : :shared #: Scope::kind
  type = type_from_group(node) || type_from_filename(spec_filename)
  scope = Scope.new(node: node, kind: kind, type: type, tags: tags_from_group(node),
                    carries_examples: carries_examples?(node))
  helpers = helper_nodes(node)
  collect_definitions(node, scope)
  helpers.each { record_helper_references(_1, scope) }
  collect_example_references(node, scope, helpers)
  inject_implicit_references(scope)
  scope
end

#candidate_helpers_in(node) ⇒ Array[RuboCop::AST::Node]

The defs in node's region that may turn out to be helpers of this group: every one no class/module body claims. Wider than #method_definitions_in: a def inside Class.new/class_eval/an unrecognized block still has its references read here, on the chance it runs in the example's scope after all.

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:

  • (Array[RuboCop::AST::Node])


232
233
234
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 232

def candidate_helpers_in(node) #: Array[RuboCop::AST::Node]
  node.each_descendant(:def).select { defined_in?(_1, node, &method(:keyword_definee?)) }
end

#collect_definitions(node, scope) ⇒ void

This method returns an undefined value.

RBS:

  • node: RuboCop::AST::Node

  • scope: Scope

Parameters:

  • node (RuboCop::AST::Node)
  • scope (Scope)


110
111
112
113
114
115
116
117
118
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 110

def collect_definitions(node, scope) #: void
  group = RuboCop::RSpec::ExampleGroup.new(node)
  group.lets.each do |let|
    helper, name = let_definition(let)
    scope.add_definition(helper, name.to_sym, let) if helper && name
  end
  collect_subject_definitions(group, scope)
  collect_method_definitions(node, scope)
end

#collect_example_references(node, scope, helpers) ⇒ void

This method returns an undefined value.

References in node's region that sit outside its helper bodies (examples and any other group-level code), collected into refs_in_example. Stops at nested spec groups and skips the helper definitions, whose references belong to refs. Resolves a shared inclusion found along the way.

A subject in a form helper_nodes misses (subject { _1 }, subject(:x, &blk)) reaches this walk, so its declaring call is left out of the references here too.

RBS:

  • node: RuboCop::AST::Node

  • scope: Scope

  • helpers: Array[RuboCop::AST::Node]

Parameters:

  • node (RuboCop::AST::Node)
  • scope (Scope)
  • helpers (Array[RuboCop::AST::Node])


161
162
163
164
165
166
167
168
169
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 161

def collect_example_references(node, scope, helpers) #: void
  node.each_child_node do |child|
    next if spec_group?(child) || helpers.any? { _1.equal?(child) }

    references_in(child).each { scope.add_reference_in_example(_1) } unless subject_definition_head?(child)
    record_inclusion(child, scope) if inclusion_call?(child)
    collect_example_references(child, scope, helpers)
  end
end

#collect_method_definitions(node, scope) ⇒ void

This method returns an undefined value.

def foo at an example group's level becomes an instance method on the group's example class, so it is a definition just like a let.

RBS:

  • node: RuboCop::AST::Node

  • scope: Scope

Parameters:

  • node (RuboCop::AST::Node)
  • scope (Scope)


141
142
143
144
145
146
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 141

def collect_method_definitions(node, scope) #: void
  method_definitions_in(node).each do |defn|
    inner = defn #: untyped
    scope.add_definition(:def, inner.method_name, defn)
  end
end

#collect_subject_definitions(group, scope) ⇒ void

This method returns an undefined value.

A subject is a let that additionally answers to the implicit name subject (see References::SUBJECT_ALIASES), recorded as its alias. An anonymous subject { } has no other name, so that alias is the only way to reach it.

RBS:

  • group: RuboCop::RSpec::ExampleGroup

  • scope: Scope

Parameters:



127
128
129
130
131
132
133
134
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 127

def collect_subject_definitions(group, scope) #: void
  group.subjects.each do |subject_node|
    helper, name = subject_definition(subject_node)
    next unless helper

    scope.add_definition(helper, name, subject_node, alias_name: :subject)
  end
end

#defined_in?(defn, group) {|arg0| ... } ⇒ Boolean

Whether defn is written directly in group's region: walking outward from it reaches group before a nested spec group (which collects the defs written in it on its own traversal) or a node owned_by_other calls true for, a boundary of its own.

RBS:

  • defn: RuboCop::AST::Node

  • group: RuboCop::AST::Node

  • &owned_by_other: (RuboCop::AST::Node) -> boolish

Parameters:

  • defn (RuboCop::AST::Node)
  • group (RuboCop::AST::Node)

Yields:

Yield Parameters:

  • arg0 (RuboCop::AST::Node)

Yield Returns:

  • (boolish)

Returns:

  • (Boolean)


256
257
258
259
260
261
262
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 256

def defined_in?(defn, group, &owned_by_other) #: bool
  defn.each_ancestor do |ancestor|
    return true if ancestor.equal?(group)
    return false if spec_group?(ancestor) || owned_by_other.call(ancestor)
  end
  false
end

#helper_nodes(node) ⇒ Array[RuboCop::AST::Node]

The nodes whose bodies are read as running in the example's scope: the group's own lets, subjects and hooks, plus every def written in its region (see #candidate_helpers_in).

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:

  • (Array[RuboCop::AST::Node])


210
211
212
213
214
215
216
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 210

def helper_nodes(node) #: Array[RuboCop::AST::Node]
  group = RuboCop::RSpec::ExampleGroup.new(node)
  group.lets +
    group.subjects +
    group.hooks.map(&:to_node) +
    candidate_helpers_in(node)
end

#implicit_reference_names(scope) ⇒ Array[Symbol]

RBS:

  • scope: Scope

Parameters:

Returns:

  • (Array[Symbol])


102
103
104
105
106
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 102

def implicit_reference_names(scope) #: Array[Symbol]
  by_type = IMPLICIT_REFS_BY_TYPE[scope.type] || []
  by_tag = scope.tags.flat_map { IMPLICIT_REFS_BY_TAG[_1] || [] }
  by_type + by_tag
end

#inject_implicit_references(scope) ⇒ void

This method returns an undefined value.

A well-known gem's shared context (pulled in by type: metadata or by a tag) can reference let names that single-file analysis never sees. Record them exactly as a real helper on this group would be: an example reference (justifying a let here or in an ancestor) and a helper reference (reaching lets in descendant groups, since helper bodies run in the example's scope).

RBS:

  • scope: Scope

Parameters:



94
95
96
97
98
99
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 94

def inject_implicit_references(scope) #: void
  implicit_reference_names(scope).each do |name|
    scope.add_reference_in_example(name)
    scope.add_reference(name)
  end
end

#mark_inline_bound_references(name, node, scope) ⇒ void

This method returns an undefined value.

An inline inclusion injects the block's definitions here, so its bound references (names it both defines and references) reach a same-named let in this scope — mark those referenced. Scoped to this group's own definitions, so it never suppresses an unrelated let in an ancestor or descendant group.

RBS:

  • name: Symbol | String

  • node: RuboCop::AST::Node

  • scope: Scope

Parameters:

  • name (Symbol, String)
  • node (RuboCop::AST::Node)
  • scope (Scope)


201
202
203
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 201

def mark_inline_bound_references(name, node, scope) #: void
  registry.bound_references(name, node).each { scope.mark_referenced(_1) }
end

#method_definitions_in(node) ⇒ Array[RuboCop::AST::Node]

def foo written at an example group's level becomes an instance method on the group's example class. Skip defs that belong elsewhere: nested inside a deeper example/shared group (not visible from node), or inside a definee scope written in the group (see Matchers#definee_scope?), which defines methods on that inner scope, not on the example class.

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:

  • (Array[RuboCop::AST::Node])


244
245
246
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 244

def method_definitions_in(node) #: Array[RuboCop::AST::Node]
  node.each_descendant(:def).select { defined_in?(_1, node, &method(:definee_scope?)) }
end

#record_helper_references(node, scope) ⇒ void

This method returns an undefined value.

RBS:

  • node: RuboCop::AST::Node

  • scope: Scope

Parameters:

  • node (RuboCop::AST::Node)
  • scope (Scope)


220
221
222
223
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 220

def record_helper_references(node, scope) #: void
  references_in(node).each { scope.add_reference(_1) } unless subject_definition_head?(node)
  node.each_child_node { record_helper_references(_1, scope) }
end

#record_inclusion(node, scope) ⇒ void

This method returns an undefined value.

A shared inclusion whose block is defined in this file consumes only its free references, recorded like any other example reference. An inclusion we cannot resolve (an unknown or dynamically named block) falls back to silencing every let visible at this point. An inline inclusion additionally reaches a same-named let here through its bound references, via #mark_inline_bound_references.

RBS:

  • node: RuboCop::AST::Node

  • scope: Scope

Parameters:

  • node (RuboCop::AST::Node)
  • scope (Scope)


180
181
182
183
184
185
186
187
188
189
190
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 180

def record_inclusion(node, scope) #: void
  name = inclusion_name(node)
  free_refs = name && registry.resolve(name, node)
  unless name && free_refs
    scope.mark_inclusion
    return
  end

  free_refs.each { scope.add_reference_in_example(_1) }
  mark_inline_bound_references(name, node, scope) if inline_inclusion?(node)
end

#tags_from_group(node) ⇒ Array[Symbol]

The bare symbols an example group carries as metadata. The first argument is skipped: a symbol there is the group's description (describe :config do), not a tag.

RSpec itself reads only the symbols trailing the argument list (a trailing hash aside), so describe X, :config, "extra" leaves :config in the description. Reading every symbol past the first over-collects there, which errs toward leaving a let alone rather than flagging one wrongly.

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:

  • (Array[Symbol])


290
291
292
293
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 290

def tags_from_group(node) #: Array[Symbol]
  block = node #: untyped
  block.send_node.arguments.drop(1).select(&:sym_type?).map(&:value)
end

#type_from_filename(filename) ⇒ Symbol?

rspec-rails infers a spec's type: from its location when none is set explicitly. Only :helper is inferred here, the one type the cop acts on.

RBS:

  • filename: String?

Parameters:

  • filename (String, nil)

Returns:

  • (Symbol, nil)


300
301
302
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 300

def type_from_filename(filename) #: Symbol?
  :helper if filename && HELPER_SPEC_PATH.match?(filename)
end

#type_from_group(node) ⇒ Symbol?

RBS:

  • node: RuboCop::AST::Node

Parameters:

  • node (RuboCop::AST::Node)

Returns:

  • (Symbol, nil)


265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/rubocop/cop/rspec/unused_let/scope_builder.rb', line 265

def type_from_group(node) #: Symbol?
  block = node #: untyped
  block.send_node.arguments.each do |arg|
    next unless arg.hash_type?

    arg.pairs.each do |pair|
      key = pair.key
      value = pair.value
      return value.value if key.sym_type? && key.value == :type && value.sym_type?
    end
  end
  nil
end