Module: Woods::Extractors::SharedUtilityMethods

Overview

Utility methods shared across multiple extractors.

Provides common helpers for namespace extraction, public method scanning, class method scanning, and initialize parameter parsing. These methods are duplicated across 4-11 extractors; this module centralizes them.

Examples:

class FooExtractor
  include SharedUtilityMethods

  def extract_foo(klass)
    namespace = extract_namespace(klass)
    # ...
  end
end

Instance Method Summary collapse

Instance Method Details

#app_source?(path, app_root) ⇒ Boolean

Check whether a path points to application source (under app_root, but not inside vendor/ or node_modules/ directories).

In Docker environments where Rails.root is ‘/app`, a naive `start_with?(app_root)` also matches vendor bundle paths like `/app/vendor/bundle/ruby/…`. This helper rejects those.

Parameters:

  • path (String, nil)

    Absolute file path

  • app_root (String)

    Rails.root.to_s

Returns:

  • (Boolean)


33
34
35
36
37
# File 'lib/woods/extractors/shared_utility_methods.rb', line 33

def app_source?(path, app_root)
  return false unless path

  path.start_with?(app_root) && !path.include?('/vendor/') && !path.include?('/node_modules/')
end

#condition_label(condition) ⇒ String

Human-readable label for a non-ActionFilter condition.

Parameters:

  • condition (Object)

    A proc, symbol, or other condition

Returns:

  • (String)


192
193
194
195
196
197
198
199
# File 'lib/woods/extractors/shared_utility_methods.rb', line 192

def condition_label(condition)
  case condition
  when Symbol then ":#{condition}"
  when Proc then 'Proc'
  when String then condition
  else condition.class.name
  end
end

#count_loc(source) ⇒ Integer

Count non-blank, non-comment lines of code.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Integer)

    LOC count



101
102
103
# File 'lib/woods/extractors/shared_utility_methods.rb', line 101

def count_loc(source)
  source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
end

#detect_entry_points(source) ⇒ Array<String>

Detect common entry point methods in a source file.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Array<String>)

    Entry point method names



125
126
127
128
129
130
131
132
133
# File 'lib/woods/extractors/shared_utility_methods.rb', line 125

def detect_entry_points(source)
  points = []
  points << 'call'    if source.match?(/def (self\.)?call\b/)
  points << 'perform' if source.match?(/def (self\.)?perform\b/)
  points << 'execute' if source.match?(/def (self\.)?execute\b/)
  points << 'run'     if source.match?(/def (self\.)?run\b/)
  points << 'process' if source.match?(/def (self\.)?process\b/)
  points.empty? ? ['unknown'] : points
end

#extract_action_filter_actions(condition) ⇒ Array<String>?

Extract action names from an ActionFilter-like condition object. Duck-types on the @actions ivar being a Set, avoiding dependence on private class names across Rails versions.

Parameters:

  • condition (Object)

    A condition from the callback’s @if/@unless array

Returns:

  • (Array<String>, nil)

    Action names, or nil if not an ActionFilter



179
180
181
182
183
184
185
186
# File 'lib/woods/extractors/shared_utility_methods.rb', line 179

def extract_action_filter_actions(condition)
  return nil unless condition.instance_variable_defined?(:@actions)

  actions = condition.instance_variable_get(:@actions)
  return nil unless actions.is_a?(Set)

  actions.to_a
end

#extract_callback_conditions(callback) ⇒ Array(Array<String>, Array<String>, Array<String>, Array<String>)

Extract :only/:except action lists and :if/:unless conditions from a callback.

Modern Rails (4.2+) stores conditions in @if/@unless ivar arrays. ActionFilter objects hold action Sets; other conditions are procs/symbols.

Parameters:

  • callback (ActiveSupport::Callbacks::Callback)

Returns:

  • (Array(Array<String>, Array<String>, Array<String>, Array<String>))
    only_actions, except_actions, if_labels, unless_labels


143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/woods/extractors/shared_utility_methods.rb', line 143

def extract_callback_conditions(callback)
  if_conditions = callback.instance_variable_get(:@if) || []
  unless_conditions = callback.instance_variable_get(:@unless) || []

  only = []
  except = []
  if_labels = []
  unless_labels = []

  if_conditions.each do |cond|
    actions = extract_action_filter_actions(cond)
    if actions
      only.concat(actions)
    else
      if_labels << condition_label(cond)
    end
  end

  unless_conditions.each do |cond|
    actions = extract_action_filter_actions(cond)
    if actions
      except.concat(actions)
    else
      unless_labels << condition_label(cond)
    end
  end

  [only, except, if_labels, unless_labels]
end

#extract_class_methods(source) ⇒ Array<String>

Extract class-level (self.) method names from source code.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Array<String>)

    Class method names



247
248
249
# File 'lib/woods/extractors/shared_utility_methods.rb', line 247

def extract_class_methods(source)
  source.scan(/def\s+self\.(\w+[?!=]?)/).flatten
end

#extract_class_name(file_path, source, dir_prefix) ⇒ String

Extract the primary class name from source or fall back to a file path convention.

Parameters:

  • file_path (String)

    Absolute path to the Ruby file

  • source (String)

    Ruby source code

  • dir_prefix (String)

    Regex fragment matching the app/ subdirectory to strip (e.g., “policies”, “validators”, “(?:services|interactors|operations|commands|use_cases)”)

Returns:

  • (String)

    The class name



82
83
84
85
86
# File 'lib/woods/extractors/shared_utility_methods.rb', line 82

def extract_class_name(file_path, source, dir_prefix)
  return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/

  file_path.sub("#{Rails.root}/", '').sub(%r{^app/#{dir_prefix}/}, '').sub('.rb', '').camelize
end

#extract_custom_errors(source) ⇒ Array<String>

Extract custom error/exception class names defined inline.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Array<String>)

    Custom error class names



117
118
119
# File 'lib/woods/extractors/shared_utility_methods.rb', line 117

def extract_custom_errors(source)
  source.scan(/class\s+(\w+(?:Error|Exception))\s*</).flatten
end

#extract_initialize_params(source) ⇒ Array<Hash>

Extract initialize parameters from source code via regex.

Parses the parameter list of the initialize method to determine parameter names, defaults, and whether they are keyword arguments.

Note: PhlexExtractor and ViewComponentExtractor override this with a runtime-introspection version that takes a Class object instead of source text, providing richer type information (:req, :opt, :keyreq, :rest, etc.).

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Array<Hash>)

    Parameter info hashes with :name, :has_default, :keyword



262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/woods/extractors/shared_utility_methods.rb', line 262

def extract_initialize_params(source)
  init_match = source.match(/def\s+initialize\s*\((.*?)\)/m)
  return [] unless init_match

  params_str = init_match[1]
  params = []

  params_str.scan(/(\w+)(?::\s*([^,\n]+))?/) do |name, default|
    params << {
      name: name,
      has_default: !default.nil?,
      keyword: params_str.include?("#{name}:")
    }
  end

  params
end

#extract_namespace(name_or_object) ⇒ String?

Extract namespace from a class name string or class object.

Handles both string input (e.g., “Payments::StripeService”) and class object input (e.g., a Controller class).

Parameters:

  • name_or_object (String, Class, Module)

    A class name or class object

Returns:

  • (String, nil)

    The namespace, or nil if top-level



208
209
210
211
212
# File 'lib/woods/extractors/shared_utility_methods.rb', line 208

def extract_namespace(name_or_object)
  name = name_or_object.is_a?(String) ? name_or_object : name_or_object.name
  parts = name.split('::')
  parts.size > 1 ? parts[0..-2].join('::') : nil
end

#extract_parent_class(source) ⇒ String?

Extract the parent class name from a class definition.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (String, nil)

    Parent class name or nil



92
93
94
95
# File 'lib/woods/extractors/shared_utility_methods.rb', line 92

def extract_parent_class(source)
  match = source.match(/^\s*class\s+[\w:]+\s*<\s*([\w:]+)/)
  match ? match[1] : nil
end

#extract_public_methods(source) ⇒ Array<String>

Extract public instance and class methods from source code.

Walks source line-by-line tracking private/protected visibility. Returns method names that are in public scope and don’t start with underscore.

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Array<String>)

    Public method names



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/woods/extractors/shared_utility_methods.rb', line 221

def extract_public_methods(source)
  methods = []
  in_private = false
  in_protected = false

  source.each_line do |line|
    stripped = line.strip

    in_private = true if stripped == 'private'
    in_protected = true if stripped == 'protected'
    in_private = false if stripped == 'public'
    in_protected = false if stripped == 'public'

    if !in_private && !in_protected && stripped =~ /def\s+((?:self\.)?\w+[?!=]?)/
      method_name = ::Regexp.last_match(1)
      methods << method_name unless method_name.start_with?('_')
    end
  end

  methods
end

#resolve_source_location(klass, app_root:, fallback:) ⇒ String

Resolve the source file for a class using reliable introspection, filtered through #app_source? to reject vendor/gem paths.

Tier order:

1. +const_source_location+ (returns the class definition site)
2. Instance method source locations (first match wins)
3. Class/singleton method source locations (first match wins)

Parameters:

  • klass (Class, Module)

    The class to resolve

  • app_root (String)

    Rails.root.to_s

  • fallback (String)

    Path to return when resolution fails

Returns:

  • (String)

    Resolved source path or fallback



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# File 'lib/woods/extractors/shared_utility_methods.rb', line 51

def resolve_source_location(klass, app_root:, fallback:)
  # Tier 1: const_source_location (most reliable — returns class definition site)
  if Object.respond_to?(:const_source_location) && klass.name
    loc = Object.const_source_location(klass.name)&.first
    return loc if app_source?(loc, app_root)
  end

  # Tier 2: Instance methods defined directly on this class
  klass.instance_methods(false).each do |method_name|
    loc = klass.instance_method(method_name).source_location&.first
    return loc if app_source?(loc, app_root)
  end

  # Tier 3: Class/singleton methods defined on this class
  klass.methods(false).each do |method_name|
    loc = klass.method(method_name).source_location&.first
    return loc if app_source?(loc, app_root)
  end

  fallback
rescue StandardError
  fallback
end

#skip_file?(source) ⇒ Boolean

Skip module-only files (concerns, base modules without a class).

Parameters:

  • source (String)

    Ruby source code

Returns:

  • (Boolean)


109
110
111
# File 'lib/woods/extractors/shared_utility_methods.rb', line 109

def skip_file?(source)
  source.match?(/^\s*module\s+[\w:]+\s*$/) && !source.match?(/^\s*class\s+/)
end