Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,10 @@ AllCops:
TargetRubyVersion: 3.2
NewCops: enable

Naming/FileName:
Exclude:
# This file matches RuboCop naming conventions, like `rubocop-rails`, `rubocop-sorbet`, etc.
- lib/rubocop-type_toolkit.rb

Style/Semicolon:
AllowAsExpressionSeparator: true
4 changes: 4 additions & 0 deletions config/default.yml
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"
7 changes: 7 additions & 0 deletions lib/rubocop-type_toolkit.rb
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"
103 changes: 103 additions & 0 deletions lib/rubocop/cop/type_toolkit/dont_expect_unexpected_nil.rb
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need raise here? Look like we commented the branch?

Copy link
Contributor Author

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. 🤦‍♂️


#: (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)
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
Empty file added lib/type_toolkit/all.rb
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Empty file.
34 changes: 34 additions & 0 deletions lib/type_toolkit/ext/nil_assertions.rb
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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need override?

Copy link
Contributor Author

@amomchilov amomchilov Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not strictly necessary. @override isn't unless the parent is explicitly marked @overrideable (whether you use RBS or sig {} syntax).

But this is indeed an override, since NilClass < Kernel, so I opted to document that fact

#: -> 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't is just be super?

Suggested change
super(message)
super

end
end
17 changes: 17 additions & 0 deletions sorbet/rbi/shims/rubocop_minitest.rbi
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
19 changes: 19 additions & 0 deletions spec/nil_assertions.rb
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
181 changes: 181 additions & 0 deletions spec/rubocop/cop/type_toolkit/dont_expect_unexpected_nil_spec.rb
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
2 changes: 1 addition & 1 deletion spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# frozen_string_literal: true

$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "type_toolkit"
require "type_toolkit/all"

require "minitest/autorun"
require "minitest/spec"
Loading