empirical 0.0.1 → 0.0.3

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: 3c9b64cdbca71cda53eac79ca46ed80f2e2d0e19901dfa5b7b675c0465c3f4eb
4
- data.tar.gz: c8abbdbbdb0cc7dd461e810ee398a8046f259fdc78a1c7b6aa117749c6263f38
3
+ metadata.gz: 7fede1fc1d6079a9a1b13ae0875ac41bf61a6b7fe812688ca06e8ccd4e1cb9f5
4
+ data.tar.gz: cadaa32b10e496014d25418b594dd0195958e8aa15eee50f45549a1390cf9012
5
5
  SHA512:
6
- metadata.gz: 114a23808cdb5746bfcc791207a80693900c960ef37870f0370c35b3c0bc6d215fff22f59ba9b57516002a31f56d20a76b8982473606da9421dffe9cff585876
7
- data.tar.gz: da7ef76d2913b4be14a5dc08ce01a8a4f01fba6d103500eb197c7604cb38dcb12d9f0e23dd24a6d8e9381bd547db6e24bf9876418b42b9e75431d55e1723fbbd
6
+ metadata.gz: 8a5371864bd72f10a9a65648ea13227af2e621978018f151d6d4dae360d08dd909d105663d0954a98f378c2d464b1d46fc5cca098ae547c5148319f7f6acac95
7
+ data.tar.gz: 6fa32faa04f2e919616ee06a0e4fe60e3c0b316eb5e07936eb16dac1a5fd6200aace0e8b821b43c62bf5902a3eb7a65b6f0d3af5646d478ea182dca34beba808
data/README.md CHANGED
@@ -1,21 +1,27 @@
1
- # Strict Ivars
1
+ # Empirical
2
2
 
3
- If you reference an undefined method, constant or local varaible, Ruby will helpfully raise an error. But reference an undefined _instance_ variable and Ruby just returns `nil`. This can lead to all kinds of bugs — many of which can lay dormant for years before surprising you with an unexpected outage, data breach or data loss event.
3
+ > (_adjective_) based on what is experienced or seen rather than on theory \
4
+ > (_noun_) enhancements for Ruby with a runtime type system
4
5
 
5
- Strict Ivars solves this by making Ruby raise a `NameError` any time you read an undefined instance varaible. It’s enabled with two lines of code in your boot process, then it just works in the background and you’ll never have to think about it again. Strict Ivars has no known false-positives or false-negatives.
6
+ Empirical catches bugs early and makes your code self-documenting by enhancing Ruby with beautiful syntax to define runtime type assertions.
6
7
 
