ivar 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.augment-guidelines +10 -0
- data/.devcontainer/Dockerfile +21 -0
- data/.devcontainer/devcontainer.json +22 -0
- data/.standard.yml +3 -0
- data/.vscode/extensions.json +8 -0
- data/.vscode/settings.json +14 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +343 -0
- data/Rakefile +14 -0
- data/examples/sandwich_inheritance.rb +33 -0
- data/examples/sandwich_with_checked.rb +23 -0
- data/examples/sandwich_with_checked_once.rb +29 -0
- data/examples/sandwich_with_ivar_block.rb +43 -0
- data/examples/sandwich_with_ivar_macro.rb +37 -0
- data/examples/sandwich_with_kwarg.rb +45 -0
- data/ivar.gemspec +49 -0
- data/lib/ivar/auto_check.rb +77 -0
- data/lib/ivar/checked.rb +40 -0
- data/lib/ivar/macros.rb +123 -0
- data/lib/ivar/policies.rb +147 -0
- data/lib/ivar/prism_analysis.rb +102 -0
- data/lib/ivar/validation.rb +55 -0
- data/lib/ivar/version.rb +5 -0
- data/lib/ivar.rb +53 -0
- data/sig/ivar.rbs +4 -0
- data/test_file.rb +1 -0
- metadata +98 -0
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ivar"
|
4
|
+
|
5
|
+
class SandwichWithIvarBlock
|
6
|
+
include Ivar::Checked
|
7
|
+
|
8
|
+
# Pre-declare instance variables with a block that runs before initialization
|
9
|
+
ivar :@side do
|
10
|
+
@pickles = true
|
11
|
+
@condiments = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@bread = "wheat"
|
16
|
+
@cheese = "muenster"
|
17
|
+
# Note: @pickles is already set to true by the ivar block
|
18
|
+
# Note: @condiments is already initialized to an empty array by the ivar block
|
19
|
+
@condiments << "mayo" if !@pickles
|
20
|
+
@condiments << "mustard"
|
21
|
+
# Note: @side is not set here, but it's pre-initialized to nil
|
22
|
+
end
|
23
|
+
|
24
|
+
def to_s
|
25
|
+
result = "A #{@bread} sandwich with #{@cheese}"
|
26
|
+
result += " and #{@condiments.join(", ")}" unless @condiments.empty?
|
27
|
+
result += " with pickles" if @pickles
|
28
|
+
result += " and a side of #{@side}" if @side
|
29
|
+
result
|
30
|
+
end
|
31
|
+
|
32
|
+
def add_side(side)
|
33
|
+
@side = side
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Create a sandwich - this will automatically check instance variables
|
38
|
+
sandwich = SandwichWithIvarBlock.new
|
39
|
+
puts sandwich
|
40
|
+
|
41
|
+
# Add a side
|
42
|
+
sandwich.add_side("chips")
|
43
|
+
puts sandwich
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ivar"
|
4
|
+
|
5
|
+
class SandwichWithIvarMacro
|
6
|
+
include Ivar::Checked
|
7
|
+
|
8
|
+
# Pre-declare only instance variables that might be referenced before being set
|
9
|
+
# You don't need to include variables that are always set in initialize
|
10
|
+
ivar :@side
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@bread = "wheat"
|
14
|
+
@cheese = "muenster"
|
15
|
+
@condiments = %w[mayo mustard]
|
16
|
+
# NOTE: @side is not set here, but it's pre-initialized to nil
|
17
|
+
end
|
18
|
+
|
19
|
+
def to_s
|
20
|
+
result = "A #{@bread} sandwich with #{@cheese} and #{@condiments.join(", ")}"
|
21
|
+
# This won't trigger a warning because @side is pre-initialized
|
22
|
+
result += " and a side of #{@side}" if @side
|
23
|
+
result
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_side(side)
|
27
|
+
@side = side
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Create a sandwich - this will automatically check instance variables
|
32
|
+
sandwich = SandwichWithIvarMacro.new
|
33
|
+
puts sandwich
|
34
|
+
|
35
|
+
# Add a side and print again
|
36
|
+
sandwich.add_side("chips")
|
37
|
+
puts sandwich
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ivar"
|
4
|
+
|
5
|
+
class SandwichWithKwarg
|
6
|
+
include Ivar::Checked
|
7
|
+
|
8
|
+
# Pre-declare instance variables to be initialized from keyword arguments
|
9
|
+
ivar kwarg: [:@bread, :@cheese, :@condiments]
|
10
|
+
|
11
|
+
def initialize(pickles: false, side: nil)
|
12
|
+
# Note: @bread, @cheese, and @condiments are already set from keyword arguments
|
13
|
+
# We only need to handle the remaining keyword arguments
|
14
|
+
@pickles = pickles
|
15
|
+
@side = side
|
16
|
+
end
|
17
|
+
|
18
|
+
def to_s
|
19
|
+
result = "A #{@bread} sandwich with #{@cheese}"
|
20
|
+
result += " and #{@condiments.join(", ")}" unless @condiments.empty?
|
21
|
+
result += " with pickles" if @pickles
|
22
|
+
result += " and a side of #{@side}" if @side
|
23
|
+
result
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Create a sandwich with keyword arguments
|
28
|
+
sandwich = SandwichWithKwarg.new(
|
29
|
+
bread: "wheat",
|
30
|
+
cheese: "muenster",
|
31
|
+
condiments: ["mayo", "mustard"],
|
32
|
+
side: "chips"
|
33
|
+
)
|
34
|
+
|
35
|
+
puts sandwich # Outputs: A wheat sandwich with muenster and mayo, mustard and a side of chips
|
36
|
+
|
37
|
+
# Create another sandwich with different keyword arguments
|
38
|
+
sandwich2 = SandwichWithKwarg.new(
|
39
|
+
bread: "rye",
|
40
|
+
cheese: "swiss",
|
41
|
+
condiments: ["mustard"],
|
42
|
+
pickles: true
|
43
|
+
)
|
44
|
+
|
45
|
+
puts sandwich2 # Outputs: A rye sandwich with swiss and mustard with pickles
|
data/ivar.gemspec
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/ivar/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "ivar"
|
7
|
+
spec.version = Ivar::VERSION
|
8
|
+
spec.authors = ["Avdi Grimm"]
|
9
|
+
spec.email = ["avdi@avdi.codes"]
|
10
|
+
|
11
|
+
spec.summary = "Automatically check instance variables for typos."
|
12
|
+
spec.description = <<~EOF
|
13
|
+
Ruby instance variables are so convenient - you don't even need to declare them!
|
14
|
+
But... they are also dangerous, because a mispelled variable name results in `nil`
|
15
|
+
instead of an error.
|
16
|
+
|
17
|
+
Why not have the best of both worlds? Ivar lets you use plain-old instance variables,
|
18
|
+
and automatically checks for typos.
|
19
|
+
|
20
|
+
Ivar waits until an instance is created to do the checking, then uses Prism to look
|
21
|
+
for variables that don't match what was set in initialization. So it's a little bit
|
22
|
+
dynamic, a little bit static. It doesn't encumber your instance variable reads and
|
23
|
+
writes with any extra checking. And with the `:warn_once` policy, it won't overwhelm
|
24
|
+
you with output.
|
25
|
+
EOF
|
26
|
+
|
27
|
+
spec.homepage = "https://github.com/avdi/ivar"
|
28
|
+
spec.license = "MIT"
|
29
|
+
spec.required_ruby_version = ">= 3.4.0"
|
30
|
+
|
31
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
32
|
+
|
33
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
34
|
+
spec.metadata["source_code_uri"] = "https://github.com/avdi/ivar"
|
35
|
+
spec.metadata["changelog_uri"] = "https://github.com/avdi/ivar/blob/main/CHANGELOG.md"
|
36
|
+
|
37
|
+
# Specify which files should be added to the gem when it is released.
|
38
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
39
|
+
spec.files = Dir.chdir(__dir__) do
|
40
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
41
|
+
(File.expand_path(f) == __FILE__) ||
|
42
|
+
f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor Gemfile])
|
43
|
+
end
|
44
|
+
end
|
45
|
+
spec.require_paths = ["lib"]
|
46
|
+
|
47
|
+
# Dependencies
|
48
|
+
spec.add_dependency "prism", "~> 1.2"
|
49
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "validation"
|
4
|
+
require_relative "macros"
|
5
|
+
|
6
|
+
module Ivar
|
7
|
+
# Module for adding ivar_check_policy to classes
|
8
|
+
module CheckPolicy
|
9
|
+
# Set the check policy for this class
|
10
|
+
# @param policy [Symbol, Policy] The check policy
|
11
|
+
# @return [Symbol, Policy] The check policy
|
12
|
+
def ivar_check_policy(policy = nil, **options)
|
13
|
+
if policy.nil?
|
14
|
+
# Getter - return the current policy
|
15
|
+
@__ivar_check_policy || Ivar.check_policy
|
16
|
+
else
|
17
|
+
# Setter - set the policy
|
18
|
+
@__ivar_check_policy = options.empty? ? policy : [policy, options]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Hook method called when the module is included
|
23
|
+
def inherited(subclass)
|
24
|
+
super
|
25
|
+
# Copy the check policy to the subclass
|
26
|
+
subclass.instance_variable_set(:@__ivar_check_policy, @__ivar_check_policy)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Provides automatic validation for instance variables
|
31
|
+
# When included, automatically calls check_ivars after initialization
|
32
|
+
module Checked
|
33
|
+
# When this module is included in a class, it extends the class
|
34
|
+
# with ClassMethods and includes the Validation module
|
35
|
+
def self.included(base)
|
36
|
+
base.include(Validation)
|
37
|
+
base.include(PreInitializeIvars)
|
38
|
+
base.extend(ClassMethods)
|
39
|
+
base.extend(CheckPolicy)
|
40
|
+
base.extend(Macros)
|
41
|
+
base.prepend(InstanceMethods)
|
42
|
+
|
43
|
+
# Set default policy for Checked to :warn
|
44
|
+
base.ivar_check_policy(:warn)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Class methods added to the including class
|
48
|
+
module ClassMethods
|
49
|
+
# Hook method called when the module is included
|
50
|
+
def inherited(subclass)
|
51
|
+
super
|
52
|
+
# Ensure subclasses also get the initialize wrapper
|
53
|
+
subclass.prepend(Ivar::Checked::InstanceMethods)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Instance methods that will be prepended to the including class
|
58
|
+
module InstanceMethods
|
59
|
+
# Wrap the initialize method to automatically call check_ivars
|
60
|
+
def initialize(*args, **kwargs, &block)
|
61
|
+
# Initialize pre-declared instance variables
|
62
|
+
initialize_pre_declared_ivars
|
63
|
+
# Execute the initialization block if provided
|
64
|
+
execute_ivar_init_block
|
65
|
+
|
66
|
+
# Process keyword arguments
|
67
|
+
remaining_kwargs = initialize_from_kwargs(kwargs)
|
68
|
+
|
69
|
+
# Call the original initialize method with remaining arguments
|
70
|
+
super(*args, **remaining_kwargs, &block)
|
71
|
+
|
72
|
+
# Automatically check instance variables
|
73
|
+
check_ivars
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
data/lib/ivar/checked.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "validation"
|
4
|
+
|
5
|
+
module Ivar
|
6
|
+
# Provides automatic validation for instance variables
|
7
|
+
# When included, automatically checks instance variables after initialization
|
8
|
+
module Checked
|
9
|
+
# When this module is included in a class, it extends the class
|
10
|
+
# with ClassMethods and includes the Validation module
|
11
|
+
def self.included(base)
|
12
|
+
base.include(Validation)
|
13
|
+
base.extend(ClassMethods)
|
14
|
+
base.prepend(InstanceMethods)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Class methods added to the including class
|
18
|
+
module ClassMethods
|
19
|
+
# Hook method called when the module is included
|
20
|
+
def inherited(subclass)
|
21
|
+
super
|
22
|
+
# Ensure subclasses also get the initialize wrapper
|
23
|
+
subclass.prepend(InstanceMethods)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Instance methods that will be prepended to the including class
|
28
|
+
module InstanceMethods
|
29
|
+
# Wrap the initialize method to automatically call check_ivars
|
30
|
+
def initialize(*args, **kwargs, &block)
|
31
|
+
# Call the original initialize method
|
32
|
+
super
|
33
|
+
# Automatically check instance variables
|
34
|
+
# We need to collect all instance variables from the current object
|
35
|
+
# and pass them to check_ivars_once to ensure they're all recognized
|
36
|
+
check_ivars_once
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/ivar/macros.rb
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ivar
|
4
|
+
# Provides macros for working with instance variables
|
5
|
+
module Macros
|
6
|
+
# When this module is extended, it adds class methods to the extending class
|
7
|
+
def self.extended(base)
|
8
|
+
# Store pre-declared instance variables for this class
|
9
|
+
base.instance_variable_set(:@__ivar_pre_declared_ivars, [])
|
10
|
+
# Store initialization block
|
11
|
+
base.instance_variable_set(:@__ivar_init_block, nil)
|
12
|
+
# Store keyword argument mappings
|
13
|
+
base.instance_variable_set(:@__ivar_kwarg_mappings, [])
|
14
|
+
end
|
15
|
+
|
16
|
+
# Declares instance variables that should be pre-initialized to nil
|
17
|
+
# before the initializer is called
|
18
|
+
# @param ivars [Array<Symbol>] Instance variables to pre-initialize
|
19
|
+
# @param kwarg [Array<Symbol>] Instance variables to initialize from keyword arguments
|
20
|
+
# @yield Optional block to execute in the context of the instance before initialization
|
21
|
+
def ivar(*ivars, kwarg: [], &block)
|
22
|
+
# Store the pre-declared instance variables
|
23
|
+
pre_declared = instance_variable_get(:@__ivar_pre_declared_ivars) || []
|
24
|
+
instance_variable_set(:@__ivar_pre_declared_ivars, pre_declared + ivars)
|
25
|
+
|
26
|
+
# Store the keyword argument mappings
|
27
|
+
kwarg_mappings = instance_variable_get(:@__ivar_kwarg_mappings) || []
|
28
|
+
instance_variable_set(:@__ivar_kwarg_mappings, kwarg_mappings + Array(kwarg))
|
29
|
+
|
30
|
+
# Store the initialization block if provided
|
31
|
+
instance_variable_set(:@__ivar_init_block, block) if block
|
32
|
+
end
|
33
|
+
|
34
|
+
# Hook method called when the module is included
|
35
|
+
def inherited(subclass)
|
36
|
+
super
|
37
|
+
# Copy pre-declared instance variables to subclass
|
38
|
+
parent_ivars = instance_variable_get(:@__ivar_pre_declared_ivars) || []
|
39
|
+
subclass.instance_variable_set(:@__ivar_pre_declared_ivars, parent_ivars.dup)
|
40
|
+
|
41
|
+
# Copy keyword argument mappings to subclass
|
42
|
+
parent_kwarg_mappings = instance_variable_get(:@__ivar_kwarg_mappings) || []
|
43
|
+
subclass.instance_variable_set(:@__ivar_kwarg_mappings, parent_kwarg_mappings.dup)
|
44
|
+
|
45
|
+
# Copy initialization block to subclass
|
46
|
+
parent_block = instance_variable_get(:@__ivar_init_block)
|
47
|
+
subclass.instance_variable_set(:@__ivar_init_block, parent_block) if parent_block
|
48
|
+
end
|
49
|
+
|
50
|
+
# Get the pre-declared instance variables for this class
|
51
|
+
# @return [Array<Symbol>] Pre-declared instance variables
|
52
|
+
def ivar_pre_declared
|
53
|
+
instance_variable_get(:@__ivar_pre_declared_ivars) || []
|
54
|
+
end
|
55
|
+
|
56
|
+
# Get the keyword argument mappings for this class
|
57
|
+
# @return [Array<Symbol>] Keyword argument mappings
|
58
|
+
def ivar_kwarg_mappings
|
59
|
+
instance_variable_get(:@__ivar_kwarg_mappings) || []
|
60
|
+
end
|
61
|
+
|
62
|
+
# Get the initialization block for this class
|
63
|
+
# @return [Proc, nil] The initialization block or nil if none was provided
|
64
|
+
def ivar_init_block
|
65
|
+
instance_variable_get(:@__ivar_init_block)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Module to pre-initialize instance variables
|
70
|
+
module PreInitializeIvars
|
71
|
+
# Initialize pre-declared instance variables to nil
|
72
|
+
def initialize_pre_declared_ivars
|
73
|
+
klass = self.class
|
74
|
+
while klass.respond_to?(:ivar_pre_declared)
|
75
|
+
klass.ivar_pre_declared.each do |ivar|
|
76
|
+
instance_variable_set(ivar, nil) unless instance_variable_defined?(ivar)
|
77
|
+
end
|
78
|
+
klass = klass.superclass
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Execute the initialization block in the context of the instance
|
83
|
+
def execute_ivar_init_block
|
84
|
+
klass = self.class
|
85
|
+
while klass.respond_to?(:ivar_init_block)
|
86
|
+
block = klass.ivar_init_block
|
87
|
+
instance_eval(&block) if block
|
88
|
+
klass = klass.superclass
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get all keyword argument mappings from the class hierarchy
|
93
|
+
# @return [Array<Symbol>] All keyword argument mappings
|
94
|
+
def all_kwarg_mappings
|
95
|
+
mappings = []
|
96
|
+
klass = self.class
|
97
|
+
while klass.respond_to?(:ivar_kwarg_mappings)
|
98
|
+
mappings.concat(klass.ivar_kwarg_mappings)
|
99
|
+
klass = klass.superclass
|
100
|
+
end
|
101
|
+
mappings
|
102
|
+
end
|
103
|
+
|
104
|
+
# Initialize instance variables from keyword arguments
|
105
|
+
# @param kwargs [Hash] Keyword arguments
|
106
|
+
# @return [Hash] Remaining keyword arguments
|
107
|
+
def initialize_from_kwargs(kwargs)
|
108
|
+
remaining_kwargs = kwargs.dup
|
109
|
+
|
110
|
+
all_kwarg_mappings.each do |ivar|
|
111
|
+
# Convert @ivar_name to ivar_name for keyword lookup
|
112
|
+
key = ivar.to_s.delete_prefix("@").to_sym
|
113
|
+
|
114
|
+
if remaining_kwargs.key?(key)
|
115
|
+
instance_variable_set(ivar, remaining_kwargs[key])
|
116
|
+
remaining_kwargs.delete(key)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
remaining_kwargs
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
|
5
|
+
module Ivar
|
6
|
+
# Base class for all ivar checking policies
|
7
|
+
class Policy
|
8
|
+
# Handle unknown instance variables
|
9
|
+
# @param unknown_refs [Array<Hash>] References to unknown instance variables
|
10
|
+
# @param klass [Class] The class being checked
|
11
|
+
# @param allowed_ivars [Array<Symbol>] List of allowed instance variables
|
12
|
+
def handle_unknown_ivars(unknown_refs, klass, allowed_ivars)
|
13
|
+
raise NotImplementedError, "Subclasses must implement handle_unknown_ivars"
|
14
|
+
end
|
15
|
+
|
16
|
+
# Find the closest match for a variable name
|
17
|
+
# @param ivar [Symbol] The variable to find a match for
|
18
|
+
# @param known_ivars [Array<Symbol>] List of known variables
|
19
|
+
# @return [Symbol, nil] The closest match or nil if none found
|
20
|
+
def find_closest_match(ivar, known_ivars)
|
21
|
+
finder = DidYouMean::SpellChecker.new(dictionary: known_ivars)
|
22
|
+
suggestions = finder.correct(ivar.to_s)
|
23
|
+
suggestions.first&.to_sym if suggestions.any?
|
24
|
+
end
|
25
|
+
|
26
|
+
# Format a warning message for an unknown instance variable
|
27
|
+
# @param ref [Hash] Reference to an unknown instance variable
|
28
|
+
# @param suggestion [Symbol, nil] Suggested correction or nil
|
29
|
+
# @return [String] Formatted warning message
|
30
|
+
def format_warning(ref, suggestion)
|
31
|
+
ivar = ref[:name]
|
32
|
+
suggestion_text = suggestion ? "Did you mean: #{suggestion}?" : ""
|
33
|
+
"#{ref[:path]}:#{ref[:line]}: warning: unknown instance variable #{ivar}. #{suggestion_text}\n"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Policy that warns about unknown instance variables
|
38
|
+
class WarnPolicy < Policy
|
39
|
+
# Handle unknown instance variables by emitting warnings
|
40
|
+
# @param unknown_refs [Array<Hash>] References to unknown instance variables
|
41
|
+
# @param klass [Class] The class being checked
|
42
|
+
# @param allowed_ivars [Array<Symbol>] List of allowed instance variables
|
43
|
+
def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars)
|
44
|
+
unknown_refs.each do |ref|
|
45
|
+
ivar = ref[:name]
|
46
|
+
suggestion = find_closest_match(ivar, allowed_ivars)
|
47
|
+
$stderr.write(format_warning(ref, suggestion))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Policy that warns about unknown instance variables only once per class
|
53
|
+
class WarnOncePolicy < Policy
|
54
|
+
# Handle unknown instance variables by emitting warnings once per class
|
55
|
+
# @param unknown_refs [Array<Hash>] References to unknown instance variables
|
56
|
+
# @param klass [Class] The class being checked
|
57
|
+
# @param allowed_ivars [Array<Symbol>] List of allowed instance variables
|
58
|
+
def handle_unknown_ivars(unknown_refs, klass, allowed_ivars)
|
59
|
+
# Skip if this class has already been checked
|
60
|
+
return if Ivar.class_checked?(klass)
|
61
|
+
|
62
|
+
# Emit warnings
|
63
|
+
unknown_refs.each do |ref|
|
64
|
+
ivar = ref[:name]
|
65
|
+
suggestion = find_closest_match(ivar, allowed_ivars)
|
66
|
+
$stderr.write(format_warning(ref, suggestion))
|
67
|
+
end
|
68
|
+
|
69
|
+
# Mark this class as having been checked
|
70
|
+
Ivar.mark_class_checked(klass)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Policy that raises an exception for unknown instance variables
|
75
|
+
class RaisePolicy < Policy
|
76
|
+
# Handle unknown instance variables by raising an exception
|
77
|
+
# @param unknown_refs [Array<Hash>] References to unknown instance variables
|
78
|
+
# @param klass [Class] The class being checked
|
79
|
+
# @param allowed_ivars [Array<Symbol>] List of allowed instance variables
|
80
|
+
def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars)
|
81
|
+
return if unknown_refs.empty?
|
82
|
+
|
83
|
+
# Get the first unknown reference
|
84
|
+
ref = unknown_refs.first
|
85
|
+
ivar = ref[:name]
|
86
|
+
suggestion = find_closest_match(ivar, allowed_ivars)
|
87
|
+
suggestion_text = suggestion ? " Did you mean: #{suggestion}?" : ""
|
88
|
+
|
89
|
+
# Raise an exception with location information
|
90
|
+
message = "#{ref[:path]}:#{ref[:line]}: unknown instance variable #{ivar}.#{suggestion_text}"
|
91
|
+
raise NameError, message
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Policy that logs unknown instance variables to a logger
|
96
|
+
class LogPolicy < Policy
|
97
|
+
# Initialize with a logger
|
98
|
+
# @param logger [Logger] The logger to use
|
99
|
+
def initialize(logger: Logger.new($stderr))
|
100
|
+
@logger = logger
|
101
|
+
end
|
102
|
+
|
103
|
+
# Handle unknown instance variables by logging them
|
104
|
+
# @param unknown_refs [Array<Hash>] References to unknown instance variables
|
105
|
+
# @param klass [Class] The class being checked
|
106
|
+
# @param allowed_ivars [Array<Symbol>] List of allowed instance variables
|
107
|
+
def handle_unknown_ivars(unknown_refs, _klass, allowed_ivars)
|
108
|
+
unknown_refs.each do |ref|
|
109
|
+
ivar = ref[:name]
|
110
|
+
suggestion = find_closest_match(ivar, allowed_ivars)
|
111
|
+
suggestion_text = suggestion ? " Did you mean: #{suggestion}?" : ""
|
112
|
+
message = "#{ref[:path]}:#{ref[:line]}: unknown instance variable #{ivar}.#{suggestion_text}"
|
113
|
+
@logger.warn(message)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Map of policy symbols to policy classes
|
119
|
+
POLICY_CLASSES = {
|
120
|
+
warn: WarnPolicy,
|
121
|
+
warn_once: WarnOncePolicy,
|
122
|
+
raise: RaisePolicy,
|
123
|
+
log: LogPolicy
|
124
|
+
}.freeze
|
125
|
+
|
126
|
+
# Get a policy instance from a symbol or policy object
|
127
|
+
# @param policy [Symbol, Policy, Array] The policy to get
|
128
|
+
# @param options [Hash] Options to pass to the policy constructor
|
129
|
+
# @return [Policy] The policy instance
|
130
|
+
def self.get_policy(policy, **options)
|
131
|
+
return policy if policy.is_a?(Policy)
|
132
|
+
|
133
|
+
# Handle the case where policy is an array with [policy_name, options]
|
134
|
+
if policy.is_a?(Array) && policy.size == 2 && policy[1].is_a?(Hash)
|
135
|
+
policy_name, policy_options = policy
|
136
|
+
policy_class = POLICY_CLASSES[policy_name]
|
137
|
+
raise ArgumentError, "Unknown policy: #{policy_name}" unless policy_class
|
138
|
+
|
139
|
+
return policy_class.new(**policy_options)
|
140
|
+
end
|
141
|
+
|
142
|
+
policy_class = POLICY_CLASSES[policy]
|
143
|
+
raise ArgumentError, "Unknown policy: #{policy}" unless policy_class
|
144
|
+
|
145
|
+
policy_class.new(**options)
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "prism"
|
4
|
+
|
5
|
+
module Ivar
|
6
|
+
# Analyzes a class to find all instance variables using Prism
|
7
|
+
class PrismAnalysis
|
8
|
+
attr_reader :ivars
|
9
|
+
|
10
|
+
def initialize(klass)
|
11
|
+
@klass = klass
|
12
|
+
@references = nil
|
13
|
+
collect_references
|
14
|
+
@ivars = unique_ivar_names
|
15
|
+
end
|
16
|
+
|
17
|
+
# Returns a list of hashes each representing a code reference to an ivar
|
18
|
+
# Each hash includes var name, path, line number, and column number
|
19
|
+
def ivar_references
|
20
|
+
@references
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def collect_references
|
26
|
+
source_files = collect_source_files
|
27
|
+
@references = []
|
28
|
+
|
29
|
+
source_files.each do |file_path|
|
30
|
+
code = File.read(file_path)
|
31
|
+
result = Prism.parse(code)
|
32
|
+
visitor = IvarReferenceVisitor.new(file_path)
|
33
|
+
result.value.accept(visitor)
|
34
|
+
@references.concat(visitor.references)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def unique_ivar_names
|
39
|
+
@references.map { |ref| ref[:name] }.uniq.sort
|
40
|
+
end
|
41
|
+
|
42
|
+
def collect_source_files
|
43
|
+
# Get all instance methods
|
44
|
+
instance_methods = @klass.instance_methods(false) | @klass.private_instance_methods(false)
|
45
|
+
|
46
|
+
# Collect source files for all methods
|
47
|
+
source_files = Set.new
|
48
|
+
instance_methods.each do |method_name|
|
49
|
+
next unless @klass.instance_method(method_name).source_location
|
50
|
+
|
51
|
+
source_files << @klass.instance_method(method_name).source_location.first
|
52
|
+
end
|
53
|
+
|
54
|
+
source_files
|
55
|
+
end
|
56
|
+
|
57
|
+
# Visitor that collects instance variable references with location information
|
58
|
+
class IvarReferenceVisitor < Prism::Visitor
|
59
|
+
attr_reader :references
|
60
|
+
|
61
|
+
def initialize(file_path)
|
62
|
+
super()
|
63
|
+
@file_path = file_path
|
64
|
+
@references = []
|
65
|
+
end
|
66
|
+
|
67
|
+
def visit_instance_variable_read_node(node)
|
68
|
+
add_reference(node)
|
69
|
+
true
|
70
|
+
end
|
71
|
+
|
72
|
+
def visit_instance_variable_write_node(node)
|
73
|
+
add_reference(node)
|
74
|
+
true
|
75
|
+
end
|
76
|
+
|
77
|
+
def visit_instance_variable_operator_write_node(node)
|
78
|
+
add_reference(node)
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
def visit_instance_variable_target_node(node)
|
83
|
+
add_reference(node)
|
84
|
+
true
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def add_reference(node)
|
90
|
+
location = node.location
|
91
|
+
reference = {
|
92
|
+
name: node.name.to_sym,
|
93
|
+
path: @file_path,
|
94
|
+
line: location.start_line,
|
95
|
+
column: location.start_column
|
96
|
+
}
|
97
|
+
|
98
|
+
@references << reference
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|