Module: WhyClasses::AST::NodeHelpers

Defined in:
lib/why_classes/ast/node_helpers.rb

Overview

Predicates over the parser-gem AST. Every helper operates on the direct body of a class/module node and never recurses into nested classes, so a rule handling an outer class does not accidentally analyse an inner one.

Constant Summary collapse

Node =
::Parser::AST::Node
METAPROGRAMMING_SENDS =
%i[
  define_method define_singleton_method instance_variable_set
  instance_variable_get method_missing respond_to_missing?
  class_eval instance_eval module_eval const_set
  attr define_attribute_methods
].freeze
NESTED_SCOPE_TYPES =
%i[class module].freeze

Class Method Summary collapse

Class Method Details

.attr_fields(class_node) ⇒ Object

All field symbols exposed by any attr_* macro.



82
83
84
# File 'lib/why_classes/ast/node_helpers.rb', line 82

def attr_fields(class_node)
  attr_macros(class_node).values.flatten.uniq
end

.attr_macros(class_node) ⇒ Object

attr_reader/attr_writer/attr_accessor macro calls -> flat list of field symbols keyed by macro name.



67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/why_classes/ast/node_helpers.rb', line 67

def attr_macros(class_node)
  result = { attr_reader: [], attr_writer: [], attr_accessor: [] }
  body_statements(class_body(class_node)).each do |stmt|
    next unless node?(stmt) && stmt.type == :send && stmt.children[0].nil?

    macro = stmt.children[1]
    next unless result.key?(macro)

    fields = stmt.children[2..].select { |a| node?(a) && a.type == :sym }.map { |a| a.children[0] }
    result[macro].concat(fields)
  end
  result
end

.body_statements(body) ⇒ Object

The list of top-level statements in a class/module/def body.



18
19
20
21
22
23
# File 'lib/why_classes/ast/node_helpers.rb', line 18

def body_statements(body)
  return [] if body.nil?
  return body.children if body.type == :begin

  [body]
end

.class_body(class_node) ⇒ Object

Body node of a :class (children) or :module (children).



26
27
28
# File 'lib/why_classes/ast/node_helpers.rb', line 26

def class_body(class_node)
  class_node.type == :module ? class_node.children[1] : class_node.children[2]
end

.class_name(class_node) ⇒ Object



38
39
40
# File 'lib/why_classes/ast/node_helpers.rb', line 38

def class_name(class_node)
  const_path(class_node.children[0])
end

.collect(node, cross_scopes: false, &block) ⇒ Object

Recursively collect nodes matching the block. By default the walk stays within the current type definition: it descends into method bodies (def/defs) and class << self (sclass) but not into nested class or module definitions, so a class's analysis never bleeds into an inner one.



164
165
166
167
168
# File 'lib/why_classes/ast/node_helpers.rb', line 164

def collect(node, cross_scopes: false, &block)
  found = []
  walk_local(node, cross_scopes, found, &block)
  found
end

.const_path(const_node) ⇒ Object

"Foo::Bar" for a const node, else nil.



31
32
33
34
35
36
# File 'lib/why_classes/ast/node_helpers.rb', line 31

def const_path(const_node)
  return nil unless node?(const_node) && const_node.type == :const

  scope, name = const_node.children
  scope.nil? ? name.to_s : "#{const_path(scope)}::#{name}"
end

.initialize_method(class_node) ⇒ Object

Names of def initialize in the body.



97
98
99
# File 'lib/why_classes/ast/node_helpers.rb', line 97

def initialize_method(class_node)
  instance_methods(class_node).find { |m| m.children[0] == :initialize }
end

.initialize_only_assigns_params?(class_node) ⇒ Boolean

True when initialize only assigns instance variables straight from its own parameters (no logic, no defaults, no computed values).

Returns:

  • (Boolean)


103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/why_classes/ast/node_helpers.rb', line 103