7
- It’s especially good when used with [Literal](https://literal.fun) and [Phlex](https://www.phlex.fun), though it also works with regular Ruby objects and even ERB templates, which are actually pretty common spots for undefined instance variable reads to hide since that’s the main way of passing data to ERB.
8
-
9
- When combined with Literal, you can essentially remove all unexpected `nil`s. Literal validates your inputs and Strict Ivars ensures you’re reading the right instance variables.
8
+ ```ruby
9
+ fun word_frequency(text: String) => _Hash(String, Integer) do
10
+ text
11
+ .downcase
12
+ .scan(/\w+/)
13
+ .tally
14
+ .sort_by { |word, count| -count }
15
+ .first(10)
16
+ .to_h
17
+ end
18
+ ```
10
19
 
11
- > [!NOTE]
12
- > JRuby and TruffleRuby are not currently supported.
20
+ (see [below](#runtime-typing)).
13
21
 
14
22
  ## Setup
15
23
 
16
- Strict Ivars should really be used in apps not libraries. Though you could definitely use it in your library’s test suite to help catch issues in the library code.
17
-
18
- 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.
24
+ Install the gem by adding it to your <kbd>Gemfile</kbd> and running <kbd>bundle install</kbd>. You’ll probably want to set it to `require: false` here because you should require it manually at precisely the right moment.
19
25
 
20
26
  ```ruby
21
27
  gem "empirical", require: false
@@ -32,144 +38,3 @@ You can pass an array of globs to `Empirical.init` as `include:` and `exclude:`
32
38
  ```ruby
33
39
  Empirical.init(include: ["#{Dir.pwd}/**/*"], exclude: ["#{Dir.pwd}/vendor/**/*"])
34
40
  ```
35
-
36
- This example include everything in the current directory apart from the `./vendor` folder (which is where GitHub Actions installs gems).
37
-
38
- If you’re setting this up in Rails, your `boot.rb` file should look something like this.
39
-
40
- ```ruby
41
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
42
-
43
- require "bundler/setup" # Set up gems listed in the Gemfile.
44
- require "bootsnap/setup" # Speed up boot time by caching expensive operations.
45
-
46
- require "empirical"
47
-
48
- Empirical.init(include: ["#{Dir.pwd}/**/*"], exclude: ["#{Dir.pwd}/vendor/**/*"])
49
- ```
50
-
51
- If you’re using Bootsnap, you should clear your bootsnap cache by deleting the folder `tmp/cache/bootsnap`.
52
-
53
- ## How does it work?
54
-
55
- 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.
56
-
57
- For example, it will replace this:
58
-
59
- ```ruby
60
- def example
61
- foo if @bar
62
- end
63
- ```
64
-
65
- ...with something like this:
66
-
67
- ```ruby
68
- def example
69
- foo if (defined?(@bar) ? @bar : raise)
70
- end
71
- ```
72
-
73
- 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.
74
-
75
- **Writes:**
76
-
77
- Strict Ivars doesn’t apply to writes, since these are considered the authoritative source of the instance variable definitions.
78
-
79
- ```ruby
80
- @foo = 1
81
- ```
82
-
83
- **Or-writes:**
84
-
85
- Or-writes are considered an authoritative definition, not a read.
86
-
87
- ```ruby
88
- @foo ||= 1
89
- ```
90
-
91
- **And-writes:**
92
-
93
- And-writes are considered an authoritative definition, not a read.
94
-
95
- ```ruby
96
- @foo &&= 1
97
- ```
98
-
99
- ## Common mistakes
100
-
101
- #### Implicitly depending on undefined instance variables
102
-
103
- ```ruby
104
- def description
105
- return @description if @description.present?
106
- @description = get_description
107
- end
108
- ```
109
-
110
- 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.
111
-
112
- ```ruby
113
- def description
114
- return @description if defined?(@description)
115
- @description = get_description
116
- end
117
- ```
118
-
119
- Alternatively, as long as `get_description` doesn’t return `nil` and expect us to memoize it, we could use an “or-write” `||=`
120
-
121
- ```ruby
122
- def description
123
- @description ||= get_description
124
- end
125
- ```
126
-
127
- #### Rendering instance variables that are only set somtimes
128
-
129
- It’s common to render an instance variable in an ERB view that you only set on some controllers.
130
-
131
- ```erb
132
- <div data-favourites="<%= @user_favourites %>"></div>
133
- ```
134
-
135
- The best solution to this to always set it on all controllers, but set it to `nil` in the cases where you don’t have anything to render. This will prevent you from making a typo in your views.
136
-
137
- Alternatively, you could update the view to be explicit about the fact this ivar may not be set.
138
-
139
- ```erb
140
- <div data-favourites="<%= (@user_favourites ||= nil) %>"></div>
141
- ```
142
-
143
- Better yet, add a `defined?` check:
144
-
145
- ```erb
146
- <% if defined?(@user_favourites) %>
147
- <div data-favourites="<%= @user_favourites %>"></div>
148
- <% end %>
149
- ```
150
-
151
- ## Performance
152
-
153
- #### Boot performance
154
-
155
- 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.
156
-
157
- #### Runtime performance
158
-
159
- 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.
160
-
161
- 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.
162
-
163
- #### Dynamic evals
164
-
165
- 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.
166
-
167
- ## Stability
168
-
169
- Strict Ivars has 100% line and branch coverage and there are no known false-positives, false-negatives or bugs.
170
-
171
- ## Uninstall
172
-
173
- Becuase Strict Ivars only ever makes your code safer, you can always back out without anything breaking.
174
-
175
- 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`.
@@ -3,59 +3,8 @@
3
3
  class Empirical::BaseProcessor < Prism::Visitor
4
4
  EVAL_METHODS = Set[:class_eval, :module_eval, :instance_eval, :eval].freeze
5
5
 
6
- #: (String) -> String
7
- def self.call(source)
8
- visitor = new
9
- visitor.visit(Prism.parse(source).value)
10
- buffer = source.dup
11
- annotations = visitor.annotations
12
- annotations.sort_by!(&:first)
13
-
14
- annotations.reverse_each do |offset, length, string|
15
- buffer[offset, length] = string
16
- end
17
-
18
- buffer
19
- end
20
-
21
- def initialize
6
+ def initialize(annotations:)
22
7
  @context = Set[]
23
- @annotations = []
24
- end
25
-
26
- #: Array[[Integer, String]]
27
- attr_reader :annotations
28
-
29
- def visit_call_node(node)
30
- name = node.name
31
-
32
- if EVAL_METHODS.include?(name) && (arguments = node.arguments)
33
- location = arguments.location
34
-
35
- closing = if arguments.contains_forwarding?
36
- ")), &(::Empirical.__eval_block_from_forwarding__(...))"
37
- else
38
- "))"
39
- end
40
-
41
- if node.receiver
42
- receiver_local = "__eval_receiver_#{SecureRandom.hex(8)}__"
43
- receiver_location = node.receiver.location
44
-
45
- @annotations.push(
46
- [receiver_location.start_character_offset, 0, "(#{receiver_local} = "],
47
- [receiver_location.end_character_offset, 0, ")"],
48
- [location.start_character_offset, 0, "*(::Empirical.__process_eval_args__(#{receiver_local}, :#{name}, "],
49
- [location.end_character_offset, 0, closing]
50
- )
51
- else
52
- @annotations.push(
53
- [location.start_character_offset, 0, "*(::Empirical.__process_eval_args__(self, :#{name}, "],
54
- [location.end_character_offset, 0, closing]
55
- )
56
- end
57
- end
58
-
59
- super
8
+ @annotations = annotations
60
9
  end
61
10
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ensure the new callback methods exist on the base classes.
4
+ # Developers can define their own callback methods in their classes/modules.
5
+ class Module
6
+ def module_defined
7
+ end
8
+ end
9
+
10
+ class Class
11
+ def class_defined
12
+ end
13
+ end
14
+
15
+ class Empirical::ClassCallbacksProcessor < Empirical::BaseProcessor
16
+ # def visit_class_node(node)
17
+ # @annotations << [node.end_keyword_loc.start_offset, 0, ";class_defined();"]
18
+ # end
19
+
20
+ # def visit_module_node(node)
21
+ # @annotations << [node.end_keyword_loc.start_offset, 0, ";module_defined();"]
22
+ # end
23
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Empirical
4
+ # For internal use only. This method pre-processes arguments to an eval method.
5
+ #: (Object, Symbol, *untyped)
6
+ def self.__process_eval_args__(receiver, method_name, *args)
7
+ method = METHOD_METHOD.bind_call(receiver, method_name)
8
+ owner = method.owner
9
+
10
+ source, file = nil
11
+
12
+ case method_name
13
+ when :class_eval, :module_eval
14
+ if Module == owner
15
+ source, file = args
16
+ end
17
+ when :instance_eval
18
+ if BasicObject == owner
19
+ source, file = args
20
+ end
21
+ when :eval
22
+ if Kernel == owner
23
+ source, _binding, file = args
24
+ elsif Binding == owner
25
+ source, file = args
26
+ end
27
+ end
28
+
29
+ if String === source
30
+ file ||= caller_locations(1, 1).first.path
31
+
32
+ if CONFIG.match?(file)
33
+ args[0] = process(source, with: PROCESSORS)
34
+ else
35
+ args[0] = process(source)
36
+ end
37
+ end
38
+
39
+ args
40
+ rescue ::NameError
41
+ args
42
+ end
43
+
44
+ #: () { () -> void } -> Proc
45
+ def self.__eval_block_from_forwarding__(*, &block)
46
+ block
47
+ end
48
+
49
+ class EvalProcessor < Empirical::BaseProcessor
50
+ EVAL_METHODS = Set[:class_eval, :module_eval, :instance_eval, :eval].freeze
51
+
52
+ def visit_call_node(node)
53
+ name = node.name
54
+
55
+ if EVAL_METHODS.include?(name) && (arguments = node.arguments)
56
+ location = arguments.location
57
+
58
+ closing = if arguments.contains_forwarding?
59
+ ")), &(::Empirical.__eval_block_from_forwarding__(...))"
60
+ else
61
+ "))"
62
+ end
63
+
64
+ if node.receiver
65
+ receiver_local = "__eval_receiver_#{SecureRandom.hex(8)}__"
66
+ receiver_location = node.receiver.location
67
+
68
+ @annotations.push(
69
+ [receiver_location.start_character_offset, 0, "(#{receiver_local} = "],
70
+ [receiver_location.end_character_offset, 0, ")"],
71
+ [location.start_character_offset, 0, "*(::Empirical.__process_eval_args__(#{receiver_local}, :#{name}, "],
72
+ [location.end_character_offset, 0, closing]
73
+ )
74
+ else
75
+ @annotations.push(
76
+ [location.start_character_offset, 0, "*(::Empirical.__process_eval_args__(self, :#{name}, "],
77
+ [location.end_character_offset, 0, closing]
78
+ )
79
+ end
80
+ end
81
+
82
+ super
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Empirical::IvarProcessor < Empirical::BaseProcessor
4
+ def visit_class_node(node)
5
+ new_context { super }
6
+ end
7
+
8
+ def visit_module_node(node)
9
+ new_context { super }
10
+ end
11
+
12
+ def visit_block_node(node)
13
+ new_context { super }
14
+ end
15
+
16
+ def visit_singleton_class_node(node)
17
+ new_context { super }
18
+ end
19
+
20
+ def visit_def_node(node)
21
+ new_context { super }
22
+ end
23
+
24
+ def visit_if_node(node)
25
+ visit(node.predicate)
26
+
27
+ branch { visit(node.statements) }
28
+ branch { visit(node.subsequent) }
29
+ end
30
+
31
+ def visit_case_node(node)
32
+ visit(node.predicate)
33
+
34
+ node.conditions.each do |condition|
35
+ branch { visit(condition) }
36
+ end
37
+
38
+ branch { visit(node.else_clause) }
39
+ end
40
+
41
+ def visit_defined_node(node)
42
+ value = node.value
43
+
44
+ return if Prism::InstanceVariableReadNode === value
45
+
46
+ super
47
+ end
48
+
49
+ def visit_instance_variable_read_node(node)
50
+ name = node.name
51
+
52
+ unless @context.include?(name)
53
+ location = node.location
54
+
55
+ @context << name
56
+
57
+ @annotations.push(
58
+ [location.start_character_offset, 0, "(defined?(#{name}) ? "],
59
+ [location.end_character_offset, 0, " : (::Kernel.raise(::Empirical::NameError.new(self, :#{name}))))"]
60
+ )
61
+ end
62
+
63
+ super
64
+ end
65
+
66
+ private def new_context
67
+ original_context = @context
68
+
69
+ @context = Set[]
70
+
71
+ begin
72
+ yield
73
+ ensure
74
+ @context = original_context
75
+ end
76
+ end
77
+
78
+ private def branch
79
+ original_context = @context
80
+ @context = original_context.dup
81
+
82
+ begin
83
+ yield
84
+ ensure
85
+ @context = original_context
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Empirical::SignatureProcessor < Empirical::BaseProcessor
4
+ def initialize(...)
5
+ @return_type = nil
6
+ @block_stack = []
7
+ super
8
+ end
9
+
10
+ def visit_call_node(node)
11
+ case node
12
+ in { name: :fun }
13
+ original_return_type = @return_type
14
+ @return_type = visit_fun_call_node(node)
15
+ super # ensures any early returns are processed (also, technically, any internal method defs)
16
+ @return_type = original_return_type
17
+
18
+ # handle "method macros" (like `private`, `protected`, etc.)
19
+ # because the body block is attached to that call node,
20
+ # not the `fun` call node
21
+ in { block: Prism::BlockNode }
22
+ @block_stack << node.block
23
+ super
24
+ @block_stack.pop
25
+ else
26
+ original_return_type = @return_type
27
+ super # ensures any early returns are processed (also, technically, any internal method defs)
28
+ @return_type = original_return_type
29
+ end
30
+ end
31
+
32
+ def visit_fun_call_node(node)
33
+ # TODO: better error messages
34
+ raise SyntaxError unless node.arguments
35
+ raise SyntaxError unless nil == node.receiver
36
+
37
+ case node
38
+ in {
39
+ arguments: Prism::ArgumentsNode[
40
+ arguments: [
41
+ Prism::KeywordHashNode[
42
+ elements: [
43
+ Prism::AssocNode[
44
+ key: signature,
45
+ value: return_type
46
+ ]
47
+ ]
48
+ ]
49
+ ]
50
+ ]
51
+ }
52
+ body_block = node.block || @block_stack.first
53
+ preamble = []
54
+ postamble = []
55
+
56
+ case signature
57
+ # parameterless method defs (e.g. `fun foo` or `fun foo()`)
58
+ in Prism::LocalVariableReadNode | Prism::ConstantReadNode
59
+ # no-op
60
+ # parameterful method defs (e.g. `fun foo(a: Type)` or `fun foo(a = Type)`)
61
+ in Prism::CallNode
62
+ raise SyntaxError if signature.block
63
+
64
+ signature.arguments&.arguments&.each do |argument|
65
+ case argument
66
+ # Positional splat (e.g. `a = [Type]` becomes `*a`)
67
+ in Prism::LocalVariableWriteNode[name: name, value: Prism::ArrayNode[elements: [type]]]
68
+ # make argument a splat
69
+ @annotations << [
70
+ argument.name_loc.start_offset,
71
+ 0,
72
+ "*",
73
+ ]
74
+
75
+ # remove the type and equals operator from the argument
76
+ @annotations << [
77
+ argument.name_loc.end_offset,
78
+ type.location.end_offset - argument.name_loc.end_offset + 1,
79
+ "",
80
+ ]
81
+
82
+ preamble << "raise(::Empirical::TypeError.argument_type_error(name: '#{name}', value: #{name}, expected: ::Literal::_Array(#{type.slice}), method_name: __method__, context: self)) unless ::Literal::_Array(#{type.slice}) === #{name}"
83
+
84
+ # Positional (e.g. `a = Type` becomes `a = nil` or `a = default`)
85
+ in Prism::LocalVariableWriteNode[name: name, value: typed_param]
86
+ case typed_param
87
+ # Positional with default (e.g. `a = Type | 1` becomes `a = 1`)
88
+ in Prism::CallNode[name: :|, receiver: type, arguments: Prism::ArgumentsNode[arguments: [default]]]
89
+ type_slice = type.slice
90
+ default_string = default.slice
91
+ # Positional without default (e.g. `a = Type` becomes `a = nil`)
92
+ else
93
+ type_slice = typed_param.slice
94
+ default_string = "nil"
95
+ end
96
+
97
+ # replace the typed_param from the argument with the appropriate default value
98
+ @annotations << [
99
+ (start = typed_param.location.start_offset),
100
+ typed_param.location.end_offset - start,
101
+ default_string,
102
+ ]
103
+
104
+ preamble << "raise(::Empirical::TypeError.argument_type_error(name: '#{name}', value: #{name}, expected: #{type_slice}, method_name: __method__, context: self)) unless #{type_slice} === #{name}"
105
+
106
+ # Keyword (e.g. `a: Type` becomes `a: nil` or `a: default`)
107
+ in Prism::KeywordHashNode
108
+ argument.elements.each do |argument|
109
+ name = argument.key.unescaped
110
+
111
+ nilable = false
112
+
113
+ if name.end_with?("?")
114
+ name = name[0..-2]
115
+ nilable = true
116
+
117
+ @annotations << [
118
+ argument.key.location.end_offset - 2,
119
+ 1,
120
+ "",
121
+ ]
122
+ end
123
+
124
+ typed_param = argument.value
125
+
126
+ case typed_param
127
+ # Keyword splat (e.g. `a: {Type => Type}` becomes `**a`)
128
+ in Prism::HashNode[elements: [Prism::AssocNode[key: key_type, value: value_type]]]
129
+ # make argument a splat
130
+ @annotations << [
131
+ argument.key.location.start_offset,
132
+ 0,
133
+ "**",
134
+ ]
135
+
136
+ # remove the typed_param and equals operator from the argument
137
+ @annotations << [
138
+ argument.key.location.end_offset - 1,
139
+ typed_param.location.end_offset - argument.key.location.end_offset + 1,
140
+ "",
141
+ ]
142
+
143
+ preamble << "raise(::Empirical::TypeError.argument_type_error(name: '#{name}', value: #{name}, expected: ::Literal::_Hash(#{key_type.slice}, #{value_type.slice}), method_name: __method__, context: self)) unless ::Literal::_Hash(#{key_type.slice}, #{value_type.slice}) === #{name}"
144
+ else
145
+ case typed_param
146
+ # Keyword with default
147
+ in Prism::CallNode[name: :|, receiver: type, arguments: Prism::ArgumentsNode[arguments: [default]]]
148
+ type_slice = if nilable
149
+ "::Literal::_Nilable(#{type.slice})"
150
+ else
151
+ type.slice
152
+ end
153
+
154
+ default_string = default.slice
155
+ else
156
+ type_slice = if nilable
157
+ "::Literal::_Nilable(#{typed_param.slice})"
158
+ else
159
+ typed_param.slice
160
+ end
161
+
162
+ default_string = "nil"
163
+ end
164
+
165
+ # replace the typed_param from the argument with the appropriate default value
166
+ @annotations << [
167
+ (start = typed_param.location.start_offset),
168
+ typed_param.location.end_offset - start,
169
+ default_string,
170
+ ]
171
+
172
+ preamble << "raise(::Empirical::TypeError.argument_type_error(name: '#{name}', value: #{name}, expected: #{type_slice}, method_name: __method__, context: self)) unless #{type_slice} === #{name}"
173
+ end
174
+ end
175
+ else
176
+ # TODO: better error message
177
+ raise SyntaxError
178
+ end
179
+ end
180
+ else
181
+ # TODO: better error message
182
+ raise SyntaxError
183
+ end
184
+
185
+ preamble << "__literally_returns__ = ("
186
+ postamble << ")"
187
+
188
+ case return_type
189
+ in Prism::LocalVariableReadNode[name: :void] | Prism::CallNode[name: :void, receiver: nil, block: nil, arguments: nil]
190
+ postamble << "::Empirical::Void"
191
+ in Prism::LocalVariableReadNode[name: :never] | Prism::CallNode[name: :never, receiver: nil, block: nil, arguments: nil]
192
+ postamble << "raise(::Empirical::NeverError.new)"
193
+ else
194
+ postamble << "raise(::Empirical::TypeError.return_type_error(value: __literally_returns__, expected: #{return_type.slice}, method_name: __method__, context: self)) unless #{return_type.slice} === __literally_returns__"
195
+ postamble << "__literally_returns__"
196
+ end
197
+
198
+ # Replace `fun` with `def`
199
+ @annotations << [
200
+ (start = node.message_loc.start_offset),
201
+ node.message_loc.end_offset - start,
202
+ "def",
203
+ ]
204
+
205
+ # Remove the return type and `do` and replace with preamble
206
+ @annotations << [
207
+ (start = signature.location.end_offset),
208
+ body_block.opening_loc.end_offset - start,
209
+ ";#{preamble.join(';')};",
210
+ ]
211
+
212
+ # Insert postamble
213
+ @annotations << [
214
+ body_block.closing_loc.start_offset,
215
+ 0,
216
+ ";#{postamble.join(';')};",
217
+ ]
218
+ else
219
+ # TODO: better error message
220
+ raise SyntaxError
221
+ end
222
+
223
+ return_type
224
+ end
225
+
226
+ def visit_return_node(node)
227
+ case @return_type
228
+ in nil
229
+ # no-op
230
+ in Prism::LocalVariableReadNode[name: :void] | Prism::CallNode[name: :void, receiver: nil, block: nil, arguments: nil]
231
+ if node.arguments
232
+ raise "You’re returning something"
233
+ else
234
+ @annotations << [
235
+ node.keyword_loc.end_offset,
236
+ 0,
237
+ "(::Empirical::Void)",
238
+ ]
239
+ end
240
+ in Prism::LocalVariableReadNode[name: :never] | Prism::CallNode[name: :never, receiver: nil, block: nil, arguments: nil]
241
+ @annotations << [
242
+ node.keyword_loc.start_offset,
243
+ node.keyword_loc.end_offset - node.keyword_loc.start_offset,
244
+ "(raise(::Empirical::NeverError.new))",
245
+ ]
246
+ else
247
+ @annotations.push(
248
+ [
249
+ node.keyword_loc.start_offset,
250
+ node.keyword_loc.end_offset - node.keyword_loc.start_offset,
251
+ "(__literally_returning__ = (",
252
+ ],
253
+ [
254
+ node.location.end_offset,
255
+ 0,
256
+ ");(raise ::Empirical::TypeError.return_type_error(value: __literally_returning__, expected: #{@return_type.slice}, method_name: __method__, context: self) unless #{@return_type.slice} === __literally_returning__);return(__literally_returning__))",
257
+ ]
258
+ )
259
+ end
260
+
261
+ super
262
+ end
263
+ end
@@ -0,0 +1,28 @@
1
+ class Empirical::TypeError < ::TypeError
2
+ def self.argument_type_error(name:, value:, expected:, method_name:, context:)
3
+ owner = context.method(method_name).owner
4
+ sign = owner.singleton_class? ? "." : "#"
5
+
6
+ new(<<~MESSAGE)
7
+ Method #{method_name} called with the wrong type for the argument #{name}.
8
+
9
+ #{owner.name}#{sign}#{method_name}
10
+ #{name}:
11
+ Expected: #{expected.inspect}
12
+ Actual (#{value.class}): #{value.inspect}
13
+ MESSAGE
14
+ end
15
+
16
+ def self.return_type_error(value:, expected:, method_name:, context:)
17
+ owner = context.method(method_name).owner
18
+ sign = owner.singleton_class? ? "." : "#"
19
+
20
+ new(<<~MESSAGE)
21
+ Method #{method_name} returned the wrong type.
22
+
23
+ #{owner.name}#{sign}#{method_name}
24
+ Expected: #{expected.inspect}
25
+ Actual (#{value.class}): #{value.inspect}
26
+ MESSAGE
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Empirical
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.3"
5
5
  end
data/lib/empirical.rb CHANGED
@@ -1,28 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
3
  require "prism"
5
4
  require "securerandom"
6
-
5
+ require "literal"
7
6
  require "empirical/version"
8
7
  require "empirical/name_error"
8
+ require "empirical/type_error"
9
9
  require "empirical/base_processor"
10
- require "empirical/processor"
10
+ require "empirical/ivar_processor"
11
+ require "empirical/eval_processor"
12
+ require "empirical/class_callbacks_processor"
13
+ require "empirical/signature_processor"
11
14
  require "empirical/configuration"
12
15
 
13
16
  require "require-hooks/setup"
14
17
 
15
18
  module Empirical
19
+ class VoidClass < BasicObject
20
+ def method_missing(method_name, ...)
21
+ ::Kernel.raise "The method `#{method_name}` was called on void. Methods that explicitly declare a void return type should not have their return values used for anything."
22
+ end
23
+ end
24
+
25
+ Void = VoidClass.new
26
+
16
27
  EMPTY_ARRAY = [].freeze
17
28
  EVERYTHING = ["**/*"].freeze
18
29
  METHOD_METHOD = Module.instance_method(:method)
19
30
 
20
31
  CONFIG = Configuration.new
21
- TypedSignatureError = Class.new(StandardError)
32
+ PROCESSORS = [
33
+ IvarProcessor,
34
+ SignatureProcessor,
35
+ ClassCallbacksProcessor,
36
+ ]
37
+
38
+ TypedSignatureError = Class.new(SyntaxError)
39
+ NeverError = Class.new(RuntimeError)
22
40
 
23
- # Initializes Empirical so that code loaded after this point will be
24
- # guarded against undefined instance variable reads. You can pass an array
25
- # of globs to `include:` and `exclude:`.
41
+ # Initializes Empirical so that code loaded after this point will:
42
+ # 1. be guarded against undefined instance variable reads,
43
+ # 2. permit users to define type checked method definitions, and
44
+ # 3. permit users to define class/module defined callbacks
45
+ #
46
+ # You can pass an array of globs to `include:` and `exclude:`.
26
47
  #
27
48
  # ```ruby
28
49
  # Empirical.init(
@@ -42,55 +63,34 @@ module Empirical
42
63
  source ||= File.read(path)
43
64
 
44
65
  if CONFIG.match?(path)
45
- Processor.call(source)
66
+ process(source, with: PROCESSORS)
46
67
  else
47
- BaseProcessor.call(source)
68
+ process(source)
48
69
  end
49
70
  end
50
71
  end
51
72
 
52
- # For internal use only. This method pre-processes arguments to an eval method.
53
- #: (Object, Symbol, *untyped)
54
- def self.__process_eval_args__(receiver, method_name, *args)
55
- method = METHOD_METHOD.bind_call(receiver, method_name)
56
- owner = method.owner
57
-
58
- source, file = nil
73
+ def self.process(source, with: [])
74
+ annotations = []
75
+ tree = Prism.parse(source).value
59
76
 
60
- case method_name
61
- when :class_eval, :module_eval
62
- if Module == owner
63
- source, file = args
64
- end
65
- when :instance_eval
66
- if BasicObject == owner
67
- source, file = args
68
- end
69
- when :eval
70
- if Kernel == owner
71
- source, binding, file = args
72
- elsif Binding == owner
73
- source, file = args
74
- end
77
+ Array(with).each do |processor|
78
+ processor.new(annotations:).visit(tree)
75
79
  end
76
80
 
77
- if String === source
78
- file ||= caller_locations(1, 1).first.path
81
+ Empirical::EvalProcessor.new(annotations:).visit(tree)
79
82
 
80
- if CONFIG.match?(file)
81
- args[0] = Processor.call(source)
82
- else
83
- args[0] = BaseProcessor.call(source)
84
- end
83
+ buffer = source.dup
84
+ annotations.sort_by!(&:first)
85
+
86
+ annotations.reverse_each do |offset, length, string|
87
+ buffer[offset, length] = string
85
88
  end
86
89
 
87
- args
88
- rescue ::NameError
89
- args
90
+ buffer
90
91
  end
92
+ end
91
93
 
92
- #: () { () -> void } -> Proc
93
- def self.__eval_block_from_forwarding__(*, &block)
94
- block
95
- end
94
+ class Object
95
+ include Literal::Types
96
96
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RuboCop::Cop::Empirical::NoDefs < RuboCop::Cop::Base
4
+ MSG = "Use `fun` method definitions instead of `def` method definitions."
5
+
6
+ def on_def(node)
7
+ add_offense(node) unless node.arguments.any?(&:forward_args_type?)
8
+ end
9
+
10
+ def on_defs(node)
11
+ add_offense(node) unless node.arguments.any?(&:forward_args_type?)
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Empirical; end
6
+ end
7
+ end
8
+
9
+ require_relative "empirical/no_defs"
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+ require_relative "rubocop/cop/empirical"
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_lsp/addon"
4
+
5
+ module RubyLsp
6
+ module Empirical
7
+ class Addon < ::RubyLsp::Addon
8
+ def activate(global_state, message_queue)
9
+ end
10
+
11
+ def deactivate
12
+ end
13
+
14
+ def name
15
+ "Empirical"
16
+ end
17
+
18
+ def version
19
+ "0.1.0"
20
+ end
21
+ end
22
+
23
+ class IndexingEnhancement < RubyIndexer::Enhancement
24
+ def on_call_node_enter(node)
25
+ call_name = node.name
26
+ owner = @listener.current_owner
27
+ location = node.location
28
+
29
+ return unless owner
30
+ return unless :fun == call_name
31
+ return unless node.arguments
32
+
33
+ # Match the pattern: fun foo(...) => ReturnType do ... end
34
+ case node
35
+ in {
36
+ arguments: Prism::ArgumentsNode[
37
+ arguments: [
38
+ Prism::KeywordHashNode[
39
+ elements: [
40
+ Prism::AssocNode[
41
+ key: signature,
42
+ value: _return_type
43
+ ]
44
+ ]
45
+ ]
46
+ ]
47
+ ],
48
+ block: Prism::BlockNode
49
+ }
50
+ # Extract method name from signature
51
+ method_name = case signature
52
+ in Prism::LocalVariableReadNode
53
+ signature.name.to_s
54
+ in Prism::ConstantReadNode
55
+ signature.name.to_s
56
+ in Prism::CallNode
57
+ signature.name.to_s
58
+ else
59
+ return
60
+ end
61
+
62
+ # Extract parameters from signature if it's a call node
63
+ parameters = []
64
+ if signature.is_a?(Prism::CallNode) && signature.arguments
65
+ signature.arguments.arguments.each do |arg|
66
+ case arg
67
+ in Prism::LocalVariableWriteNode[name: param_name]
68
+ parameters << RubyIndexer::Entry::OptionalParameter.new(name: param_name.to_s)
69
+ in Prism::KeywordHashNode
70
+ arg.elements.each do |element|
71
+ param_name = element.key.unescaped
72
+ parameters << RubyIndexer::Entry::OptionalKeywordParameter.new(name: param_name)
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ @listener.add_method(
79
+ method_name,
80
+ location,
81
+ [RubyIndexer::Entry::Signature.new(parameters)]
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: empirical
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Drapper
@@ -38,10 +38,25 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: literal
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  description: Based on, concerned with, or verifiable by observation or experience
42
56
  rather than theory or pure logic.
43
57
  email:
44
58
  - joel@drapper.me
59
+ - stephen.margheim@gmail.com
45
60
  executables: []
46
61
  extensions: []
47
62
  extra_rdoc_files: []
@@ -50,10 +65,18 @@ files:
50
65
  - README.md
51
66
  - lib/empirical.rb
52
67
  - lib/empirical/base_processor.rb
68
+ - lib/empirical/class_callbacks_processor.rb
53
69
  - lib/empirical/configuration.rb
70
+ - lib/empirical/eval_processor.rb
71
+ - lib/empirical/ivar_processor.rb
54
72
  - lib/empirical/name_error.rb
55
- - lib/empirical/processor.rb
73
+ - lib/empirical/signature_processor.rb
74
+ - lib/empirical/type_error.rb
56
75
  - lib/empirical/version.rb
76
+ - lib/rubocop-empirical.rb
77
+ - lib/rubocop/cop/empirical.rb
78
+ - lib/rubocop/cop/empirical/no_defs.rb
79
+ - lib/ruby_lsp/empirical/addon.rb
57
80
  homepage: https://github.com/yippee-fun/empirical
58
81
  licenses:
59
82
  - MIT
@@ -1,257 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class Empirical::Processor < Empirical::BaseProcessor
4
- #: (Prism::ClassNode) -> void
5
- def visit_class_node(node)
6
- new_context { super }
7
- end
8
-
9
- #: (Prism::ModuleNode) -> void
10
- def visit_module_node(node)
11
- new_context { super }
12
- end
13
-
14
- #: (Prism::BlockNode) -> void
15
- def visit_block_node(node)
16
- new_context { super }
17
- end
18
-
19
- #: (Prism::SingletonClassNode) -> void
20
- def visit_singleton_class_node(node)
21
- new_context { super }
22
- end
23
-
24
- #: (Prism::IfNode) -> void
25
- def visit_if_node(node)
26
- visit(node.predicate)
27
-
28
- branch { visit(node.statements) }
29
- branch { visit(node.subsequent) }
30
- end
31
-
32
- #: (Prism::CaseNode) -> void
33
- def visit_case_node(node)
34
- visit(node.predicate)
35
-
36
- node.conditions.each do |condition|
37
- branch { visit(condition) }
38
- end
39
-
40
- branch { visit(node.else_clause) }
41
- end
42
-
43
- #: (Prism::DefinedNode) -> void
44
- def visit_defined_node(node)
45
- value = node.value
46
-
47
- return if Prism::InstanceVariableReadNode === value
48
-
49
- super
50
- end
51
-
52
- #: (Prism::InstanceVariableReadNode) -> void
53
- def visit_instance_variable_read_node(node)
54
- name = node.name
55
-
56
- unless @context.include?(name)
57
- location = node.location
58
-
59
- @context << name
60
-
61
- @annotations.push(
62
- [location.start_character_offset, 0, "(defined?(#{name}) ? "],
63
- [location.end_character_offset, 0, " : (::Kernel.raise(::Empirical::NameError.new(self, :#{name}))))"]
64
- )
65
- end
66
-
67
- super
68
- end
69
-
70
- #: () { () -> void } -> void
71
- private def new_context
72
- original_context = @context
73
-
74
- @context = Set[]
75
-
76
- begin
77
- yield
78
- ensure
79
- @context = original_context
80
- end
81
- end
82
-
83
- #: () { () -> void } -> void
84
- private def branch
85
- original_context = @context
86
- @context = original_context.dup
87
-
88
- begin
89
- yield
90
- ensure
91
- @context = original_context
92
- end
93
- end
94
-
95
- def visit_def_node(node)
96
- new_context do
97
- return super unless node.equal_loc
98
- return super unless node in {
99
- body: {
100
- body: [
101
- Prism::CallNode[
102
- block: Prism::BlockNode[
103
- body: Prism::StatementsNode
104
- ] => block
105
- ] => call
106
- ]
107
- }
108
- }
109
-
110
- signature = build_typed_parameters_assertion(node)
111
-
112
- if node.rparen_loc
113
- @annotations << [
114
- start = node.rparen_loc.start_offset + 1,
115
- block.opening_loc.end_offset - start,
116
- ";binding.assert(#{signature});__literally_returns__ = (;",
117
- ]
118
- else
119
- @annotations << [
120
- start = node.equal_loc.start_offset - 1,
121
- block.opening_loc.end_offset - start,
122
- ";__literally_returns__ = (;",
123
- ]
124
- end
125
-
126
- return_type = if call.closing_loc
127
- node.slice[(call.start_offset)...(call.closing_loc.end_offset)]
128
- else
129
- call.name
130
- end
131
- @annotations << [
132
- block.closing_loc.start_offset,
133
- 0,
134
- ";);binding.assert(__literally_returns__: #{return_type});__literally_returns__;",
135
- ]
136
-
137
- @annotations << [
138
- start = block.closing_loc.start_offset,
139
- block.closing_loc.end_offset - start,
140
- "end",
141
- ]
142
- end
143
- end
144
-
145
- private def build_typed_parameters_assertion(node)
146
- return unless node.parameters
147
-
148
- if (requireds = node.parameters.requireds)&.any?
149
- raise Empirical::TypedSignatureError.new("Typed method signatures don't allow required keyword parameters: #{requireds.inspect}")
150
- elsif (rest = node.parameters.rest)&.any?
151
- raise Empirical::TypedSignatureError.new("Typed method signatures don't allow a splat array parameter: #{rest.inspect}")
152
- elsif (posts = node.parameters.posts)&.any?
153
- raise Empirical::TypedSignatureError.new("Typed method signatures don't allow a splat hash parameter: #{posts.inspect}")
154
- elsif (keyword_rest = node.parameters.keyword_rest)&.any?
155
- raise Empirical::TypedSignatureError.new("Typed method signatures don't allow a splat hash parameter: #{keyword_rest.inspect}")
156
- end
157
-
158
- parameters_assertions = []
159
-
160
- if (optionals = node.parameters.optionals)&.any?
161
- parameters_assertions << optionals.map do |optional|
162
- case optional
163
- # typed splats, e.g.
164
- # `(names = [String])` => `(*names); assert(names: _Array(String))` and
165
- # `(position = [*Position])` => `(*position); assert(position: Position)`
166
- in { value: Prism::ArrayNode[elements: [type_node]] => value }
167
- if type_node in Prism::SplatNode
168
- type = type_node.expression.slice
169
- else
170
- type = "::Literal::_Array(#{type_node.slice})"
171
- end
172
-
173
- # Make the parameter a splat
174
- @annotations << [optional.name_loc.start_offset, 0, "*"]
175
-
176
- # Remove the type signature (the default value)
177
- @annotations << [optional.operator_loc.start_offset, value.closing_loc.end_offset - optional.operator_loc.start_offset, ""]
178
- next "#{optional.name}: #{type}"
179
- # With default
180
- in {
181
- value: Prism::CallNode[
182
- block: Prism::BlockNode[
183
- body: Prism::StatementsNode => default_node
184
- ]
185
- ] => call
186
- }
187
- default = "(#{default_node.slice})"
188
-
189
- type = if call.closing_loc
190
- node.slice[(call.start_offset)...(call.closing_loc.end_offset)]
191
- else
192
- call.name
193
- end
194
- # No default
195
- else
196
- default = "nil"
197
- type = optional.value.slice
198
- end
199
-
200
- value_location = optional.value.location
201
- @annotations << [value_location.start_offset, value_location.end_offset - value_location.start_offset, default]
202
- "#{optional.name}: #{type}"
203
- end.join(", ")
204
- end
205
-
206
- if (keywords = node.parameters.keywords)&.any?
207
- parameters_assertions << keywords.map do |keyword|
208
- case keyword
209
- # Splat
210
- in { value: Prism::HashNode[elements: [Prism::AssocNode[key: key_type_node, value: val_type_node]]] => value }
211
- type = "::Literal::_Hash(#{key_type_node.slice}, #{val_type_node.slice})"
212
-
213
- # Make the parameter a splat
214
- @annotations << [keyword.name_loc.start_offset, 0, "**"]
215
-
216
- # Remove the type signature (the default value) and the colon at the end of the keyword
217
- @annotations << [keyword.name_loc.end_offset - 1, value.closing_loc.end_offset - keyword.name_loc.end_offset + 1, ""]
218
- next "#{keyword.name}: #{type}"
219
- in { value: Prism::HashNode[elements: [Prism::AssocSplatNode[value: val_type_node]]] => value }
220
- type = val_type_node.slice
221
-
222
- # Make the parameter a splat
223
- @annotations << [keyword.name_loc.start_offset, 0, "**"]
224
-
225
- # Remove the type signature (the default value) and the colon at the end of the keyword
226
- @annotations << [keyword.name_loc.end_offset - 1, value.closing_loc.end_offset - keyword.name_loc.end_offset + 1, ""]
227
- next "#{keyword.name}: #{type}"
228
- # With default
229
- in {
230
- value: Prism::CallNode[
231
- block: Prism::BlockNode[
232
- body: Prism::StatementsNode => default_node
233
- ]
234
- ] => call
235
- }
236
- default = "(#{default_node.slice})"
237
-
238
- type = if call.closing_loc
239
- node.slice[(call.start_offset)...(call.closing_loc.end_offset)]
240
- else
241
- call.name
242
- end
243
- # No default
244
- else
245
- default = "nil"
246
- type = keyword.value.slice
247
- end
248
-
249
- value_location = keyword.value.location
250
- @annotations << [value_location.start_offset, value_location.end_offset - value_location.start_offset, default]
251
- "#{keyword.name}: #{type}"
252
- end.join(", ")
253
- end
254
-
255
- parameters_assertions.join(", ")
256
- end
257
- end