steep-relaxed 1.9.3.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 +7 -0
- data/.gitignore +13 -0
- data/.gitmodules +0 -0
- data/CHANGELOG.md +1032 -0
- data/LICENSE +21 -0
- data/README.md +260 -0
- data/Rakefile +227 -0
- data/STDGEM_DEPENDENCIES.txt +59 -0
- data/Steepfile +68 -0
- data/bin/console +14 -0
- data/bin/generate-diagnostics-docs.rb +112 -0
- data/bin/mem_graph.rb +67 -0
- data/bin/mem_prof.rb +102 -0
- data/bin/output_rebaseline.rb +34 -0
- data/bin/output_test.rb +60 -0
- data/bin/rbs +20 -0
- data/bin/rbs-inline +19 -0
- data/bin/setup +9 -0
- data/bin/stackprof_test.rb +19 -0
- data/bin/steep +19 -0
- data/bin/steep-check.rb +251 -0
- data/bin/steep-prof +16 -0
- data/doc/narrowing.md +195 -0
- data/doc/shape.md +194 -0
- data/exe/steep +18 -0
- data/guides/README.md +5 -0
- data/guides/src/gem-rbs-collection/gem-rbs-collection.md +126 -0
- data/guides/src/getting-started/getting-started.md +163 -0
- data/guides/src/nil-optional/nil-optional.md +195 -0
- data/lib/steep/annotation_parser.rb +199 -0
- data/lib/steep/ast/annotation/collection.rb +172 -0
- data/lib/steep/ast/annotation.rb +137 -0
- data/lib/steep/ast/builtin.rb +104 -0
- data/lib/steep/ast/ignore.rb +148 -0
- data/lib/steep/ast/node/type_application.rb +88 -0
- data/lib/steep/ast/node/type_assertion.rb +81 -0
- data/lib/steep/ast/types/any.rb +35 -0
- data/lib/steep/ast/types/boolean.rb +45 -0
- data/lib/steep/ast/types/bot.rb +35 -0
- data/lib/steep/ast/types/class.rb +43 -0
- data/lib/steep/ast/types/factory.rb +557 -0
- data/lib/steep/ast/types/helper.rb +40 -0
- data/lib/steep/ast/types/instance.rb +42 -0
- data/lib/steep/ast/types/intersection.rb +93 -0
- data/lib/steep/ast/types/literal.rb +59 -0
- data/lib/steep/ast/types/logic.rb +84 -0
- data/lib/steep/ast/types/name.rb +128 -0
- data/lib/steep/ast/types/nil.rb +41 -0
- data/lib/steep/ast/types/proc.rb +117 -0
- data/lib/steep/ast/types/record.rb +79 -0
- data/lib/steep/ast/types/self.rb +43 -0
- data/lib/steep/ast/types/shared_instance.rb +11 -0
- data/lib/steep/ast/types/top.rb +35 -0
- data/lib/steep/ast/types/tuple.rb +60 -0
- data/lib/steep/ast/types/union.rb +97 -0
- data/lib/steep/ast/types/var.rb +65 -0
- data/lib/steep/ast/types/void.rb +35 -0
- data/lib/steep/cli.rb +401 -0
- data/lib/steep/diagnostic/deprecated/else_on_exhaustive_case.rb +20 -0
- data/lib/steep/diagnostic/deprecated/unknown_constant_assigned.rb +28 -0
- data/lib/steep/diagnostic/helper.rb +18 -0
- data/lib/steep/diagnostic/lsp_formatter.rb +78 -0
- data/lib/steep/diagnostic/result_printer2.rb +48 -0
- data/lib/steep/diagnostic/ruby.rb +1221 -0
- data/lib/steep/diagnostic/signature.rb +570 -0
- data/lib/steep/drivers/annotations.rb +52 -0
- data/lib/steep/drivers/check.rb +339 -0
- data/lib/steep/drivers/checkfile.rb +210 -0
- data/lib/steep/drivers/diagnostic_printer.rb +105 -0
- data/lib/steep/drivers/init.rb +66 -0
- data/lib/steep/drivers/langserver.rb +56 -0
- data/lib/steep/drivers/print_project.rb +113 -0
- data/lib/steep/drivers/stats.rb +203 -0
- data/lib/steep/drivers/utils/driver_helper.rb +143 -0
- data/lib/steep/drivers/utils/jobs_option.rb +26 -0
- data/lib/steep/drivers/vendor.rb +27 -0
- data/lib/steep/drivers/watch.rb +194 -0
- data/lib/steep/drivers/worker.rb +58 -0
- data/lib/steep/equatable.rb +23 -0
- data/lib/steep/expectations.rb +228 -0
- data/lib/steep/index/rbs_index.rb +350 -0
- data/lib/steep/index/signature_symbol_provider.rb +185 -0
- data/lib/steep/index/source_index.rb +167 -0
- data/lib/steep/interface/block.rb +103 -0
- data/lib/steep/interface/builder.rb +843 -0
- data/lib/steep/interface/function.rb +1090 -0
- data/lib/steep/interface/method_type.rb +330 -0
- data/lib/steep/interface/shape.rb +239 -0
- data/lib/steep/interface/substitution.rb +159 -0
- data/lib/steep/interface/type_param.rb +115 -0
- data/lib/steep/located_value.rb +20 -0
- data/lib/steep/method_name.rb +42 -0
- data/lib/steep/module_helper.rb +24 -0
- data/lib/steep/node_helper.rb +273 -0
- data/lib/steep/path_helper.rb +30 -0
- data/lib/steep/project/dsl.rb +268 -0
- data/lib/steep/project/group.rb +31 -0
- data/lib/steep/project/options.rb +63 -0
- data/lib/steep/project/pattern.rb +59 -0
- data/lib/steep/project/target.rb +92 -0
- data/lib/steep/project.rb +78 -0
- data/lib/steep/rake_task.rb +132 -0
- data/lib/steep/range_extension.rb +29 -0
- data/lib/steep/server/base_worker.rb +97 -0
- data/lib/steep/server/change_buffer.rb +73 -0
- data/lib/steep/server/custom_methods.rb +77 -0
- data/lib/steep/server/delay_queue.rb +45 -0
- data/lib/steep/server/interaction_worker.rb +492 -0
- data/lib/steep/server/lsp_formatter.rb +455 -0
- data/lib/steep/server/master.rb +922 -0
- data/lib/steep/server/target_group_files.rb +205 -0
- data/lib/steep/server/type_check_controller.rb +366 -0
- data/lib/steep/server/type_check_worker.rb +303 -0
- data/lib/steep/server/work_done_progress.rb +64 -0
- data/lib/steep/server/worker_process.rb +176 -0
- data/lib/steep/services/completion_provider.rb +802 -0
- data/lib/steep/services/content_change.rb +61 -0
- data/lib/steep/services/file_loader.rb +74 -0
- data/lib/steep/services/goto_service.rb +441 -0
- data/lib/steep/services/hover_provider/rbs.rb +88 -0
- data/lib/steep/services/hover_provider/ruby.rb +221 -0
- data/lib/steep/services/hover_provider/singleton_methods.rb +20 -0
- data/lib/steep/services/path_assignment.rb +46 -0
- data/lib/steep/services/signature_help_provider.rb +202 -0
- data/lib/steep/services/signature_service.rb +428 -0
- data/lib/steep/services/stats_calculator.rb +68 -0
- data/lib/steep/services/type_check_service.rb +394 -0
- data/lib/steep/services/type_name_completion.rb +236 -0
- data/lib/steep/signature/validator.rb +651 -0
- data/lib/steep/source/ignore_ranges.rb +69 -0
- data/lib/steep/source.rb +691 -0
- data/lib/steep/subtyping/cache.rb +30 -0
- data/lib/steep/subtyping/check.rb +1113 -0
- data/lib/steep/subtyping/constraints.rb +341 -0
- data/lib/steep/subtyping/relation.rb +101 -0
- data/lib/steep/subtyping/result.rb +324 -0
- data/lib/steep/subtyping/variable_variance.rb +89 -0
- data/lib/steep/test.rb +9 -0
- data/lib/steep/thread_waiter.rb +43 -0
- data/lib/steep/type_construction.rb +5183 -0
- data/lib/steep/type_inference/block_params.rb +416 -0
- data/lib/steep/type_inference/case_when.rb +303 -0
- data/lib/steep/type_inference/constant_env.rb +56 -0
- data/lib/steep/type_inference/context.rb +195 -0
- data/lib/steep/type_inference/logic_type_interpreter.rb +613 -0
- data/lib/steep/type_inference/method_call.rb +193 -0
- data/lib/steep/type_inference/method_params.rb +531 -0
- data/lib/steep/type_inference/multiple_assignment.rb +194 -0
- data/lib/steep/type_inference/send_args.rb +712 -0
- data/lib/steep/type_inference/type_env.rb +341 -0
- data/lib/steep/type_inference/type_env_builder.rb +138 -0
- data/lib/steep/typing.rb +321 -0
- data/lib/steep/version.rb +3 -0
- data/lib/steep.rb +369 -0
- data/manual/annotations.md +181 -0
- data/manual/ignore.md +20 -0
- data/manual/ruby-diagnostics.md +1879 -0
- data/sample/Steepfile +22 -0
- data/sample/lib/conference.rb +49 -0
- data/sample/lib/length.rb +35 -0
- data/sample/sig/conference.rbs +42 -0
- data/sample/sig/generics.rbs +15 -0
- data/sample/sig/length.rbs +34 -0
- data/steep-relaxed.gemspec +56 -0
- metadata +340 -0
@@ -0,0 +1,126 @@
|
|
1
|
+
# Using RBS from gem_rbs_collection
|
2
|
+
|
3
|
+
gem_rbs_collection is a repository of type definitions of gems that are managed by the community. You may find the type definition of a gem that ships without RBS type definitions.
|
4
|
+
|
5
|
+
To use RBS files from the repository, you can use rbs-collection subcommand. This guide explains how the command works.
|
6
|
+
|
7
|
+
## Quick start
|
8
|
+
|
9
|
+
Run rbs-collection-init to start setup.
|
10
|
+
|
11
|
+
```
|
12
|
+
$ rbs collection init
|
13
|
+
```
|
14
|
+
|
15
|
+
You have to edit your `Gemfile`. Specify `require: false` for gems for which you do not want type definitions.
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'rbs', require: false
|
19
|
+
gem 'steep', require: false
|
20
|
+
gem 'rbs_rails', require: false
|
21
|
+
gem 'rbs_protobuf', require: false
|
22
|
+
```
|
23
|
+
|
24
|
+
Once you save the file, run the install command.
|
25
|
+
|
26
|
+
```
|
27
|
+
$ rbs collection install
|
28
|
+
```
|
29
|
+
|
30
|
+
That generates `rbs_collection.lock.yaml` and downloads the RBS files from the git repository.
|
31
|
+
|
32
|
+
Note that rbs-collection automatically downloads RBS files of gems included in your Bundler environment. You don't have to write all of the gems in your `Gemfile` in `rbs_collection.yaml`.
|
33
|
+
|
34
|
+
Finally, we recommend adding the `rbs_collection.yaml` and `rbs_collection.lock.yaml` to your repository, and ignoring `.gem_rbs_collection` directory.
|
35
|
+
|
36
|
+
```
|
37
|
+
$ git add rbs_collection.yaml
|
38
|
+
$ git add rbs_collection.lock.yaml
|
39
|
+
$ echo /.gem_rbs_collection >> .gitignore
|
40
|
+
$ git commit -m "Set up rbs-collection"
|
41
|
+
```
|
42
|
+
|
43
|
+
## Updating RBS files
|
44
|
+
|
45
|
+
You may want to run rbs-collection-update to update the contents of `rbs_collection.lock.yaml`, when you add a new gem or some gems are updated.
|
46
|
+
|
47
|
+
You also need to run rbs-collection-update when the RBS files in the source repository are updated. The type definitions are updated with bug-fixes or improvements, and you need to update to apply the changes to your app.
|
48
|
+
|
49
|
+
## Using rbs-collection with Steep
|
50
|
+
|
51
|
+
Steep automatically reads `rb_collection.yaml`. You can use Steep immediately without any modifications.
|
52
|
+
|
53
|
+
```
|
54
|
+
$ bin/steep project # Show dependencies detected by Steep
|
55
|
+
$ bin/steep check # Run type check
|
56
|
+
```
|
57
|
+
|
58
|
+
## Migration
|
59
|
+
|
60
|
+
If you have used older versions of Steep or RBS, you may have configured libraries manually.
|
61
|
+
|
62
|
+
* You have library calls in `Steepfile`
|
63
|
+
* You have git submodules in your git repository to download gem_rbs_collection
|
64
|
+
|
65
|
+
These are steps to migrate to rbs-collection.
|
66
|
+
|
67
|
+
1. Remove unnecessary library configuration
|
68
|
+
2. Set up rbs-collection
|
69
|
+
3. Validate the configuration
|
70
|
+
4. Delete submodule
|
71
|
+
|
72
|
+
### 1. Remove unnecessary library configurations
|
73
|
+
|
74
|
+
You may have `#library` calls in your Steepfile, or something equivalent in your scripts. We can group the configured libraries into three groups.
|
75
|
+
|
76
|
+
1. Gems that is managed by Bundler
|
77
|
+
2. Standard libraries (non-gem libs, default gems, and bundled gems)
|
78
|
+
* 2-1) That is a transitive dependency from libraries in group 1
|
79
|
+
* 2-2) That is included in Gemfile.lock
|
80
|
+
* 2-3) Implicitly installed gems – not included in Gemfile
|
81
|
+
|
82
|
+
You can delete library configs of 1, 2-1, and 2-2. So, you have to keep the configurations of libraries in 2-3.
|
83
|
+
|
84
|
+
Practically, you can remove all library configs and `#library` calls in `Steepfile`, go step 2, run steep check to test, and restore the configs of libraries if error is detected.
|
85
|
+
|
86
|
+
### 2. Set up rbs-collection
|
87
|
+
|
88
|
+
See the quick start section above!
|
89
|
+
|
90
|
+
### 3. Validate the configuration
|
91
|
+
|
92
|
+
Run steep check to validate the configuration.
|
93
|
+
|
94
|
+
```
|
95
|
+
$ bin/steep check
|
96
|
+
```
|
97
|
+
|
98
|
+
You may see the `UnknownTypeName` error if some libraries are missing. Or some errors that implies duplication or inconsistency of methods/class/module definitions, that may be caused by loading RBS files of a library twice.
|
99
|
+
|
100
|
+
Running steep project may help you too. It shows the source files and library directories recognized by Steep.
|
101
|
+
|
102
|
+
```
|
103
|
+
$ bin/steep project
|
104
|
+
```
|
105
|
+
|
106
|
+
Note that the type errors detected may change after migrating to rbs-collection, because it typically includes updating to newer versions of RBS files.
|
107
|
+
|
108
|
+
### 4. Delete submodule
|
109
|
+
|
110
|
+
After you confirmed everything is working correctly, you can delete the submodule. deinit the submodule, remove the directory using git-rm, and delete $GIT_DIR/modules/<name>/.
|
111
|
+
|
112
|
+
## What is the rbs_collection.yaml file?
|
113
|
+
|
114
|
+
The file mainly defines three properties – sources, gems and path. Sources is essential when you want to create a new RBS file repository, usually for RBS files of the private gems.
|
115
|
+
|
116
|
+
Another trick is ignoring RBS files of type checker toolchain. RBS and Steep ships with their own RBS files. However, these RBS files may be unnecessary for you, unless you are not a type checking toolchain developer. It requires some additional gems and RBS files. So adding ignore: true is recommended for the gems.
|
117
|
+
|
118
|
+
It seems like we need to add a feature to skip loading RBS files automatically and use the feature for RBS, Steep, and RBS Rails. It looks weird that the gems section is only used to ignore gems.
|
119
|
+
|
120
|
+
## Versions of RBS files in gem_rbs_collection
|
121
|
+
|
122
|
+
Gem versions in rbs-collection are relatively loosely managed. If a gem is found but the version is different, rbs-collection simply uses the incorrect version.
|
123
|
+
|
124
|
+
It will load RBS files of activerecord/6.1 even if your Gemfile specifies activerecord-7.0.4. This is by design with an assumption that having RBS files with some incompatibility is better than having nothing. We see most APIs are compatible even after major version upgrade. Dropping everything for minor API incompatibilities would not make much sense.
|
125
|
+
|
126
|
+
That behavior will change in future versions when we see that the assumption is not reasonable and we have better coverage.
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# Getting Started with Steep in 5 minutes
|
2
|
+
|
3
|
+
## Installing Steep
|
4
|
+
|
5
|
+
Add the lines to Gemfile:
|
6
|
+
|
7
|
+
```rb
|
8
|
+
group :development do
|
9
|
+
gem "steep", require: false
|
10
|
+
end
|
11
|
+
```
|
12
|
+
|
13
|
+
and install the gems.
|
14
|
+
|
15
|
+
```
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Alternatively, you can install it with the gem command.
|
20
|
+
|
21
|
+
```
|
22
|
+
$ gem install steep
|
23
|
+
```
|
24
|
+
|
25
|
+
Execute the following command to confirm if the command is successfully installed.
|
26
|
+
|
27
|
+
```
|
28
|
+
$ steep version
|
29
|
+
$ bundle exec steep version # If you install with bundler
|
30
|
+
```
|
31
|
+
|
32
|
+
We omit the `bundle exec` prefix from the following commands. Run commands with the prefix if you installed Steep with Bundler.
|
33
|
+
|
34
|
+
## Type checking your first Ruby script
|
35
|
+
|
36
|
+
Run the `steep init` command to generate the configuration file, `Steepfile`.
|
37
|
+
|
38
|
+
```
|
39
|
+
$ steep init
|
40
|
+
```
|
41
|
+
|
42
|
+
Open `Steepfile` in your text editor, and replace the content with the following lines:
|
43
|
+
|
44
|
+
```rb
|
45
|
+
target :lib do
|
46
|
+
signature "sig"
|
47
|
+
check "lib"
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
Type the following Ruby code in your editor, and save it as `lib/hello.rb`.
|
52
|
+
|
53
|
+
```rb
|
54
|
+
currencies = { US: "$", JP: "¥", UK: "£" }
|
55
|
+
country = %w(US JP UK).sample()
|
56
|
+
|
57
|
+
puts "Hello! The price is #{currencies[country]}100. 💸"
|
58
|
+
```
|
59
|
+
|
60
|
+
And type check it with Steep.
|
61
|
+
|
62
|
+
```
|
63
|
+
$ steep check
|
64
|
+
```
|
65
|
+
|
66
|
+
The output will report a type error.
|
67
|
+
|
68
|
+
```
|
69
|
+
# Type checking files:
|
70
|
+
|
71
|
+
........................................................F
|
72
|
+
|
73
|
+
lib/hello.rb:4:39: [error] Cannot pass a value of type `(::String | nil)` as an argument of type `::Symbol`
|
74
|
+
│ (::String | nil) <: ::Symbol
|
75
|
+
│ ::String <: ::Symbol
|
76
|
+
│ ::Object <: ::Symbol
|
77
|
+
│ ::BasicObject <: ::Symbol
|
78
|
+
│
|
79
|
+
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
|
80
|
+
│
|
81
|
+
└ puts "Hello! The price is #{currencies[country]}100. 💸"
|
82
|
+
~~~~~~~
|
83
|
+
|
84
|
+
Detected 1 problem from 1 file
|
85
|
+
```
|
86
|
+
|
87
|
+
The error says that the type of the country variable causes a type error. It is expected to be a symbol, but string or `nil` will be given.
|
88
|
+
|
89
|
+
Let's see how we can fix the error.
|
90
|
+
|
91
|
+
## Fixing the type error
|
92
|
+
|
93
|
+
The first step is converting the string value to a symbol. We can add a `#to_sym` call.
|
94
|
+
|
95
|
+
```rb
|
96
|
+
currencies = { US: "$", JP: "¥", UK: "£" }
|
97
|
+
country = %w(US JP UK).sample()
|
98
|
+
|
99
|
+
puts "Hello! The price is #{currencies[country.to_sym]}100. 💸"
|
100
|
+
```
|
101
|
+
|
102
|
+
The `#to_sym` call will convert a string into a symbol. Does it solve the problem??
|
103
|
+
|
104
|
+
```
|
105
|
+
$ steep check
|
106
|
+
# Type checking files:
|
107
|
+
|
108
|
+
........................................................F
|
109
|
+
|
110
|
+
lib/hello.rb:4:47: [error] Type `(::String | nil)` does not have method `to_sym`
|
111
|
+
│ Diagnostic ID: Ruby::NoMethod
|
112
|
+
│
|
113
|
+
└ puts "Hello! The price is #{currencies[country.to_sym]}100. 💸"
|
114
|
+
~~~~~~
|
115
|
+
|
116
|
+
Detected 1 problem from 1 file
|
117
|
+
```
|
118
|
+
|
119
|
+
It detects another problem. The first error was `Ruby::ArgumentTypeMismatch`, but the new error is `Ruby::NoMethod`. The value of `country` may be `nil`, and it doesn't have the `#to_sym` method.
|
120
|
+
|
121
|
+
This would be annoying, but one of the most common sources of a type error. The value of an expression may be `nil` unexpectedly, and using the value of the expression may cause an error.
|
122
|
+
|
123
|
+
In this case, the `sample()` call introduces the `nil`. `Array#sample()` returns `nil` when the array is empty. We know the receiver of the `sample` call cannot be `nil`, because it is an array literal. But the type checker doesn't know of it. The source code detects the `Array#sample()` method is called, but it ignores the fact that the receiver cannot be empty.
|
124
|
+
|
125
|
+
Instead, we can simply tell the type checker that the value of the country cannot be `nil`.
|
126
|
+
|
127
|
+
# Satisfying the type checker by adding a guard
|
128
|
+
|
129
|
+
The underlying type system supports flow-sensitive typing similar to TypeScript and Rust. It detects conditional expressions testing the value of a variable and propagates the knowledge that the value cannot be `nil`.
|
130
|
+
|
131
|
+
We can fix the type error with an `or` construct.
|
132
|
+
|
133
|
+
```rb
|
134
|
+
currencies = { US: "$", JP: "¥", UK: "£" }
|
135
|
+
country = %w(US JP UK).sample() or raise
|
136
|
+
|
137
|
+
puts "Hello! The price is #{currencies[country.to_sym]}100. 💸"
|
138
|
+
```
|
139
|
+
|
140
|
+
The change lets the type checking succeed.
|
141
|
+
|
142
|
+
```
|
143
|
+
$ steep check
|
144
|
+
# Type checking files:
|
145
|
+
|
146
|
+
.........................................................
|
147
|
+
|
148
|
+
No type error detected. 🫖
|
149
|
+
```
|
150
|
+
|
151
|
+
The `raise` method is called when `sample()` returns `nil`. Steep can reason the possible control flow based on the semantics of or in Ruby:
|
152
|
+
|
153
|
+
* The value of `country` is the return value of `sample()` method call
|
154
|
+
* The value of `country` may be `nil`
|
155
|
+
* If the value of `country` is `nil`, the right hand side of the or is evaluated
|
156
|
+
* It calls the `raise` method, which results in an exception and jumps to somewhere
|
157
|
+
* So, when the execution continues to the puts line, the value of `country` cannot be `nil`
|
158
|
+
|
159
|
+
There are two possibilities of the type of the result of the `sample()` call, `nil` or a string. We humans can reason that we can safely ignore the case of `nil`. But, the type checker cannot. We have to add `or raise` to tell the type checker it can stop considering the case of `nil` safely.
|
160
|
+
|
161
|
+
## Next steps
|
162
|
+
|
163
|
+
This is a really quick introduction to using Steep. You may have noticed that I haven't explained anything about defining new classes or modules. See the RBS guide for more examples!
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# nil and Optional Types
|
2
|
+
|
3
|
+
`nil`s have been the most common source of the errors you see when testing and after the deployment of your apps.
|
4
|
+
|
5
|
+
```
|
6
|
+
NoMethodError (undefined method `save!' for nil:NilClass)
|
7
|
+
```
|
8
|
+
|
9
|
+
Steep/RBS provides *optional types* to help you identify the problems while you are coding.
|
10
|
+
|
11
|
+
## Preventing `nil` problems
|
12
|
+
|
13
|
+
Technically, there is only one way to prevent `nil` problems – test everytime if the value is `nil` before calling methods with the receiver.
|
14
|
+
|
15
|
+
```rb
|
16
|
+
if account
|
17
|
+
account.save!
|
18
|
+
end
|
19
|
+
```
|
20
|
+
|
21
|
+
Using the `if` statement is the most popular way to ensure the value is not `nil`. But you can do it with safe-navigation-operators, case-when (or case-in), and `#try` method.
|
22
|
+
|
23
|
+
```rb
|
24
|
+
account&.save!
|
25
|
+
|
26
|
+
case account
|
27
|
+
when Account
|
28
|
+
account.save!
|
29
|
+
end
|
30
|
+
|
31
|
+
account.try {|account| account.save! }
|
32
|
+
```
|
33
|
+
|
34
|
+
It's simple, but not easy to do in your code.
|
35
|
+
|
36
|
+
**You may forget testing.** This happens easily. You don't notice that you forget until you deploy the code into production and it crashes after loading an account that is old and satisfies complicated conditions that leads it to be `nil`.
|
37
|
+
|
38
|
+
**You may add redundant guards.** This won't get your app crash, but it will make understanding your code more difficult. It adds unnecessary noise that tells your teammates this can be `nil` in some place, and results in another redundant guard.
|
39
|
+
|
40
|
+
The `nil` problems can be solved by a tool that tells you:
|
41
|
+
|
42
|
+
* If the value can be `nil` or not, and
|
43
|
+
* You forget testing the value before using the value
|
44
|
+
|
45
|
+
RBS has a language construct to do this called optional types and Steep implements analysis to let you know if you forget testing the value.
|
46
|
+
|
47
|
+
## Optional types
|
48
|
+
|
49
|
+
Optional types in RBS are denoted with a suffix `?` – Type?. It means the value of the type may be `nil`.
|
50
|
+
|
51
|
+
```
|
52
|
+
Integer? # Integer or nil
|
53
|
+
Array[Account]? # An array of Account or nil
|
54
|
+
```
|
55
|
+
|
56
|
+
Note that optional types can be included in other types as:
|
57
|
+
|
58
|
+
```
|
59
|
+
Array[Account?]
|
60
|
+
```
|
61
|
+
|
62
|
+
The value of the type above is always an array, but the element may be `nil`.
|
63
|
+
|
64
|
+
In other words, a non optional type in RBS means the value cannot be `nil`.
|
65
|
+
|
66
|
+
```
|
67
|
+
Integer # Integer, cannot be nil
|
68
|
+
Array[Account] # An array, cannot be nil
|
69
|
+
```
|
70
|
+
|
71
|
+
Let's see how Steep reports errors on optional and non-optional types.
|
72
|
+
|
73
|
+
```rb
|
74
|
+
account = Account.find(1)
|
75
|
+
account.save!
|
76
|
+
```
|
77
|
+
|
78
|
+
Assume the type of `account` is `Account` (non optional type), the code type checks successfully. There is no chance to be `nil` here. The `save!` method call never results in a `NoMethodError`.
|
79
|
+
|
80
|
+
```rb
|
81
|
+
account = Account.find_by(email: "soutaro@squareup.com")
|
82
|
+
account.save!
|
83
|
+
```
|
84
|
+
|
85
|
+
Steep reports a `NoMethod` error on the `save!` call. Because the value of the `account` may be `nil`, depending on the actual records in the `accounts` table. You cannot call the `save!` method without checking if the `account` is `nil`.
|
86
|
+
|
87
|
+
You cannot assign `nil` to a local variable with non-optional types.
|
88
|
+
|
89
|
+
```rb
|
90
|
+
# @type var account: Account
|
91
|
+
|
92
|
+
account = nil
|
93
|
+
account = Account.find_by(email: "soutaro@squareup.com")
|
94
|
+
```
|
95
|
+
|
96
|
+
Because the type of `account` is declared Account, non-optional type, it cannot be `nil`. And Steep detects a type error if you try to assign `nil`. Same for assigning an optional type value at the last line.
|
97
|
+
|
98
|
+
# Unwrapping optional types
|
99
|
+
|
100
|
+
There are several ways to unwrap optional types. The most common one is using if.
|
101
|
+
|
102
|
+
```rb
|
103
|
+
account = Account.find_by(id: 1)
|
104
|
+
if account
|
105
|
+
account.save!
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
The *if* statement tests if `account` is `nil`. Inside the then clause, `account` cannot be `nil`. Then Steep type checks the code.
|
110
|
+
|
111
|
+
This works for *else* clause of *unless*.
|
112
|
+
|
113
|
+
```rb
|
114
|
+
account = Account.find_by(id: 1)
|
115
|
+
unless account
|
116
|
+
# Do something
|
117
|
+
else
|
118
|
+
account.save!
|
119
|
+
end
|
120
|
+
```
|
121
|
+
|
122
|
+
This also type checks successfully.
|
123
|
+
|
124
|
+
Steep supports `nil?` predicate too.
|
125
|
+
|
126
|
+
```rb
|
127
|
+
unless (account = Account.find_by(id: 1)).nil?
|
128
|
+
account.save!
|
129
|
+
end
|
130
|
+
```
|
131
|
+
|
132
|
+
This assumes the `Account` class doesn't have a custom `nil?` method, but keeps the built-in `nil?` or equivalent.
|
133
|
+
|
134
|
+
The last one is using safe-nevigation-navigator. It checks if the receiver is `nil` and calls the method if it is not. Otherwise just evaluates to `nil`.
|
135
|
+
|
136
|
+
```rb
|
137
|
+
account = Account.find_by(id: 1)
|
138
|
+
account&.save!
|
139
|
+
```
|
140
|
+
|
141
|
+
This is a shorthand for the case you don't do any error handling case if it is `nil`.
|
142
|
+
|
143
|
+
## What should I do for the case of `nil`?
|
144
|
+
|
145
|
+
There is no universal answer for this question. You may just stop the execution of the method by returning. You may want to insert a new account to ensure the record exists. Raising an exception with a detailed error message will help troubleshooting.
|
146
|
+
|
147
|
+
It depends on what the program is expected to do. Steep just checks if accessing `nil` may happen or not. The developers only know how to handle the `nil` cases.
|
148
|
+
|
149
|
+
# Handling unwanted `nil`s
|
150
|
+
|
151
|
+
When you start using Steep, you may see many unwanted `nil`s. This typically happens when you want to use Array methods, like `first` or `sample`.
|
152
|
+
|
153
|
+
```rb
|
154
|
+
account = accounts.first
|
155
|
+
account.save!
|
156
|
+
```
|
157
|
+
|
158
|
+
It returns `nil` if the array is empty. Steep cannot detect if the array is empty or not, and it conservatively assumes the return value of the methods may be `nil`. While you know the `account` array is not empty, Steep infer the `first` method may return `nil`.
|
159
|
+
|
160
|
+
This is one of the most frequently seen sources of unwanted `nil`s.
|
161
|
+
|
162
|
+
## Raising an error
|
163
|
+
|
164
|
+
In this case, you have to add an extra code to let Steep unwrap it.
|
165
|
+
|
166
|
+
```rb
|
167
|
+
account = accounts.first or raise
|
168
|
+
account.save!
|
169
|
+
```
|
170
|
+
|
171
|
+
My recommendation is to raise an exception, `|| raise` or `or raise`. It raises an exception in the case of `nil`, and Steep unwraps the type of the `account` variable.
|
172
|
+
|
173
|
+
Exceptions are better than other control flow operators – `return`/`break`/`next`. It doesn't affect the control flow until it actually happens during execution, and the type checking result other than the unwrapping is changed.
|
174
|
+
|
175
|
+
An `#raise` call without argument is my favorite. It's short. It's uncommon in the Ruby code and it can tell the readers that something unexpected is happening.
|
176
|
+
|
177
|
+
But of course, you can add some message:
|
178
|
+
|
179
|
+
```rb
|
180
|
+
account = accounts.first or raise("accounts cannot be empty")
|
181
|
+
account.save!
|
182
|
+
```
|
183
|
+
|
184
|
+
## Type assertions
|
185
|
+
|
186
|
+
You can also use a type assertion, that is introduced in Steep 1.3.
|
187
|
+
|
188
|
+
```rb
|
189
|
+
account = accounts.first #: Account
|
190
|
+
account.save!
|
191
|
+
```
|
192
|
+
|
193
|
+
It tells Steep that the right hand side of the assignment is `Account`. That overwrites the type checking algorithm, and the developer is responsible for making sure the value cannot be `nil`.
|
194
|
+
|
195
|
+
Note: Nothing happens during the execution. It just works for Steep and Ruby doesn't do any extra type checking on it. I recommend using the `or raise` idiom for most of the cases.
|
@@ -0,0 +1,199 @@
|
|
1
|
+
module Steep
|
2
|
+
class AnnotationParser
|
3
|
+
VAR_NAME = /[a-z][A-Za-z0-9_]*/
|
4
|
+
METHOD_NAME = Regexp.union(
|
5
|
+
/[^.:\s]+/
|
6
|
+
)
|
7
|
+
CONST_NAME = Regexp.union(
|
8
|
+
/(::)?([A-Z][A-Za-z0-9_]*::)*[A-Z][A-Za-z0-9_]*/
|
9
|
+
)
|
10
|
+
DYNAMIC_NAME = /(self\??\.)?#{METHOD_NAME}/
|
11
|
+
IVAR_NAME = /@[^:\s]+/
|
12
|
+
|
13
|
+
attr_reader :factory
|
14
|
+
|
15
|
+
def initialize(factory:)
|
16
|
+
@factory = factory
|
17
|
+
end
|
18
|
+
|
19
|
+
class SyntaxError < StandardError
|
20
|
+
attr_reader :source
|
21
|
+
attr_reader :location
|
22
|
+
|
23
|
+
def initialize(source:, location:, exn: nil, message: nil)
|
24
|
+
@source = source
|
25
|
+
@location = location
|
26
|
+
|
27
|
+
if exn
|
28
|
+
message =
|
29
|
+
case exn
|
30
|
+
when RBS::ParsingError
|
31
|
+
Diagnostic::Signature::SyntaxError.parser_syntax_error_message(exn)
|
32
|
+
else
|
33
|
+
exn.message
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
super message
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
TYPE = /(?<type>.*)/
|
42
|
+
COLON = /\s*:\s*/
|
43
|
+
|
44
|
+
PARAM = /[A-Z][A-Za-z0-9_]*/
|
45
|
+
TYPE_PARAMS = /(\[(?<params>#{PARAM}(,\s*#{PARAM})*)\])?/
|
46
|
+
|
47
|
+
def parse_type(match, name = :type, location:)
|
48
|
+
string = match[name] or raise
|
49
|
+
st, en = match.offset(name)
|
50
|
+
st or raise
|
51
|
+
en or raise
|
52
|
+
loc = RBS::Location.new(location.buffer, location.start_pos + st, location.start_pos + en)
|
53
|
+
|
54
|
+
type =
|
55
|
+
begin
|
56
|
+
RBS::Parser.parse_type(string)
|
57
|
+
rescue RBS::ParsingError => exn
|
58
|
+
raise SyntaxError.new(source: string, location: loc, exn: exn)
|
59
|
+
end or raise
|
60
|
+
|
61
|
+
unless (type.location || raise).source == string.strip
|
62
|
+
raise SyntaxError.new(source: string, location: loc, message: "Failed to parse a type in annotation")
|
63
|
+
end
|
64
|
+
|
65
|
+
factory.type(type)
|
66
|
+
end
|
67
|
+
|
68
|
+
def keyword_subject_type(keyword, name)
|
69
|
+
/@type\s+#{keyword}\s+(?<name>#{name})#{COLON}#{TYPE}/
|
70
|
+
end
|
71
|
+
|
72
|
+
def keyword_and_type(keyword)
|
73
|
+
/@type\s+#{keyword}#{COLON}#{TYPE}/
|
74
|
+
end
|
75
|
+
|
76
|
+
def parse(src, location:)
|
77
|
+
case src
|
78
|
+
when keyword_subject_type("var", VAR_NAME)
|
79
|
+
Regexp.last_match.yield_self do |match|
|
80
|
+
match or raise
|
81
|
+
name = match[:name] or raise
|
82
|
+
|
83
|
+
AST::Annotation::VarType.new(name: name.to_sym,
|
84
|
+
type: parse_type(match, location: location),
|
85
|
+
location: location)
|
86
|
+
end
|
87
|
+
|
88
|
+
when keyword_subject_type("method", METHOD_NAME)
|
89
|
+
Regexp.last_match.yield_self do |match|
|
90
|
+
match or raise
|
91
|
+
name = match[:name] or raise
|
92
|
+
type = match[:type] or raise
|
93
|
+
|
94
|
+
method_type = factory.method_type(RBS::Parser.parse_method_type(type) || raise)
|
95
|
+
|
96
|
+
AST::Annotation::MethodType.new(name: name.to_sym,
|
97
|
+
type: method_type,
|
98
|
+
location: location)
|
99
|
+
end
|
100
|
+
|
101
|
+
when keyword_subject_type("const", CONST_NAME)
|
102
|
+
Regexp.last_match.yield_self do |match|
|
103
|
+
match or raise
|
104
|
+
name = match[:name] or raise
|
105
|
+
type = parse_type(match, location: location)
|
106
|
+
|
107
|
+
AST::Annotation::ConstType.new(name: RBS::TypeName.parse(name), type: type, location: location)
|
108
|
+
end
|
109
|
+
|
110
|
+
when keyword_subject_type("ivar", IVAR_NAME)
|
111
|
+
Regexp.last_match.yield_self do |match|
|
112
|
+
match or raise
|
113
|
+
name = match[:name] or raise
|
114
|
+
type = parse_type(match, location: location)
|
115
|
+
|
116
|
+
AST::Annotation::IvarType.new(name: name.to_sym,
|
117
|
+
type: type,
|
118
|
+
location: location)
|
119
|
+
end
|
120
|
+
|
121
|
+
when keyword_and_type("return")
|
122
|
+
Regexp.last_match.yield_self do |match|
|
123
|
+
match or raise
|
124
|
+
type = parse_type(match, location: location)
|
125
|
+
AST::Annotation::ReturnType.new(type: type, location: location)
|
126
|
+
end
|
127
|
+
|
128
|
+
when keyword_and_type("block")
|
129
|
+
Regexp.last_match.yield_self do |match|
|
130
|
+
match or raise
|
131
|
+
type = parse_type(match, location: location)
|
132
|
+
AST::Annotation::BlockType.new(type: type, location: location)
|
133
|
+
end
|
134
|
+
|
135
|
+
when keyword_and_type("self")
|
136
|
+
Regexp.last_match.yield_self do |match|
|
137
|
+
match or raise
|
138
|
+
type = parse_type(match, location: location)
|
139
|
+
AST::Annotation::SelfType.new(type: type, location: location)
|
140
|
+
end
|
141
|
+
|
142
|
+
when keyword_and_type("instance")
|
143
|
+
Regexp.last_match.yield_self do |match|
|
144
|
+
match or raise
|
145
|
+
type = parse_type(match, location: location)
|
146
|
+
AST::Annotation::InstanceType.new(type: type, location: location)
|
147
|
+
end
|
148
|
+
|
149
|
+
when keyword_and_type("module")
|
150
|
+
Regexp.last_match.yield_self do |match|
|
151
|
+
match or raise
|
152
|
+
type = parse_type(match, location: location)
|
153
|
+
AST::Annotation::ModuleType.new(type: type, location: location)
|
154
|
+
end
|
155
|
+
|
156
|
+
when keyword_and_type("break")
|
157
|
+
Regexp.last_match.yield_self do |match|
|
158
|
+
match or raise
|
159
|
+
type = parse_type(match, location: location)
|
160
|
+
|
161
|
+
AST::Annotation::BreakType.new(type: type, location: location)
|
162
|
+
end
|
163
|
+
|
164
|
+
when /@dynamic\s+(?<names>(#{DYNAMIC_NAME}\s*,\s*)*#{DYNAMIC_NAME})/
|
165
|
+
Regexp.last_match.yield_self do |match|
|
166
|
+
match or raise
|
167
|
+
names = (match[:names] || raise).split(/\s*,\s*/)
|
168
|
+
|
169
|
+
AST::Annotation::Dynamic.new(
|
170
|
+
names: names.map {|name|
|
171
|
+
case
|
172
|
+
when name.delete_prefix!("self.")
|
173
|
+
AST::Annotation::Dynamic::Name.new(name: name.to_sym, kind: :module)
|
174
|
+
when name.delete_prefix!("self?.")
|
175
|
+
AST::Annotation::Dynamic::Name.new(name: name.to_sym, kind: :module_instance)
|
176
|
+
else
|
177
|
+
AST::Annotation::Dynamic::Name.new(name: name.to_sym, kind: :instance)
|
178
|
+
end
|
179
|
+
},
|
180
|
+
location: location
|
181
|
+
)
|
182
|
+
end
|
183
|
+
|
184
|
+
when /@implements\s+(?<name>#{CONST_NAME})#{TYPE_PARAMS}$/
|
185
|
+
Regexp.last_match.yield_self do |match|
|
186
|
+
match or raise
|
187
|
+
type_name = RBS::TypeName.parse(match[:name] || raise)
|
188
|
+
params = match[:params]&.yield_self {|params| params.split(/,/).map {|param| param.strip.to_sym } } || []
|
189
|
+
|
190
|
+
name = AST::Annotation::Implements::Module.new(name: type_name, args: params)
|
191
|
+
AST::Annotation::Implements.new(name: name, location: location)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
rescue RBS::ParsingError => exn
|
196
|
+
raise SyntaxError.new(source: src, location: location, exn: exn)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|