strict_ivars 0.5.0 → 1.0.0.rc1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65516e69e17c893c02c57e0cc0be683c57c9dc3fd91b80f56942453d5df43649
4
- data.tar.gz: 16d3c581474be1f5a7500eb24e442ac8bcd54f55ee5d7afef6cdea3ef986381f
3
+ metadata.gz: b910c7decefccc4495a320886042153d9178c12f76cda7ccb549c304be2b6725
4
+ data.tar.gz: 8af1c060ed4ad3b1eef1b306c336a5bac415018f19bc73954e056bb47f9d86ce
5
5
  SHA512:
6
- metadata.gz: b92fa82ca1acbcef116a5ab531bb1a4ec08301db0b17eb9d5f5a99af47c29face2c82a51d0f63b905eec0a7506e2453b040731848a69afff951f6f44b18e789e
7
- data.tar.gz: b650231f2312548af22bd11ecc031dbdcbf178a5ce4f501b29f48687a59209a6533c12a4ef8fe78a351cc03e591bbd3e01c1edf67f03568620279b3990ab0e63
6
+ metadata.gz: 957162900af7f34fe4e111065f8af1886327a3a3ae5ebdc8de4529869b02d4d71b1d2b27fdbf21ac5aba316e150463d990bdb4c065658b7381d599c520026a9b
7
+ data.tar.gz: 7748495c8e7a2e7cdddbd39b46bcb4e4d217db9b1f4f070fb810445f17b2fcd260356376ee5a7e936790410f83017dc0fdc232a69bdd1da8515204d39e8401ff
data/README.md CHANGED
@@ -1,51 +1,58 @@
1
1
  # Strict Ivars
2
2
 
