empirical 0.0.1 → 0.0.2
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 +4 -4
- data/README.md +1 -155
- data/lib/empirical/base_processor.rb +2 -53
- data/lib/empirical/class_callbacks_processor.rb +23 -0
- data/lib/empirical/eval_processor.rb +85 -0
- data/lib/empirical/ivar_processor.rb +88 -0
- data/lib/empirical/signature_processor.rb +239 -0
- data/lib/empirical/type_error.rb +28 -0
- data/lib/empirical/version.rb +1 -1
- data/lib/empirical.rb +45 -45
- data/lib/ruby_lsp/empirical/addon.rb +87 -0
- metadata +21 -2
- data/lib/empirical/processor.rb +0 -257
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e5f02cbba70ea2ac807d23f7a60cdd1e7bbd35f8df0b4fd0c9bd7e7d38dd18f
|
|
4
|
+
data.tar.gz: 927a9d036d77e2974b1dbe6d3ea1ab922a234627a653c50c5746bde132a4045b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9237b1059fa32b97c00cab01ff5d66a1131db3bca172abe55fddb44f13277496f921ede321421eb55672628c4666ad84a2077b3a6f87a5ab0e7f3041871a379
|
|
7
|
+
data.tar.gz: aa14bc1a19ab39363c500bf0c4d012f39e6c48cdb9b7221f7cfce9b748eafea147df40733eae240729f9b96378cba94f1a64c4cde378cc3dd258c12b57935253
|
data/README.md
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
|
-
#
|
|
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.
|
|
4
|
-
|
|
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
|
-
|
|
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.
|
|
10
|
-
|
|
11
|
-
> [!NOTE]
|
|
12
|
-
> JRuby and TruffleRuby are not currently supported.
|
|
1
|
+
# Empirical
|
|
13
2
|
|
|
14
3
|
## Setup
|
|
15
4
|
|
|
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
5
|
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.
|
|
19
6
|
|
|
20
7
|
```ruby
|
|
@@ -32,144 +19,3 @@ You can pass an array of globs to `Empirical.init` as `include:` and `exclude:`
|
|
|
32
19
|
```ruby
|
|
33
20
|
Empirical.init(include: ["#{Dir.pwd}/**/*"], exclude: ["#{Dir.pwd}/vendor/**/*"])
|
|
34
21
|
```
|
|
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
|
-
|
|
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,239 @@
|
|
|
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
|
+
typed_param = argument.value
|
|
111
|
+
|
|
112
|
+
case typed_param
|
|
113
|
+
# Keyword splat (e.g. `a: {Type => Type}` becomes `**a`)
|
|
114
|
+
in Prism::HashNode[elements: [Prism::AssocNode[key: key_type, value: value_type]]]
|
|
115
|
+
# make argument a splat
|
|
116
|
+
@annotations << [
|
|
117
|
+
argument.key.location.start_offset,
|
|
118
|
+
0,
|
|
119
|
+
"**",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# remove the typed_param and equals operator from the argument
|
|
123
|
+
@annotations << [
|
|
124
|
+
argument.key.location.end_offset - 1,
|
|
125
|
+
typed_param.location.end_offset - argument.key.location.end_offset + 1,
|
|
126
|
+
"",
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
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}"
|
|
130
|
+
else
|
|
131
|
+
case typed_param
|
|
132
|
+
# Keyword with default
|
|
133
|
+
in Prism::CallNode[name: :|, receiver: type, arguments: Prism::ArgumentsNode[arguments: [default]]]
|
|
134
|
+
type_slice = type.slice
|
|
135
|
+
default_string = default.slice
|
|
136
|
+
else
|
|
137
|
+
type_slice = typed_param.slice
|
|
138
|
+
default_string = "nil"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# replace the typed_param from the argument with the appropriate default value
|
|
142
|
+
@annotations << [
|
|
143
|
+
(start = typed_param.location.start_offset),
|
|
144
|
+
typed_param.location.end_offset - start,
|
|
145
|
+
default_string,
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
preamble << "raise(::Empirical::TypeError.argument_type_error(name: '#{name}', value: #{name}, expected: #{type_slice}, method_name: __method__, context: self)) unless #{type_slice} === #{name}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
else
|
|
152
|
+
# TODO: better error message
|
|
153
|
+
raise SyntaxError
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
else
|
|
157
|
+
# TODO: better error message
|
|
158
|
+
raise SyntaxError
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
preamble << "__literally_returns__ = ("
|
|
162
|
+
postamble << ")"
|
|
163
|
+
|
|
164
|
+
case return_type
|
|
165
|
+
in Prism::LocalVariableReadNode[name: :void] | Prism::CallNode[name: :void, receiver: nil, block: nil, arguments: nil]
|
|
166
|
+
postamble << "::Empirical::Void"
|
|
167
|
+
in Prism::LocalVariableReadNode[name: :never] | Prism::CallNode[name: :never, receiver: nil, block: nil, arguments: nil]
|
|
168
|
+
postamble << "raise(::Empirical::NeverError.new)"
|
|
169
|
+
else
|
|
170
|
+
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__"
|
|
171
|
+
postamble << "__literally_returns__"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Replace `fun` with `def`
|
|
175
|
+
@annotations << [
|
|
176
|
+
(start = node.message_loc.start_offset),
|
|
177
|
+
node.message_loc.end_offset - start,
|
|
178
|
+
"def",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# Remove the return type and `do` and replace with preamble
|
|
182
|
+
@annotations << [
|
|
183
|
+
(start = signature.location.end_offset),
|
|
184
|
+
body_block.opening_loc.end_offset - start,
|
|
185
|
+
";#{preamble.join(';')};",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
# Insert postamble
|
|
189
|
+
@annotations << [
|
|
190
|
+
body_block.closing_loc.start_offset,
|
|
191
|
+
0,
|
|
192
|
+
";#{postamble.join(';')};",
|
|
193
|
+
]
|
|
194
|
+
else
|
|
195
|
+
# TODO: better error message
|
|
196
|
+
raise SyntaxError
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
return_type
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def visit_return_node(node)
|
|
203
|
+
case @return_type
|
|
204
|
+
in nil
|
|
205
|
+
# no-op
|
|
206
|
+
in Prism::LocalVariableReadNode[name: :void] | Prism::CallNode[name: :void, receiver: nil, block: nil, arguments: nil]
|
|
207
|
+
if node.arguments
|
|
208
|
+
raise "You’re returning something"
|
|
209
|
+
else
|
|
210
|
+
@annotations << [
|
|
211
|
+
node.keyword_loc.end_offset,
|
|
212
|
+
0,
|
|
213
|
+
"(::Empirical::Void)",
|
|
214
|
+
]
|
|
215
|
+
end
|
|
216
|
+
in Prism::LocalVariableReadNode[name: :never] | Prism::CallNode[name: :never, receiver: nil, block: nil, arguments: nil]
|
|
217
|
+
@annotations << [
|
|
218
|
+
node.keyword_loc.start_offset,
|
|
219
|
+
node.keyword_loc.end_offset - node.keyword_loc.start_offset,
|
|
220
|
+
"(raise(::Empirical::NeverError.new))",
|
|
221
|
+
]
|
|
222
|
+
else
|
|
223
|
+
@annotations.push(
|
|
224
|
+
[
|
|
225
|
+
node.keyword_loc.start_offset,
|
|
226
|
+
node.keyword_loc.end_offset - node.keyword_loc.start_offset,
|
|
227
|
+
"(__literally_returning__ = (",
|
|
228
|
+
],
|
|
229
|
+
[
|
|
230
|
+
node.location.end_offset,
|
|
231
|
+
0,
|
|
232
|
+
");(raise ::Empirical::TypeError.return_type_error(value: __literally_returning__, expected: #{@return_type}, method_name: __method__, context: self) unless #{@return_type} === __literally_returning__);return(__literally_returning__))",
|
|
233
|
+
]
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
super
|
|
238
|
+
end
|
|
239
|
+
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
|
data/lib/empirical/version.rb
CHANGED
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/
|
|
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
|
-
|
|
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
|
|
24
|
-
# guarded against undefined instance variable reads
|
|
25
|
-
#
|
|
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
|
-
|
|
66
|
+
process(source, with: PROCESSORS)
|
|
46
67
|
else
|
|
47
|
-
|
|
68
|
+
process(source)
|
|
48
69
|
end
|
|
49
70
|
end
|
|
50
71
|
end
|
|
51
72
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
78
|
-
file ||= caller_locations(1, 1).first.path
|
|
81
|
+
Empirical::EvalProcessor.new(annotations:).visit(tree)
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
rescue ::NameError
|
|
89
|
-
args
|
|
90
|
+
buffer
|
|
90
91
|
end
|
|
92
|
+
end
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
block
|
|
95
|
-
end
|
|
94
|
+
class Object
|
|
95
|
+
include Literal::Types
|
|
96
96
|
end
|
|
@@ -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.
|
|
4
|
+
version: 0.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joel Drapper
|
|
@@ -38,6 +38,20 @@ 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:
|
|
@@ -50,10 +64,15 @@ files:
|
|
|
50
64
|
- README.md
|
|
51
65
|
- lib/empirical.rb
|
|
52
66
|
- lib/empirical/base_processor.rb
|
|
67
|
+
- lib/empirical/class_callbacks_processor.rb
|
|
53
68
|
- lib/empirical/configuration.rb
|
|
69
|
+
- lib/empirical/eval_processor.rb
|
|
70
|
+
- lib/empirical/ivar_processor.rb
|
|
54
71
|
- lib/empirical/name_error.rb
|
|
55
|
-
- lib/empirical/
|
|
72
|
+
- lib/empirical/signature_processor.rb
|
|
73
|
+
- lib/empirical/type_error.rb
|
|
56
74
|
- lib/empirical/version.rb
|
|
75
|
+
- lib/ruby_lsp/empirical/addon.rb
|
|
57
76
|
homepage: https://github.com/yippee-fun/empirical
|
|
58
77
|
licenses:
|
|
59
78
|
- MIT
|
data/lib/empirical/processor.rb
DELETED
|
@@ -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
|