def initialize_only_assigns_params?(class_node)
  init = initialize_method(class_node)
  return false unless init

  params = param_names(init.children[1])
  # No default values / splats / blocks allowed.
  return false unless simple_params?(init.children[1])

  assignments = body_statements(init.children[2])
  return false if assignments.empty?

  assignments.all? do |stmt|
    node?(stmt) && stmt.type == :ivasgn &&
      (value = stmt.children[1]) &&
      value.type == :lvar &&
      params.include?(value.children[0])
  end
end

.instance_methods(class_node) ⇒ Object

Instance method definitions declared directly in the body (:def).



50
51
52
# File 'lib/why_classes/ast/node_helpers.rb', line 50

def instance_methods(class_node)
  body_statements(class_body(class_node)).select { |n| node?(n) && n.type == :def }
end

.ivars_assigned(node) ⇒ Object

Instance variables assigned anywhere in the subtree.



87
88
89
# File 'lib/why_classes/ast/node_helpers.rb', line 87

def ivars_assigned(node)
  collect(node) { |n| n.type == :ivasgn }.map { |n| n.children[0] }.uniq
end

.ivars_read(node) ⇒ Object

Instance variables read anywhere in the subtree.



92
93
94
# File 'lib/why_classes/ast/node_helpers.rb', line 92

def ivars_read(node)
  collect(node) { |n| n.type == :ivar }.map { |n| n.children[0] }.uniq
end

.keyword_params?(args_node) ⇒ Boolean

True when all parameters are keyword arguments.

Returns:

  • (Boolean)


138
139
140
141
142
# File 'lib/why_classes/ast/node_helpers.rb', line 138

def keyword_params?(args_node)
  return false if args_node.nil? || args_node.children.empty?

  args_node.children.all? { |a| node?(a) && %i[kwarg kwoptarg].include?(a.type) }
end

.node?(obj) ⇒ Boolean

Returns:

  • (Boolean)


13
14
15
# File 'lib/why_classes/ast/node_helpers.rb', line 13

def node?(obj)
  obj.is_a?(Node)
end

.param_names(args_node) ⇒ Object

Parameter names of an :args node.



123
124
125
126
127
# File 'lib/why_classes/ast/node_helpers.rb', line 123

def param_names(args_node)
  return [] unless node?(args_node)

  args_node.children.map { |a| a.children[0] }
end

.simple_params?(args_node) ⇒ Boolean

True when every parameter is a plain required positional (:arg) or required keyword (:kwarg) -- no defaults, splats, or blocks.

Returns:

  • (Boolean)


131
132
133
134
135
# File 'lib/why_classes/ast/node_helpers.rb', line 131

def simple_params?(args_node)
  return true if args_node.nil?

  args_node.children.all? { |a| node?(a) && %i[arg kwarg].include?(a.type) }
end

.singleton_methods(class_node) ⇒ Object

Singleton methods: def self.foo (:defs) plus methods inside class << self (:sclass) blocks.



56
57
58
59
60
61
62
63
# File 'lib/why_classes/ast/node_helpers.rb', line 56

def singleton_methods(class_node)
  stmts = body_statements(class_body(class_node))
  defs = stmts.select { |n| node?(n) && n.type == :defs }
  sclass_defs = stmts.select { |n| node?(n) && n.type == :sclass }
                     .flat_map { |sc| body_statements(sc.children[1]) }
                     .select { |n| node?(n) && n.type == :def }
  defs + sclass_defs
end

.superclass_path(class_node) ⇒ Object

Superclass const path for a :class node, or nil.



43
44
45
46
47
# File 'lib/why_classes/ast/node_helpers.rb', line 43

def superclass_path(class_node)
  return nil unless class_node.type == :class

  const_path(class_node.children[1])
end

.uses_metaprogramming?(class_node) ⇒ Boolean

Static analysis is unreliable in the presence of metaprogramming, so any rule that would rewrite code must bail to advice-only when this is true.

Returns:

  • (Boolean)


153
154
155
156
157
158
# File 'lib/why_classes/ast/node_helpers.rb', line 153

def uses_metaprogramming?(class_node)
  collect(class_node) do |n|
    (n.type == :send && METAPROGRAMMING_SENDS.include?(n.children[1])) ||
      n.type == :def && %i[method_missing respond_to_missing?].include?(n.children[0])
  end.any?
end