3
- Strict Ivars is a tiny pre-processor for Ruby that guards your instance variable reads, ensuring the instance variable is actually defined. This helps catch typos nice and early. It‘s especially good when used with [Literal](https://literal.fun).
3
+ Strict Ivars is a tiny pre-processor for Ruby that guards your instance variable reads, ensuring the instance variable is actually defined. This helps catch typos nice and early. It‘s especially good when used with [Literal](https://literal.fun) and [Phlex](https://www.phlex.fun), though it also works with ERB.
4
4
 
5
- ## How does it work?
5
+ > [!NOTE]
6
+ > JRuby and TruffleRuby are not currently supported.
6
7
 
7
- When Strict Ivars detects that you are loading code from paths its configured to handle, it quickly looks for instance variable reads and guards them with a `defined?` check.
8
+ ## Setup
8
9
 
9
- For example, it will replace this:
10
+ Strict Ivars should be used in apps not libraries. Though you could use it in your library’s test suite.
11
+
12
+ Install the gem by adding it to your `Gemfile` and running `bundle install`. You’ll probably want to set it to `require: false` here because you should require it manually at precisely the right moment.
10
13
 
11
14
  ```ruby
12
- def example
13
- foo if @bar
14
- end
15
+ gem "strict_ivars", require: false
15
16
  ```
16
17
 
17
- ...with something like this:
18
+ Now the gem is installed, you should require and initialize the gem as early as possible in your boot process. Ideally, this should be right after Bootsnap is set up. In Rails, this will be in your `boot.rb` file.
18
19
 
19
20
  ```ruby
20
- def example
21
- foo if (defined?(@bar) ? @bar : raise)
22
- end
21
+ require "strict_ivars"
23
22
  ```
24
23
 
25
- The replacement happens on load, so you never see this in your source code. It’s also always wrapped in parentheses and takes up a single line, so it won’t mess up the line numbers in exceptions.
24
+ You can pass an array of globs to `StrictIvars.init` as `include:` and `exclude:`
25
+
26
+ ```ruby
27
+ StrictIvars.init(include: ["#{Dir.pwd}/**/*"], exclude: ["#{Dir.pwd}/vendor/**/*"])
28
+ ```
29
+
30
+ This example include everything in the current directory apart from the `./vendor` folder (which is where GitHub Actions installs gems).
31
+
32
+ If you’re using Bootsnap, you should clear your bootsnap cache by deleting the folder `tmp/cache/bootsnap`.
26
33
 
27
- The real guard is a little uglier than this. It uses `::Kernel.raise` so it’s compatible with `BasicObject`. It also raises a `StrictIvars::NameError` with a helpful message mentioning the name of the instance variable, and that inherits from `NameError`, allowing you to rescue either `NameError` or `StrictIvars::NameError`.
34
+ ## How does it work?
28
35
 
29
- **When you check defined already**
36
+ When Strict Ivars detects that you are loading code from paths its configured to handle, it quickly looks for instance variable reads and guards them with a `defined?` check.
30
37
 
31
- Within the same context (e.g. class definition, module definition, block, method), if you check `defined?`, Strict Ivars will not add a guard to reads of the checked instance variable.
38
+ For example, it will replace this:
32
39
 
33
40
  ```ruby
34
- if defined?(@foo)
35
- @foo
41
+ def example
42
+ foo if @bar
36
43
  end
37
44
  ```
38
45
 
39
- This applies even if the read is not in one of the conditional’s branches since you’ve indicated local awareness of the potential for this instance variable not being defined.
46
+ ...with something like this:
40
47
 
41
48
  ```ruby
42
- if defined?(@foo)
43
- # anything
49
+ def example
50
+ foo if (defined?(@bar) ? @bar : raise)
44
51
  end
45
-
46
- @foo
47
52
  ```
48
53
 
54
+ The replacement happens on load, so you never see this in your source code. It’s also always wrapped in parentheses and takes up a single line, so it won’t mess up the line numbers in exceptions.
55
+
49
56
  **Writes:**
50
57
 
51
58
  Strict Ivars doesn’t apply to writes, since these are considered the authoritative source of the instance variable definitions.
@@ -56,7 +63,7 @@ Strict Ivars doesn’t apply to writes, since these are considered the authorita
56
63
 
57
64
  **Or-writes:**
58
65
 
59
- This is considered a definition and not guarded.
66
+ Or-writes are considered an authoritative definition, not a read.
60
67
 
61
68
  ```ruby
62
69
  @foo ||= 1
@@ -64,47 +71,67 @@ This is considered a definition and not guarded.
64
71
 
65
72
  **And-writes:**
66
73
 
67
- This is considered a definition and not guarded.
74
+ And-writes are considered an authoritative definition, not a read.
68
75
 
69
76
  ```ruby
70
77
  @foo &&= 1
71
78
  ```
72
79
 
73
- ## Setup
74
-
75
- Install the gem by adding it to your `Gemfile` and running `bundle install`.
80
+ ## Common issues
76
81
 
77
- You may want to set it to `require: false` here because you should require it manually at precisely the right moment.
82
+ #### Implicitly depending on undefined instance variables
78
83
 
79
84
  ```ruby
80
- gem "strict_ivars", require: false
85
+ def description
86
+ return @description if @description.present?
87
+ @description = get_description
88
+ end
81
89
  ```
82
90
 
83
- Now the gem is installed, you should require and initialize the gem as early as possible in your boot process. Ideally, this should be right after Bootsnap is set up. In Rails, this will be in your `boot.rb` file.
91
+ This example is relying on Ruby’s behaviour of returning `nil` for undefiend instance variables, which is completely unnecessary. Instead of using `present?`, we could use `defined?` here.
84
92
 
85
93
  ```ruby
86
- require "strict_ivars"
94
+ def description
95
+ return @description if defined?(@description)
96
+ @description = get_description
97
+ end
98
+ ```
87
99
 
88
- StrictIvars.init(include: ["#{Dir.pwd}/**/*"], exclude: ["#{Dir.pwd}/vendor/**/*"])
100
+ Alternatively, as long as `get_description` doesn’t return `nil` and expect us to memoize it, we could use an “or-write” `||=`
101
+
102
+ ```ruby
103
+ def description
104
+ @description ||= get_description
105
+ end
89
106
  ```
90
107
 
91
- You can pass an array of globs to `include:` and `exclude:`.
108
+ #### Rendering instance variables that are only set somtimes
109
+
110
+ It’s common to render an instance variable in an ERB view that you only set on some controllers.
92
111
 
93
- ## Compatibility
112
+ ```erb
113
+ <div data-favourites="<%= @user_favourites %>"></div>
114
+ ```
94
115
 
95
- Because Strict Ivars only transforms the source code that matches your include paths and becuase the check happens at runtime, its completely compatible with the rest of the Ruby ecosystem.
116
+ The best solution to this to always set it on all controllers, but set it to `nil` in the cases where you dont have anything to render. This will prevent you from making a typo in your views.
96
117
 
97
- #### For apps
118
+ Alternatively, you could update the view to be explicit about the fact this ivar may not be set.
98
119
 
99
- Strict Ivars is really designed for apps, where you control the boot process and you want some extra safety in the code you and your team writes.
120
+ ```erb
121
+ <div data-favourites="<%= (@user_favourites ||= nil) %>"></div>
122
+ ```
100
123
 
101
- #### For libraries
124
+ Better yet, add a `defined?` check:
102
125
 
103
- You could use Strict Ivars as a dev dependency in your gem’s test suite, but I don’t recommend initializing Strict Ivars in a library directly.
126
+ ```erb
127
+ <% if defined?(@user_favourites) %>
128
+ <div data-favourites="<%= @user_favourites %>"></div>
129
+ <% end %>
130
+ ```
104
131
 
105
132
  ## Performance
106
133
 
107
- #### Startup performance
134
+ #### Boot performance
108
135
 
109
136
  Using Strict Ivars will impact startup performance since it needs to process each Ruby file you require. However, if you are using Bootsnap, the processed RubyVM::InstructionSequences will be cached and you probably won’t notice the incremental cache misses day-to-day.
110
137
 
@@ -112,12 +139,14 @@ Using Strict Ivars will impact startup performance since it needs to process eac
112
139
 
113
140
  In my benchmarks on Ruby 3.4 with YJIT, it’s difficult to tell if there is any performance difference with or without the `defined?` guards at runtime. Sometimes it’s about 1% faster with the guards than without. Sometimes the other way around.
114
141
 
115
- On my laptop, a method that returns an instance varible takes about 15ns and a method that checks if an instance varible is defined and then returns it takes about 15ns.
142
+ On my laptop, a method that returns an instance varible takes about 15ns and a method that checks if an instance varible is defined and then returns it takes about 15ns. All this is to say, I don’t think there will be any measurable runtime performance impact, at least not in Ruby 3.4.
143
+
144
+ #### Dynamic evals
116
145
 
117
- All this is to say, I don’t think there will be any measurable runtime performance impact.
146
+ There is a small additional cost to dynamically evaluating code via `eval`, `class_eval`, `module_eval`, `instance_eval` and `binding.eval`. Dynamic evaluation usually only happens at boot time but it can happen at runtime depending on how you use it.
118
147
 
119
148
  ## Uninstall
120
149
 
121
150
  Becuase Strict Ivars only ever makes your code safer, you can always back out without anything breaking.
122
151
 
123
- To uninstall Strict Ivars, first remove the require and initialization code from wherever you added it and then remove the gem from your `Gemfile`. If you were using Bootsnap, there’s a good chance it cached some pre-processed code with the instance variable read guards in it. To clear this, you’ll need to delete your bootsnap cache, which should be in `tmp/cache/bootsnap`.
152
+ To uninstall Strict Ivars, first remove the require and initialization code from wherever you added it and then remove the gem from your `Gemfile`. If you are using Bootsnap, there’s a good chance it cached some pre-processed code with the instance variable read guards in it. To clear this, you’ll need to delete your bootsnap cache, which should be in `tmp/cache/bootsnap`.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ class StrictIvars::BaseProcessor < Prism::Visitor
6
+ EVAL_METHODS = Set[:class_eval, :module_eval, :instance_eval, :eval].freeze
7
+
8
+ #: (String) -> String
9
+ def self.call(source)
10
+ visitor = new
11
+ visitor.visit(Prism.parse(source).value)
12
+ buffer = source.dup
13
+ annotations = visitor.annotations
14
+ annotations.sort_by!(&:first)
15
+
16
+ annotations.reverse_each do |offset, string|
17
+ buffer.insert(offset, string)
18
+ end
19
+
20
+ buffer
21
+ end
22
+
23
+ def initialize
24
+ @context = Set[]
25
+ @annotations = []
26
+ end
27
+
28
+ #: Array[[Integer, :start_ivar_read | :end_ivar_read, Symbol]]
29
+ attr_reader :annotations
30
+
31
+ def visit_call_node(node)
32
+ name = node.name
33
+
34
+ if EVAL_METHODS.include?(name) && (arguments = node.arguments)
35
+ location = arguments.location
36
+
37
+ if node.receiver
38
+ receiver_local = "__eval_receiver_#{SecureRandom.hex(8)}__"
39
+ receiver_location = node.receiver.location
40
+
41
+ @annotations.push(
42
+ [receiver_location.start_character_offset, "(#{receiver_local} = "],
43
+ [receiver_location.end_character_offset, ")"],
44
+ [location.start_character_offset, "*(::StrictIvars.__process_eval_args__(#{receiver_local}, :#{name}, "],
45
+ [location.end_character_offset, "))"]
46
+ )
47
+ else
48
+ @annotations.push(
49
+ [location.start_character_offset, "*(::StrictIvars.__process_eval_args__(self, :#{name}, "],
50
+ [location.end_character_offset, "))"]
51
+ )
52
+ end
53
+ end
54
+
55
+ super
56
+ end
57
+ end
@@ -22,7 +22,8 @@ class StrictIvars::Configuration
22
22
  end
23
23
 
24
24
  #: (String) -> bool
25
- def match(path)
25
+ def match?(path)
26
+ return false unless String === path
26
27
  path = File.absolute_path(path)
27
28
  return false if @exclude.any? { |pattern| File.fnmatch?(pattern, path) }
28
29
  return true if @include.any? { |pattern| File.fnmatch?(pattern, path) }
@@ -1,36 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class StrictIvars::Processor < Prism::Visitor
4
- #: (String) -> String
5
- def self.call(source)
6
- visitor = new
7
- visitor.visit(Prism.parse(source).value)
8
- buffer = source.dup
9
- annotations = visitor.annotations
10
- annotations.sort_by!(&:first)
11
-
12
- annotations.reverse_each do |offset, action, name|
13
- case action
14
- when :start
15
- buffer.insert(offset, "(defined?(#{name}) ? ")
16
- when :end
17
- buffer.insert(offset, " : (::Kernel.raise(::StrictIvars::NameError.new('Undefined instance variable #{name}'))))")
18
- else
19
- raise "Invalid annotation"
20
- end
21
- end
22
-
23
- buffer
24
- end
25
-
26
- def initialize
27
- @context = Set[]
28
- @annotations = []
29
- end
30
-
31
- #: Array[[Integer, :start | :end, Symbol]]
32
- attr_reader :annotations
33
-
3
+ class StrictIvars::Processor < StrictIvars::BaseProcessor
34
4
  #: (Prism::ClassNode) -> void
35
5
  def visit_class_node(node)
36
6
  new_context { super }
@@ -51,17 +21,6 @@ class StrictIvars::Processor < Prism::Visitor
51
21
  new_context { super }
52
22
  end
53
23
 
54
- #: (Prism::DefinedNode) -> void
55
- def visit_defined_node(node)
56
- value = node.value
57
-
58
- if Prism::InstanceVariableReadNode === value
59
- @context << value.name
60
- end
61
-
62
- super
63
- end
64
-
65
24
  #: (Prism::DefNode) -> void
66
25
  def visit_def_node(node)
67
26
  new_context { super }
@@ -95,9 +54,10 @@ class StrictIvars::Processor < Prism::Visitor
95
54
 
96
55
  @context << name
97
56
 
98
- @annotations <<
99
- [location.start_character_offset, :start, name] <<
100
- [location.end_character_offset, :end, name]
57
+ @annotations.push(
58
+ [location.start_character_offset, "(defined?(#{name}) ? "],
59
+ [location.end_character_offset, " : (::Kernel.raise(::StrictIvars::NameError.new('Undefined instance variable #{name}'))))"]
60
+ )
101
61
  end
102
62
 
103
63
  super
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StrictIvars
4
- VERSION = "0.5.0"
4
+ VERSION = "1.0.0.rc1"
5
5
  end
data/lib/strict_ivars.rb CHANGED
@@ -3,28 +3,83 @@
3
3
  require "prism"
4
4
  require "require-hooks/setup"
5
5
  require "strict_ivars/version"
6
+ require "strict_ivars/base_processor"
7
+ require "strict_ivars/processor"
6
8
  require "strict_ivars/configuration"
7
9
 
8
10
  module StrictIvars
9
11
  NameError = Class.new(::NameError)
10
12
 
13
+ EMPTY_ARRAY = [].freeze
14
+ EVERYTHING = ["**/*"].freeze
15
+ METHOD_METHOD = Module.instance_method(:method)
16
+
11
17
  CONFIG = Configuration.new
12
18
 
19
+ # Initializes StrictIvars so that code loaded after this point will be
20
+ # guarded against undefined instance variable reads. You can pass an array
21
+ # of globs to `include:` and `exclude:`.
22
+ #
23
+ # ```ruby
24
+ # StrictIvars.init(
25
+ # include: ["#{Dir.pwd}/**/*"],
26
+ # exclude: ["#{Dir.pwd}/vendor/**/*"]
27
+ # )
28
+ # ```
13
29
  #: (include: Array[String], exclude: Array[String]) -> void
14
- def self.init(include: [], exclude: [])
15
- require "strict_ivars/patch_eval"
16
-
17
- CONFIG.include(*include)
18
- CONFIG.exclude(*exclude)
30
+ def self.init(include: EMPTY_ARRAY, exclude: EMPTY_ARRAY)
31
+ CONFIG.include(*include) unless include.length == 0
32
+ CONFIG.exclude(*exclude) unless exclude.length == 0
19
33
 
20
34
  RequireHooks.source_transform(
21
- patterns: include,
22
- exclude_patterns: exclude
35
+ patterns: EVERYTHING,
36
+ exclude_patterns: EMPTY_ARRAY
23
37
  ) do |path, source|
24
38
  source ||= File.read(path)
25
- Processor.call(source)
39
+
40
+ if CONFIG.match?(path)
41
+ Processor.call(source)
42
+ else
43
+ BaseProcessor.call(source)
44
+ end
26
45
  end
27
46
  end
28
- end
29
47
 
30
- require "strict_ivars/processor"
48
+ # For internal use only. This method pre-processes arguments to an eval method.
49
+ #: (Object, Symbol, *untyped)
50
+ def self.__process_eval_args__(receiver, method_name, *args)
51
+ method = METHOD_METHOD.bind_call(receiver, method_name)
52
+ owner = method.owner
53
+
54
+ source, file = nil
55
+
56
+ case method_name
57
+ when :class_eval, :module_eval
58
+ if Module == owner
59
+ source, file = args
60
+ end
61
+ when :instance_eval
62
+ if BasicObject == owner
63
+ source, file = args
64
+ end
65
+ when :eval
66
+ if Kernel == owner
67
+ source, binding, file = args
68
+ elsif Binding == owner
69
+ source, file = args
70
+ end
71
+ end
72
+
73
+ if String === source
74
+ file ||= caller_locations(1, 1).first&.path
75
+
76
+ if CONFIG.match?(file)
77
+ args[0] = Processor.call(source)
78
+ else
79
+ args[0] = BaseProcessor.call(source)
80
+ end
81
+ end
82
+
83
+ args
84
+ end
85
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strict_ivars
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Drapper
@@ -47,8 +47,8 @@ files:
47
47
  - LICENSE.txt
48
48
  - README.md
49
49
  - lib/strict_ivars.rb
50
+ - lib/strict_ivars/base_processor.rb
50
51
  - lib/strict_ivars/configuration.rb
51
- - lib/strict_ivars/patch_eval.rb
52
52
  - lib/strict_ivars/processor.rb
53
53
  - lib/strict_ivars/version.rb
54
54
  homepage: https://github.com/joeldrapper/strict_ivars
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module StrictIvars
4
- module ModuleEvalPatch
5
- #: (String, ?String, ?Integer) -> void
6
- #: () { () -> void } -> void
7
- def class_eval(*args)
8
- source, file, lineno = args
9
-
10
- file ||= caller_locations(1, 1).first.path
11
-
12
- if source && file && CONFIG.match(file)
13
- args[0] = Processor.call(source)
14
- end
15
-
16
- super
17
- end
18
-
19
- #: (String, ?String, ?Integer) -> void
20
- #: () { () -> void } -> void
21
- def module_eval(*args)
22
- source, file, lineno = args
23
-
24
- file ||= caller_locations(1, 1).first.path
25
-
26
- if source && file && CONFIG.match(file)
27
- args[0] = Processor.call(source)
28
- end
29
-
30
- super
31
- end
32
- end
33
-
34
- module InstanceEvalPatch
35
- #: (String, ?String, ?Integer) -> void
36
- #: () { () -> void } -> void
37
- def instance_eval(*args)
38
- source, file, lineno = args
39
-
40
- file ||= caller_locations(1, 1).first.path
41
-
42
- if source && file && CONFIG.match(file)
43
- args[0] = Processor.call(source)
44
- end
45
-
46
- super
47
- end
48
- end
49
-
50
- module KernelEvalPatch
51
- #: (String, Binding, ?String, ?Integer) -> void
52
- def eval(*args)
53
- source, binding, file, lineno = args
54
-
55
- file ||= caller_locations(1, 1).first.path
56
-
57
- if source && file && CONFIG.match(file)
58
- args[0] = Processor.call(source.to_s)
59
- end
60
-
61
- super
62
- end
63
- end
64
-
65
- module BindingEvalPatch
66
- #: (String, ?String, ?Integer) -> void
67
- def eval(*args)
68
- source, file, lineno = args
69
-
70
- file ||= caller_locations(1, 1).first.path
71
-
72
- if source && file && CONFIG.match(file)
73
- args[0] = Processor.call(source.to_s)
74
- end
75
-
76
- super
77
- end
78
- end
79
-
80
- Kernel.prepend(KernelEvalPatch)
81
- Module.prepend(ModuleEvalPatch)
82
- Binding.prepend(BindingEvalPatch)
83
- BasicObject.prepend(InstanceEvalPatch)
84
- end