-
Notifications
You must be signed in to change notification settings - Fork 0
Add Object#not_nil! assertion
#7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| TypeToolkit/DontExpectUnexpectedNil: | ||
| Description: "Detects misuse of UnexpectedNilError (rescuing, raising, or asserting it)." | ||
| Enabled: true | ||
| VersionAdded: "0.1.0" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require "rubocop" | ||
|
|
||
| RuboCop::ConfigLoader.inject_defaults!(File.join(__dir__, "..", "config", "default.yml")) | ||
|
|
||
| require_relative "rubocop/cop/type_toolkit/dont_expect_unexpected_nil" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| # typed: true | ||
| # frozen_string_literal: true | ||
|
|
||
| module RuboCop | ||
| module Cop | ||
| module TypeToolkit | ||
| # This cop detects attempts to raise, rescue, or otherwise use the `UnexpectedNilError` class. | ||
| class DontExpectUnexpectedNil < Base | ||
| RESTRICT_ON_SEND = [:assert_raises, :raise].freeze | ||
|
|
||
| #: (RuboCop::AST::SendNode) -> void | ||
| def on_send(node) | ||
| case node.method_name | ||
| # when :raise then check_raise(node) | ||
| when :assert_raises then check_assert_raises(node) | ||
| end | ||
| end | ||
|
|
||
| #: (RuboCop::AST::ResbodyNode) -> void | ||
| def on_resbody(node) | ||
| if (rescued_cls = node.exceptions.find { |ex_class| ex_class.const_type? && unexpected_nil_error?(ex_class) }) | ||
| message = "It is always a mistake for `not_nil!` to be called on nil, " \ | ||
| "so you should never try to rescue `UnexpectedNilError` specifically. " \ | ||
| "Change your code to gracefully handle `nil` instead." | ||
| add_offense(rescued_cls, message:) | ||
| ignore_const_node(rescued_cls) | ||
| end | ||
| end | ||
|
|
||
| # This is a catch-all for cases where the `UnexpectedNilError` class is used outside of a raise, rescue, etc. | ||
| #: (RuboCop::AST::ConstNode) -> void | ||
| def on_const(node) | ||
| # Don't report this node right away, in case its parent AST is reported by one of the other cases. | ||
| # Instead, record it for now, and maybe report it at the end of the investigation. | ||
| if unexpected_nil_error?(node) | ||
| @const_read_nodes ||= Set.new.compare_by_identity | ||
| @const_read_nodes << node | ||
| end | ||
| end | ||
|
|
||
| # @override | ||
| #: -> void | ||
| def on_investigation_end | ||
| @const_read_nodes&.each do |node| | ||
| next if @ignored_const_nodes&.include?(node) | ||
|
|
||
| message = "`UnexpectedNilError` should only ever be used by `#not_nil!`." | ||
| add_offense(node, message:) | ||
| end | ||
|
|
||
| super | ||
| end | ||
|
|
||
| private | ||
|
|
||
| #: (RuboCop::AST::ConstNode) -> bool | ||
| def unexpected_nil_error?(node) | ||
| node.short_name == :UnexpectedNilError && (node.namespace.nil? || node.namespace.cbase_type?) | ||
| end | ||
|
|
||
| # Check for `raise UnexpectedNilError` | ||
| #: (RuboCop::AST::SendNode) -> void | ||
| def check_raise(node) | ||
amomchilov marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| constant = case (first_arg = node.arguments.first) | ||
| when RuboCop::AST::ConstNode | ||
| node.arguments.first | ||
| when RuboCop::AST::SendNode | ||
| return unless first_arg.method_name == :new && first_arg.receiver.const_type? | ||
|
|
||
| first_arg.receiver | ||
| else return | ||
| end | ||
|
|
||
| if unexpected_nil_error?(constant) | ||
| message = "`UnexpectedNilError` should only ever be raised by `NilClass#not_nil!`." | ||
| add_offense(node, message:) | ||
| ignore_const_node(constant) | ||
| end | ||
| end | ||
|
|
||
| # Check for `assert_raises UnexpectedNilError` | ||
| #: (RuboCop::AST::SendNode) -> void | ||
| def check_assert_raises(node) | ||
| if (constants = node.arguments.filter { |arg| arg.const_type? && unexpected_nil_error?(arg) }).any? | ||
| message = "It is always a mistake for `not_nil!` to be called on nil, " \ | ||
| "so tests should not expect any code to raise `UnexpectedNilError`. " \ | ||
| "Change your code to gracefully handle `nil` instead." | ||
| add_offense(node, message:) | ||
| ignore_const_node(*constants) | ||
| end | ||
| end | ||
|
|
||
| # Call this when this constant node is part of a node tree that already has an offense. | ||
| # This way we don't report a second offense for the same constant. | ||
| #: (*RuboCop::AST::ConstNode) -> void | ||
| def ignore_const_node(*nodes) | ||
| @ignored_const_nodes ||= Set.new.compare_by_identity | ||
| @ignored_const_nodes.merge(nodes) | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was initially thinking following in the footsteps of Active Support, https://guides.rubyonrails.org/active_support_core_extensions.html#loading-all-active-support , but decided against and changed this in #13 |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||
| # typed: true | ||||||
| # frozen_string_literal: true | ||||||
|
|
||||||
| # Asserts that the receiver is not nil. | ||||||
| # | ||||||
| # You should use `not_nil!` in places where you're absolutely sure a `nil` value can't occur. | ||||||
| # This should be done as closely to where the value is created as possible, so that the `nil` | ||||||
| # value doesn't have a chance to be passed around the system. This way, failures occur close to | ||||||
| # the source of the problem, and are easier to fix. | ||||||
| module Kernel | ||||||
| #: -> self | ||||||
| def not_nil! | ||||||
| self | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| class NilClass | ||||||
| # @override | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does this need override?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's not strictly necessary. But this is indeed an override, since |
||||||
| #: -> bot | ||||||
| def not_nil! | ||||||
| raise UnexpectedNilError | ||||||
| end | ||||||
| end | ||||||
|
|
||||||
| # An error raised when calling `#not_nil!` on a `nil` value. | ||||||
| # | ||||||
| # `UnexpectedNilError` should never occur in well-formed code, so it should never be rescued. | ||||||
| # This is why it inherits from `Exception` instead of `StandardError`, | ||||||
| # so that bare rescues clauses (like `rescue => e`) don't rescue it. | ||||||
| class UnexpectedNilError < Exception # rubocop:disable Lint/InheritException | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we want this to be namespaced or are we happy having it at the root?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm leaning towards namespaced. My dream is for this gem to be common/standard enough that it's worthy of owning top-level constants (to look "clean" and "native" 😎), but I won't get too big for my britches, so I'll make it namespaced. |
||||||
| def initialize(message = "Called `not_nil!` on nil.") | ||||||
| super(message) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can't is just be
Suggested change
|
||||||
| end | ||||||
| end | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # typed: true | ||
| # frozen_string_literal: true | ||
|
|
||
| module RuboCop | ||
| module Minitest | ||
| module AssertOffense | ||
| sig { params(source: String).void } | ||
| def assert_offense(source); end | ||
|
|
||
| sig { params(source: String).void } | ||
| def assert_no_offenses(source); end | ||
|
|
||
| sig { params(source: String).void } | ||
| def assert_correction(source); end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # typed: true | ||
| # frozen_string_literal: true | ||
|
|
||
| require "spec_helper" | ||
|
|
||
| module TypeToolkit | ||
| class NilAssertionsTest < Minitest::Spec | ||
| describe "#not_nil!" do | ||
| it "returns self on non-nil values" do | ||
| x = "Hello, world!" | ||
| assert_same x, x.not_nil! | ||
| end | ||
|
|
||
| it "raises an error on nil values" do | ||
| assert_raises(UnexpectedNilError) { nil.not_nil! } | ||
| end | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| # typed: true | ||
| # frozen_string_literal: true | ||
|
|
||
| require "spec_helper" | ||
| require "rubocop" | ||
| require "rubocop/minitest/assert_offense" | ||
| require "rubocop-type_toolkit" | ||
|
|
||
| module RuboCop | ||
| module Cop | ||
| module TypeToolkit | ||
| describe DontExpectUnexpectedNil do | ||
| include RuboCop::Minitest::AssertOffense | ||
|
|
||
| before do | ||
| @cop = DontExpectUnexpectedNil.new | ||
| end | ||
|
|
||
| describe "assert_raises with UnexpectedNilError" do | ||
| it "adds offense when assert_raises is used with UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| assert_raises(UnexpectedNilError) { foo } | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when assert_raises is used with ::UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| assert_raises(::UnexpectedNilError) { foo } | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when assert_raises is used with do...end block" do | ||
| assert_offense(<<~RUBY) | ||
| assert_raises(UnexpectedNilError) do | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} | ||
| foo | ||
| end | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when UnexpectedNilError is among other arguments" do | ||
| assert_offense(<<~RUBY) | ||
| assert_raises(ArgumentError, UnexpectedNilError) { foo } | ||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ #{assert_raises_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "does not add offense when assert_raises uses a different error" do | ||
| assert_no_offenses(<<~RUBY) | ||
| assert_raises(ArgumentError) { foo } | ||
| RUBY | ||
| end | ||
| end | ||
|
|
||
| describe "rescuing UnexpectedNilError" do | ||
| it "adds offense when rescuing UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| begin | ||
| foo | ||
| rescue UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^ #{rescue_message} | ||
| bar | ||
| end | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when rescuing ::UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| begin | ||
| foo | ||
| rescue ::UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^^^ #{rescue_message} | ||
| bar | ||
| end | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when rescuing UnexpectedNilError among other exceptions" do | ||
| assert_offense(<<~RUBY) | ||
| begin | ||
| foo | ||
| rescue UnexpectedNilError, ArgumentError | ||
| ^^^^^^^^^^^^^^^^^^ #{rescue_message} | ||
| bar | ||
| end | ||
| RUBY | ||
| end | ||
|
|
||
| it "does not add offense when rescuing other exceptions" do | ||
| assert_no_offenses(<<~RUBY) | ||
| begin | ||
| foo | ||
| rescue ArgumentError | ||
| bar | ||
| end | ||
| RUBY | ||
| end | ||
| end | ||
|
|
||
| describe "raising UnexpectedNilError" do | ||
| it "adds offense when raising UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| raise UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when raising UnexpectedNilError with a message" do | ||
| assert_offense(<<~RUBY) | ||
| raise UnexpectedNilError, "message" | ||
| ^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when raising UnexpectedNilError.new" do | ||
| assert_offense(<<~RUBY) | ||
| raise UnexpectedNilError.new | ||
| ^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when raising ::UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| raise ::UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "does not add offense when raising other exceptions" do | ||
| assert_no_offenses(<<~RUBY) | ||
| raise ArgumentError | ||
| RUBY | ||
| end | ||
| end | ||
|
|
||
| describe "other usages of UnexpectedNilError" do | ||
| it "adds offense when using UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| x = UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "adds offense when using ::UnexpectedNilError" do | ||
| assert_offense(<<~RUBY) | ||
| x = ::UnexpectedNilError | ||
| ^^^^^^^^^^^^^^^^^^^^ #{general_usage_message} | ||
| RUBY | ||
| end | ||
|
|
||
| it "does not add offense when using other constants" do | ||
| assert_no_offenses(<<~RUBY) | ||
| x = ArgumentError | ||
| x = ::ArgumentError | ||
| RUBY | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def assert_raises_message | ||
| "TypeToolkit/DontExpectUnexpectedNil: It is always a mistake for `not_nil!` to be called on nil, " \ | ||
| "so tests should not expect any code to raise `UnexpectedNilError`. " \ | ||
| "Change your code to gracefully handle `nil` instead." | ||
| end | ||
|
|
||
| def rescue_message | ||
| "TypeToolkit/DontExpectUnexpectedNil: It is always a mistake for `not_nil!` to be called on nil, " \ | ||
| "so you should never try to rescue `UnexpectedNilError` specifically. " \ | ||
| "Change your code to gracefully handle `nil` instead." | ||
| end | ||
|
|
||
| def general_usage_message | ||
| "TypeToolkit/DontExpectUnexpectedNil: `UnexpectedNilError` should only ever be used by `#not_nil!`." | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we still need
raisehere? Look like we commented the branch?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The branch shouldn't be commented. And the tests should have caught that. 🤦♂️